@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.6
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 +59 -13
- package/manifest.schema.json +87 -9
- package/package.json +1 -1
- package/src/builder/pipeline.ts +55 -2
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +3 -3
- package/src/core/steps.ts +29 -6
- package/src/ctx/index.ts +3 -0
- package/src/ctx/types.ts +4 -1
- package/src/describe/describe.ts +182 -8
- package/src/describe/manifest-schema.ts +36 -3
- package/src/dev/cli.ts +10 -3
- package/src/harness/execute.ts +7 -4
- package/src/harness/main.ts +45 -6
- package/src/harness/validate.ts +19 -1
- package/src/index.ts +3 -2
- package/src/library/compose-deploy.ts +93 -0
- package/src/library/github-source.ts +23 -4
- package/src/library/helm-deploy-ssh.ts +166 -58
- package/src/library/helm-deploy.ts +268 -0
- package/src/library/image-build.ts +223 -39
- package/src/library/index.ts +7 -2
- package/src/library/models.ts +7 -1
- package/src/library/npm.ts +33 -0
- package/src/testing/index.ts +5 -3
package/README.md
CHANGED
|
@@ -13,9 +13,9 @@ The manifest format is in [`manifest.schema.json`](manifest.schema.json), genera
|
|
|
13
13
|
```
|
|
14
14
|
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 (script, gitHubSource, imageBuild, helmDeployOverSsh), their exported helpers (httpHealthCheck) and SDK models
|
|
18
|
+
src/library/ library steps (script, gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy), their exported helpers (httpHealthCheck, registryTag, npmDistTag) and SDK models
|
|
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
|
|
@@ -108,6 +108,40 @@ Rules:
|
|
|
108
108
|
- A **plain-schema input** has no model to match on, so the step needs a mapper.
|
|
109
109
|
- **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
|
|
110
110
|
|
|
111
|
+
### Config
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { config, secret, secretRef, step, z } from "@olivervea/pipelines";
|
|
117
|
+
|
|
118
|
+
export const DEX_CLIENT_SECRET = secret("DEX_CLIENT_SECRET", "Dex client secret.");
|
|
119
|
+
export const AUTH = config("auth", "How users authenticate.", z.discriminatedUnion("mode", [
|
|
120
|
+
z.object({ mode: z.literal("none") }),
|
|
121
|
+
z.object({ mode: z.literal("dex"), issuer: z.string().url(), clientSecret: secretRef(DEX_CLIENT_SECRET) }),
|
|
122
|
+
]));
|
|
123
|
+
export const TARGET = config("target", "Where the release is deployed.",
|
|
124
|
+
z.object({ host: z.string(), namespace: z.string().default("olve-pipelines") }));
|
|
125
|
+
|
|
126
|
+
export const deploy = step({ name: "deploy", description: "Deploys the release.", image: "alpine:3.22",
|
|
127
|
+
config: [AUTH, TARGET], // ctx.config is typed to exactly these
|
|
128
|
+
secrets: [DEX_CLIENT_SECRET] }); // a secretRef's secret must be declared too
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
# the instance's config document for this pipeline
|
|
133
|
+
auth: { mode: dex, issuer: https://dex.example.com, clientSecret: { secret: DEX_CLIENT_SECRET } }
|
|
134
|
+
target: { host: deploy@box }
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `config(name, description, schema)` mirrors `secret(...)`. Names match `^[a-z][a-zA-Z0-9]*$` (they are YAML keys).
|
|
138
|
+
- 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.
|
|
139
|
+
- Handlers see `ctx.config` with the output view (defaults applied); a discriminated union narrows as usual.
|
|
140
|
+
- `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.
|
|
141
|
+
- Config is per **run**: the run pins a config snapshot at start; continues and step reruns reuse it. Secrets are per attempt.
|
|
142
|
+
- Library steps 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 referenced entry (`configEntries` in the manifest); `manifestConfig` records each reference as `{"$config": "<entry>.<path>"}` (field names must not contain `.`; literal keys starting with `$` are reserved); `run()` resolves them from `ctx.config` (defaults applied) before doing anything. `describe` and reconcile reject a reference to an entry the step doesn't receive or a field the entry doesn't have.
|
|
143
|
+
- `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.
|
|
144
|
+
|
|
111
145
|
**`steps.script` contract** (for the script):
|
|
112
146
|
|
|
113
147
|
| Name | What it holds |
|
|
@@ -125,14 +159,16 @@ Library steps carry their own implementation and image; they take typed config a
|
|
|
125
159
|
| Step | Image | Input → output | Config |
|
|
126
160
|
|---|---|---|---|
|
|
127
161
|
| `steps.script` | yours | yours | `script` (embedded), `env` (see above) |
|
|
128
|
-
| `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
|
|
129
|
-
| `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; default the root), `dockerfile?` (relative to the context; default `Dockerfile`), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `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) |
|
|
130
|
-
| `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) | `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" }`), `fromRegistry?`, `deployments?` (default `[release]`), `rolloutTimeoutSeconds?` (default 120), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
|
|
162
|
+
| `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`) |
|
|
163
|
+
| `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; default the root), `dockerfile?` (relative to the context; default `Dockerfile`), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `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), `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) |
|
|
164
|
+
| `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) | `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" }`), `fromRegistry?`, `imageRefs?` (`PushedImage` input key → values prefix: set as `<prefix>.repository`/`.tag`/`.digest`, not pulled; works next to tarball `images`), `deployments?` (default `[release]`), `rolloutTimeoutSeconds?` (default 120), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
|
|
165
|
+
| `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` |
|
|
166
|
+
| `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?` |
|
|
131
167
|
|
|
132
|
-
- `gitHubSource` downloads
|
|
168
|
+
- `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
|
|
133
169
|
- `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.
|
|
134
170
|
- **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.
|
|
135
|
-
- **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;
|
|
171
|
+
- **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, base-image and cache pulls from it are logged in. `insecureRegistry` makes the lookups plain HTTP and adds kaniko `--insecure --insecure-registry=<host>`.
|
|
136
172
|
- `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. Several images (one chart, two Deployments):
|
|
137
173
|
|
|
138
174
|
```ts
|
|
@@ -141,6 +177,9 @@ Library steps carry their own implementation and image; they take typed config a
|
|
|
141
177
|
images: { server: "image", web: "web.image" }, deployments: ["olve-slimerepublics-server", "olve-slimerepublics-web"] });
|
|
142
178
|
```
|
|
143
179
|
|
|
180
|
+
- **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`.
|
|
181
|
+
- `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).
|
|
182
|
+
|
|
144
183
|
**Helpers.** General functions the library steps are built on, exported for custom handlers:
|
|
145
184
|
|
|
146
185
|
| Helper | What it does |
|
|
@@ -180,6 +219,7 @@ export default implement(deployBeta, async ({ artifact }, ctx) => {
|
|
|
180
219
|
| `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
220
|
| `retry({ attempts, delaySeconds, backoff? }, fn)` | Retries `fn`. Can be cancelled. |
|
|
182
221
|
| `secrets.NAME` | Typed to the step's declared secrets. Read from the env var `NAME`. Throws if the variable is missing. |
|
|
222
|
+
| `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
223
|
| `log(message, fields?)` | Writes a `message k=v …` line to stderr. |
|
|
184
224
|
| `signal` | Aborted on SIGTERM/SIGINT. Running commands are sent SIGTERM. |
|
|
185
225
|
| `run` | `{ pipeline, id, number, attempt, step, version }` (from the env, see below). |
|
|
@@ -206,6 +246,7 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
|
|
|
206
246
|
- **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
247
|
- **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
248
|
- **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.
|
|
249
|
+
- **`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).
|
|
209
250
|
- **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
251
|
|
|
211
252
|
### Testing handlers
|
|
@@ -214,7 +255,7 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
|
|
|
214
255
|
import { testHandler, testFileRef } from "@olivervea/pipelines/testing";
|
|
215
256
|
import deployBeta from "../handlers/deploy-beta";
|
|
216
257
|
|
|
217
|
-
const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, ctx: { http: fakeHttp }, run: { number: 412 } });
|
|
258
|
+
const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, config: { target: { host: "h" } }, ctx: { http: fakeHttp }, run: { number: 412 } });
|
|
218
259
|
expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
|
|
219
260
|
```
|
|
220
261
|
|
|
@@ -242,7 +283,7 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
242
283
|
|
|
243
284
|
| Path | Who | What |
|
|
244
285
|
|---|---|---|
|
|
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`). |
|
|
286
|
+
| `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`. **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
287
|
| `work/output.json` | harness | The envelope. Any stale copy is deleted at start, and the new file is written atomically. |
|
|
247
288
|
| `work/files/<object path>` | harness | Output files and directory tars (see "File references"). |
|
|
248
289
|
| `tmp/` | harness | Scratch space. Point `TMPDIR` here. |
|
|
@@ -259,6 +300,7 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
259
300
|
| `OLVE_RUN` | run | Run number (integer ≥ 0). Written into produced file refs. Default 0. |
|
|
260
301
|
| `OLVE_ATTEMPT` | run | Attempt number (integer ≥ 1). Written into produced file refs. Default 1. |
|
|
261
302
|
| `OLVE_VERSION_STAMP` | run | Derived version, e.g. `2026.10.21.412` → `ctx.run.version`. |
|
|
303
|
+
| `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
304
|
| `<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
305
|
| `OLVE_RESULT_URL` | optional | Pre-signed **PUT** for `result.tar.gz` (see below). Unset means no upload. |
|
|
264
306
|
| `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). |
|
|
@@ -293,7 +335,7 @@ For `map-trigger`, `output` is the pipeline input after it has been parsed by th
|
|
|
293
335
|
| 0 | ok (succeeded or skipped) | yes |
|
|
294
336
|
| 1 | the handler or trigger mapper threw | failed / `handler` |
|
|
295
337
|
| 64 | usage: unknown mode, step or trigger | none |
|
|
296
|
-
| 65 | `input.json` missing, not JSON, or invalid for the step (or event) | failed / `input` |
|
|
338
|
+
| 65 | `input.json` missing, not JSON, or invalid for the step (or event), including its `$config` | failed / `input` |
|
|
297
339
|
| 66 | the return value failed strict validation, or an own file ref is missing or changed | failed / `output` |
|
|
298
340
|
| 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
341
|
| 78 | the pipeline is invalid (describe problems, or a missing or wrong handler module) | none |
|
|
@@ -327,6 +369,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
327
369
|
- **`apiVersion`** is `"2.0"`.
|
|
328
370
|
- **`harness`** is `{ bun, sdk, sdkHash }`.
|
|
329
371
|
- **`models[name]`** is `{ output, input? }`.
|
|
372
|
+
- **Step `kind`** is `custom` or the library step's name (`imageBuild`, `composeDeploy`, …). To the service it is a label: any non-empty kind is accepted, so a new library step needs no service change.
|
|
330
373
|
- `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
|
|
331
374
|
- `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
375
|
- **Step `input`** maps each input key to a mapping:
|
|
@@ -336,7 +379,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
336
379
|
|
|
337
380
|
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
381
|
- **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
|
|
339
|
-
- **`handlerHash`** is the step's implementation identity. A step version can be computed from image + `inputTypes`/model versions + `output` model version + `secrets` + `input` mappings + `handlerHash`. Stage placement, `when` and the step's `description` are not part of it.
|
|
382
|
+
- **`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.
|
|
340
383
|
- 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.
|
|
341
384
|
- Library steps: `sha256(canonical {library: kind, config, harness})`.
|
|
342
385
|
- Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
|
|
@@ -347,6 +390,8 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
347
390
|
- `{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
391
|
- **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
|
|
349
392
|
- **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
|
|
393
|
+
- **`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.
|
|
394
|
+
- **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 library-step configuration.
|
|
350
395
|
|
|
351
396
|
Describe runs these checks; reconcile should re-run the manifest-level ones:
|
|
352
397
|
|
|
@@ -358,6 +403,7 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
|
|
|
358
403
|
- `when` on an unknown or non-literal field, or with an invalid value;
|
|
359
404
|
- a poll header referencing an undeclared secret, duplicate triggers;
|
|
360
405
|
- conflicting secret descriptions;
|
|
406
|
+
- config names, one description and schema per config name, a step receiving a `secretRef` without declaring its secret, and input keys starting with `$` (reserved);
|
|
361
407
|
- an `onFailure` scope naming an unknown step, and failure-handler inputs.
|
|
362
408
|
|
|
363
409
|
## Build tool
|
|
@@ -397,11 +443,11 @@ The manifest is the text between the sentinels in the pod log. The manifest text
|
|
|
397
443
|
|
|
398
444
|
```
|
|
399
445
|
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]...
|
|
446
|
+
olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
401
447
|
olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
|
|
402
448
|
```
|
|
403
449
|
|
|
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.
|
|
450
|
+
`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`.
|
|
405
451
|
|
|
406
452
|
## Development
|
|
407
453
|
|
package/manifest.schema.json
CHANGED
|
@@ -47,6 +47,42 @@
|
|
|
47
47
|
"const": "2.0",
|
|
48
48
|
"type": "string"
|
|
49
49
|
},
|
|
50
|
+
"config": {
|
|
51
|
+
"description": "Config entries used by steps, sorted by name (omitted when none). Fields of kind secretRef ({secret: NAME}) name a secret.",
|
|
52
|
+
"items": {
|
|
53
|
+
"additionalProperties": false,
|
|
54
|
+
"properties": {
|
|
55
|
+
"description": {
|
|
56
|
+
"type": "string"
|
|
57
|
+
},
|
|
58
|
+
"name": {
|
|
59
|
+
"description": "Config entry name; a key of the pipeline's config document.",
|
|
60
|
+
"pattern": "^[a-z][a-zA-Z0-9]*$",
|
|
61
|
+
"type": "string"
|
|
62
|
+
},
|
|
63
|
+
"required": {
|
|
64
|
+
"description": "False when the schema accepts a missing value (optional, or a default).",
|
|
65
|
+
"type": "boolean"
|
|
66
|
+
},
|
|
67
|
+
"schema": {
|
|
68
|
+
"additionalProperties": {},
|
|
69
|
+
"description": "z.input view: validates the config document's value for this entry (defaults listed).",
|
|
70
|
+
"propertyNames": {
|
|
71
|
+
"type": "string"
|
|
72
|
+
},
|
|
73
|
+
"type": "object"
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"required": [
|
|
77
|
+
"name",
|
|
78
|
+
"description",
|
|
79
|
+
"required",
|
|
80
|
+
"schema"
|
|
81
|
+
],
|
|
82
|
+
"type": "object"
|
|
83
|
+
},
|
|
84
|
+
"type": "array"
|
|
85
|
+
},
|
|
50
86
|
"description": {
|
|
51
87
|
"type": "string"
|
|
52
88
|
},
|
|
@@ -297,22 +333,70 @@
|
|
|
297
333
|
"stages"
|
|
298
334
|
],
|
|
299
335
|
"type": "object"
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"additionalProperties": false,
|
|
339
|
+
"properties": {
|
|
340
|
+
"cases": {
|
|
341
|
+
"additionalProperties": {
|
|
342
|
+
"items": {
|
|
343
|
+
"$ref": "#/$defs/Stage"
|
|
344
|
+
},
|
|
345
|
+
"type": "array"
|
|
346
|
+
},
|
|
347
|
+
"description": "Discriminator value → stages. Exactly one case runs; a step name may repeat across cases, nowhere else.",
|
|
348
|
+
"propertyNames": {
|
|
349
|
+
"type": "string"
|
|
350
|
+
},
|
|
351
|
+
"type": "object"
|
|
352
|
+
},
|
|
353
|
+
"discriminator": {
|
|
354
|
+
"description": "The union's literal field whose value names the case.",
|
|
355
|
+
"minLength": 1,
|
|
356
|
+
"type": "string"
|
|
357
|
+
},
|
|
358
|
+
"select": {
|
|
359
|
+
"description": "Config reference (`<entry>.<field>…`) to a discriminated union; the run's config snapshot picks the case.",
|
|
360
|
+
"minLength": 1,
|
|
361
|
+
"type": "string"
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
"required": [
|
|
365
|
+
"select",
|
|
366
|
+
"discriminator",
|
|
367
|
+
"cases"
|
|
368
|
+
],
|
|
369
|
+
"type": "object"
|
|
300
370
|
}
|
|
301
371
|
]
|
|
302
372
|
},
|
|
303
373
|
"Step": {
|
|
304
374
|
"additionalProperties": false,
|
|
305
375
|
"properties": {
|
|
376
|
+
"clusterAccess": {
|
|
377
|
+
"const": true,
|
|
378
|
+
"description": "The step pod gets the instance's step ServiceAccount token (Kubernetes:StepServiceAccount), if the instance grants one. Omitted = no token.",
|
|
379
|
+
"type": "boolean"
|
|
380
|
+
},
|
|
306
381
|
"config": {
|
|
307
382
|
"additionalProperties": {
|
|
308
383
|
"$ref": "#/$defs/Json"
|
|
309
384
|
},
|
|
310
|
-
"description": "Library-step configuration (display only; already covered by handlerHash).",
|
|
385
|
+
"description": "Library-step configuration (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries.",
|
|
311
386
|
"propertyNames": {
|
|
312
387
|
"type": "string"
|
|
313
388
|
},
|
|
314
389
|
"type": "object"
|
|
315
390
|
},
|
|
391
|
+
"configEntries": {
|
|
392
|
+
"description": "Config entries the step receives (sorted; omitted when none). The service delivers them in input.json under '$config'.",
|
|
393
|
+
"items": {
|
|
394
|
+
"description": "Config entry name; a key of the pipeline's config document.",
|
|
395
|
+
"pattern": "^[a-z][a-zA-Z0-9]*$",
|
|
396
|
+
"type": "string"
|
|
397
|
+
},
|
|
398
|
+
"type": "array"
|
|
399
|
+
},
|
|
316
400
|
"description": {
|
|
317
401
|
"description": "What the step does, shown in the UI (may be empty).",
|
|
318
402
|
"type": "string"
|
|
@@ -348,14 +432,8 @@
|
|
|
348
432
|
"type": "object"
|
|
349
433
|
},
|
|
350
434
|
"kind": {
|
|
351
|
-
"description": "'custom' = handlers/<name>.ts; others are SDK library steps.",
|
|
352
|
-
"
|
|
353
|
-
"custom",
|
|
354
|
-
"script",
|
|
355
|
-
"gitHubSource",
|
|
356
|
-
"imageBuild",
|
|
357
|
-
"helmDeployOverSsh"
|
|
358
|
-
],
|
|
435
|
+
"description": "'custom' = handlers/<name>.ts; others are SDK library steps. A label: the service accepts any kind.",
|
|
436
|
+
"minLength": 1,
|
|
359
437
|
"type": "string"
|
|
360
438
|
},
|
|
361
439
|
"name": {
|
package/package.json
CHANGED
package/src/builder/pipeline.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { IsUnion, OrError, Simplify } from "../core/types.ts";
|
|
|
8
8
|
import type { Optional } from "../core/models.ts";
|
|
9
9
|
import type { FailureContext } from "../library/models.ts";
|
|
10
10
|
import type { MapperResult, Ref } from "./refs.ts";
|
|
11
|
+
import { isConfigRef, narrowConfigRef, unionAt, type ConfigNode, type ConfigRef, type DiscriminatorOf, type VariantOf } from "../core/config.ts";
|
|
11
12
|
|
|
12
13
|
// ─── Availability (builder state, type level) ────────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -96,6 +97,29 @@ type OutputsOf<E extends readonly unknown[], Maybe extends boolean> = {
|
|
|
96
97
|
};
|
|
97
98
|
type MakeMaybe<X extends AvailMap> = { [K in keyof X]: Slot<X[K]["model"], X[K]["value"], true> };
|
|
98
99
|
|
|
100
|
+
// ─── select (branching on config) ────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
type KeysOfUnion<T> = T extends unknown ? keyof T : never;
|
|
103
|
+
type SlotIn<B, K extends PropertyKey> = B extends { [P in K]: infer X extends Slot } ? X : never;
|
|
104
|
+
/** A case function: the pipeline so far, and the select's reference narrowed to the case's variant. */
|
|
105
|
+
export type SelectCase<In extends AnyModel, A extends AvailMap, R> = (p: Pipeline<In, A>, ref: R) => Pipeline<In, AvailMap>;
|
|
106
|
+
export type SelectCases<In extends AnyModel, A extends AvailMap, U, D extends string> = {
|
|
107
|
+
readonly [V in Extract<U[D & keyof U], string>]: SelectCase<In, A, ConfigNode<VariantOf<U, D, V>>>;
|
|
108
|
+
};
|
|
109
|
+
type CaseAvail<C> = { [K in keyof C]: C[K] extends (...args: never[]) => Pipeline<AnyModel, infer B> ? B : never }[keyof C];
|
|
110
|
+
/**
|
|
111
|
+
* After a select: a step every case has is as available as in its cases (one case always runs);
|
|
112
|
+
* a step only some cases have is maybe-available.
|
|
113
|
+
*/
|
|
114
|
+
export type AfterSelect<A extends AvailMap, B> = Simplify<
|
|
115
|
+
A &
|
|
116
|
+
{ [K in Exclude<keyof B, keyof A>]: Slot<SlotIn<B, K>["model"], SlotIn<B, K>["value"], SlotIn<B, K>["maybe"]> } &
|
|
117
|
+
{ [K in Exclude<KeysOfUnion<B>, keyof B | keyof A>]: Slot<SlotIn<B, K>["model"], SlotIn<B, K>["value"], true> }
|
|
118
|
+
>;
|
|
119
|
+
type SelectOnError<U> = [DiscriminatorOf<U>] extends [never]
|
|
120
|
+
? "❌ select needs a reference to a discriminated union in config (z.discriminatedUnion)"
|
|
121
|
+
: IsUnion<DiscriminatorOf<U>> extends true ? "❌ the union has more than one literal field; select can't tell which is the discriminator" : never;
|
|
122
|
+
|
|
99
123
|
// ─── Conditions / triggers ───────────────────────────────────────────────────────────────────
|
|
100
124
|
|
|
101
125
|
type IsLiteralLike<T> = [T] extends [string | number | boolean]
|
|
@@ -134,7 +158,8 @@ export interface StageStepNode {
|
|
|
134
158
|
}
|
|
135
159
|
export type StageNode =
|
|
136
160
|
| { readonly kind: "stage"; readonly steps: readonly StageStepNode[] }
|
|
137
|
-
| { readonly kind: "when"; readonly cond: Readonly<Record<string, unknown>>; readonly nodes: readonly StageNode[] }
|
|
161
|
+
| { readonly kind: "when"; readonly cond: Readonly<Record<string, unknown>>; readonly nodes: readonly StageNode[] }
|
|
162
|
+
| { readonly kind: "select"; readonly on: ConfigRef; readonly cases: Readonly<Record<string, readonly StageNode[]>> };
|
|
138
163
|
|
|
139
164
|
export type TriggerNode =
|
|
140
165
|
| { readonly kind: "push"; readonly name: string; readonly map: (event: unknown) => unknown }
|
|
@@ -300,6 +325,34 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
300
325
|
return this.next();
|
|
301
326
|
}
|
|
302
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Branch on config: `on` references a discriminated union in a config entry, and each case gets the
|
|
330
|
+
* pipeline and `on` narrowed to its variant. Exactly one case runs, chosen by the run's config
|
|
331
|
+
* snapshot (not by run input; that is `when`). The same step name may appear in several cases.
|
|
332
|
+
*/
|
|
333
|
+
select<U, const C extends SelectCases<In, A, U, DiscriminatorOf<U>>>(
|
|
334
|
+
on: ConfigRef<U> & ([SelectOnError<U>] extends [never] ? unknown : SelectOnError<U>),
|
|
335
|
+
cases: C & { readonly [K in Exclude<keyof C, keyof SelectCases<In, A, U, DiscriminatorOf<U>>>]: `❌ '${K & string}' is not a case of this select` },
|
|
336
|
+
): Pipeline<In, AfterSelect<A, CaseAvail<C>>> {
|
|
337
|
+
const data = this[PIPELINE];
|
|
338
|
+
const recorded: Record<string, StageNode[]> = {};
|
|
339
|
+
const union = isConfigRef(on) ? unionAt(on) : undefined;
|
|
340
|
+
if (!union) data.problems.push("select: the first argument must reference a z.discriminatedUnion in a config entry");
|
|
341
|
+
for (const [value, fn] of Object.entries(cases as Record<string, unknown>)) {
|
|
342
|
+
if (typeof fn !== "function") {
|
|
343
|
+
data.problems.push(`select: case '${value}' is not a function`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const inner = new Pipeline<In, A>({ name: data.name, input: data.input as In }, data);
|
|
347
|
+
const returned = (fn as (p: unknown, ref: unknown) => unknown)(inner, union ? narrowConfigRef(on, union.discriminator, value) : on);
|
|
348
|
+
if (!isPipeline(returned) || pipelineData(returned) !== inner[PIPELINE])
|
|
349
|
+
data.problems.push(`select: case '${value}' must return the pipeline it was given ((p, ref) => p.stage(...))`);
|
|
350
|
+
recorded[value] = inner[PIPELINE].nodes;
|
|
351
|
+
}
|
|
352
|
+
data.nodes.push({ kind: "select", on, cases: recorded });
|
|
353
|
+
return this.next();
|
|
354
|
+
}
|
|
355
|
+
|
|
303
356
|
/** Run `handler` (a step whose inputs are all FailureContext) when one of `steps` (default: any) fails. Untracked, best-effort. */
|
|
304
357
|
onFailure<S extends AnyStep>(
|
|
305
358
|
handler: S &
|
|
@@ -307,7 +360,7 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
307
360
|
? unknown
|
|
308
361
|
: "❌ a failure handler's inputs must all be FailureContext"),
|
|
309
362
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
310
|
-
opts?: { steps?: readonly Step<StepNames<A>, any, any, any>[] },
|
|
363
|
+
opts?: { steps?: readonly Step<StepNames<A>, any, any, any, any>[] },
|
|
311
364
|
): this {
|
|
312
365
|
this[PIPELINE].failureHandlers.push({ step: handler as AnyStep, steps: opts?.steps ? [...(opts.steps as readonly AnyStep[])] : null });
|
|
313
366
|
return this.next();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Config references inside library-step definitions: collected (the step receives their entries),
|
|
2
|
+
// recorded in the manifest as `{"$config": "<entry>.<path>"}`, and resolved from ctx.config at run time.
|
|
3
|
+
import { configRefPath, configRefTarget, isConfigRef, type AnyConfigEntry, type ConfigRef } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
/** `T` with every config reference replaced by the type of its value. */
|
|
6
|
+
export type Resolved<T> = T extends ConfigRef<infer U>
|
|
7
|
+
? U
|
|
8
|
+
: T extends readonly (infer E)[]
|
|
9
|
+
? Resolved<E>[]
|
|
10
|
+
: T extends (...args: never[]) => unknown
|
|
11
|
+
? T
|
|
12
|
+
: T extends object
|
|
13
|
+
? { [K in keyof T]: Resolved<T[K]> }
|
|
14
|
+
: T;
|
|
15
|
+
|
|
16
|
+
/** Manifest key of a recorded config reference. */
|
|
17
|
+
export const CONFIG_REF_KEY = "$config";
|
|
18
|
+
/** Beside it on a reference narrowed by a `select` case: the case's condition, `<ref>.<discriminator>=<value>`. */
|
|
19
|
+
export const CONFIG_CASE_KEY = "$case";
|
|
20
|
+
|
|
21
|
+
function isPlain(v: unknown): v is Record<string, unknown> {
|
|
22
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
|
23
|
+
const proto = Object.getPrototypeOf(v);
|
|
24
|
+
return proto === Object.prototype || proto === null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every config entry referenced anywhere in `v` (plain objects and arrays only), by name. */
|
|
28
|
+
export function configRefsIn(v: unknown, into = new Map<string, AnyConfigEntry>()): AnyConfigEntry[] {
|
|
29
|
+
if (isConfigRef(v)) {
|
|
30
|
+
const { entry } = configRefTarget(v);
|
|
31
|
+
if (!into.has(entry.name)) into.set(entry.name, entry);
|
|
32
|
+
} else if (Array.isArray(v)) for (const x of v) configRefsIn(x, into);
|
|
33
|
+
else if (isPlain(v)) for (const x of Object.values(v)) configRefsIn(x, into);
|
|
34
|
+
return [...into.values()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `v` as manifest JSON: references become `{"$config": "target.beta.host"}`; literal keys starting with `$` are reserved. */
|
|
38
|
+
export function recordConfigRefs(v: unknown): unknown {
|
|
39
|
+
if (isConfigRef(v)) {
|
|
40
|
+
const { path, case: kase } = configRefTarget(v);
|
|
41
|
+
for (const seg of path) if (seg === "" || seg.includes(".")) throw new Error(`config reference '${configRefPath(v)}': field names must not be empty or contain '.'`);
|
|
42
|
+
return { [CONFIG_REF_KEY]: configRefPath(v), ...(kase ? { [CONFIG_CASE_KEY]: kase } : {}) };
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(v)) return v.map(recordConfigRefs);
|
|
45
|
+
if (isPlain(v)) {
|
|
46
|
+
const out: Record<string, unknown> = {};
|
|
47
|
+
for (const [k, x] of Object.entries(v)) {
|
|
48
|
+
if (k.startsWith("$")) throw new Error(`key '${k}': keys starting with '$' are reserved`);
|
|
49
|
+
out[k] = recordConfigRefs(x);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `v` with every reference replaced by its value in `config` (the step's parsed config entries). */
|
|
57
|
+
export function resolveConfigRefs<T>(v: T, config: Readonly<Record<string, unknown>>): Resolved<T> {
|
|
58
|
+
if (isConfigRef(v)) {
|
|
59
|
+
const { entry, path } = configRefTarget(v);
|
|
60
|
+
if (!Object.hasOwn(config, entry.name)) throw new Error(`config entry '${entry.name}' was not delivered to the step`);
|
|
61
|
+
let cur: unknown = config[entry.name];
|
|
62
|
+
for (const seg of path) cur = cur !== null && typeof cur === "object" ? (cur as Record<string, unknown>)[seg] : undefined;
|
|
63
|
+
return cur as Resolved<T>;
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(v)) return v.map((x) => resolveConfigRefs(x, config)) as Resolved<T>;
|
|
66
|
+
if (isPlain(v)) {
|
|
67
|
+
const out: Record<string, unknown> = {};
|
|
68
|
+
for (const [k, x] of Object.entries(v)) out[k] = resolveConfigRefs(x, config);
|
|
69
|
+
return out as Resolved<T>;
|
|
70
|
+
}
|
|
71
|
+
return v as Resolved<T>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Literal `v` (no reference inside), so define-time checks can run on it. */
|
|
75
|
+
export function isLiteral(v: unknown): boolean {
|
|
76
|
+
return configRefsIn(v).length === 0;
|
|
77
|
+
}
|