@olivervea/pipelines 2.0.0-beta.7 → 2.0.0-beta.8
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 +36 -31
- package/manifest.schema.json +3 -3
- package/package.json +1 -1
- package/src/build/build.ts +9 -7
- package/src/build/hashes.ts +31 -2
- package/src/build/project.ts +7 -0
- package/src/core/brands.ts +2 -0
- package/src/core/config.ts +1 -1
- package/src/core/handlers.ts +14 -8
- package/src/core/origin.ts +20 -0
- package/src/core/steps.ts +28 -20
- package/src/describe/describe.ts +37 -32
- package/src/describe/manifest-schema.ts +3 -3
- package/src/dev/cli.ts +5 -5
- package/src/harness/main.ts +5 -6
- package/src/index.ts +5 -3
- package/src/library/compose-deploy.ts +42 -39
- package/src/library/github-source.ts +45 -41
- package/src/library/helm-deploy-ssh.ts +57 -55
- package/src/library/helm-deploy.ts +73 -70
- package/src/library/image-build.ts +113 -169
- package/src/library/index.ts +5 -8
- package/src/library/models.ts +2 -1
- package/src/testing/index.ts +6 -12
- package/src/util/handler-hash.ts +3 -3
- package/src/library/npm.ts +0 -33
- package/src/library/script.ts +0 -85
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ sdk/
|
|
|
15
15
|
src/index.ts authoring API (`@olivervea/pipelines`)
|
|
16
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
|
|
@@ -93,10 +93,6 @@ export const deployBeta = step({
|
|
|
93
93
|
timeoutSeconds: 900, // optional
|
|
94
94
|
retries: 1, // optional: a failed attempt is retried (fresh pod) up to n times
|
|
95
95
|
});
|
|
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
96
|
```
|
|
101
97
|
|
|
102
98
|
Rules:
|
|
@@ -139,37 +135,26 @@ target: { host: deploy@box }
|
|
|
139
135
|
- Handlers see `ctx.config` with the output view (defaults applied); a discriminated union narrows as usual.
|
|
140
136
|
- `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
137
|
- 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
|
|
138
|
+
- 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.
|
|
143
139
|
- `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
140
|
|
|
145
|
-
**`steps.script` contract** (for the script):
|
|
146
|
-
|
|
147
|
-
| Name | What it holds |
|
|
148
|
-
|---|---|
|
|
149
|
-
| `$OLVE_INPUT` | Path to the validated input as JSON. |
|
|
150
|
-
| `INPUT_<KEY>` | One variable per input key. `deployTo` becomes `INPUT_DEPLOY_TO`. Strings are passed raw; everything else is JSON. |
|
|
151
|
-
| `$OLVE_OUTPUT` | Set only when the step has an output model. Write the output JSON to this path. |
|
|
152
|
-
|
|
153
|
-
In the output JSON, an object of the form `{"$file": "<path>"}` or `{"$dir": "<path>"}` becomes a file reference. The step's declared secrets are environment variables named after the secrets.
|
|
154
|
-
|
|
155
141
|
### Library steps
|
|
156
142
|
|
|
157
|
-
Library steps carry their own implementation and image; they take typed
|
|
143
|
+
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.
|
|
158
144
|
|
|
159
145
|
| Step | Image | Input → output | Config |
|
|
160
146
|
|---|---|---|---|
|
|
161
|
-
| `steps.script` | yours | yours | `script` (embedded), `env` (see above) |
|
|
162
147
|
| `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
|
|
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]
|
|
148
|
+
| `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `context?` (dir in the source tree with the `Dockerfile`; default the root), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default: 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) |
|
|
149
|
+
| `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), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
|
|
165
150
|
| `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
151
|
| `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?` |
|
|
167
152
|
|
|
168
153
|
- `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
154
|
+
- `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.
|
|
155
|
+
- **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 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`). 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.
|
|
156
|
+
- **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>`.
|
|
157
|
+
- `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. Several images (one chart, two Deployments):
|
|
173
158
|
|
|
174
159
|
```ts
|
|
175
160
|
steps.helmDeployOverSsh({ name: "deploy-beta", description: "", host: "oliver@bulwark-m2", sshKey: SSH_PRIVATE_KEY,
|
|
@@ -187,13 +172,33 @@ Library steps carry their own implementation and image; they take typed config a
|
|
|
187
172
|
| `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 (v1's deploy-beta loop). Throws the last error when the attempts run out. Runs in the harness, so the image needs no curl. `helmDeployOverSsh`'s `healthUrl` uses it. |
|
|
188
173
|
| `serviceUrl(release)` | `http://<release>.<namespace>.svc.cluster.local` for a `HelmRelease` (charts that name the Service after the release). |
|
|
189
174
|
| `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. |
|
|
190
|
-
| `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.
|
|
175
|
+
| `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. |
|
|
191
176
|
|
|
192
177
|
```ts
|
|
193
178
|
import { httpHealthCheck, implement } from "@olivervea/pipelines";
|
|
194
179
|
export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
|
|
195
180
|
```
|
|
196
181
|
|
|
182
|
+
### Reusable steps
|
|
183
|
+
|
|
184
|
+
A step several pipelines use is a function returning `implement(step(…), handler)`, staged directly: it carries its handler, so it has no `handlers/` file. The library steps are written this way, so a module in a repo or a package of its own works the same.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { implement, resolveConfigRefs, serviceUrl, step, HelmRelease, type Configurable, type LiteralName } from "@olivervea/pipelines";
|
|
188
|
+
|
|
189
|
+
export function smokeTest<const N extends string>(def: { name: LiteralName<N>; description: string; path: Configurable<string> }) {
|
|
190
|
+
return implement(
|
|
191
|
+
step({ name: def.name, description: def.description, kind: "smokeTest", image: "alpine:3.22", input: { release: HelmRelease }, settings: { path: def.path } }),
|
|
192
|
+
async ({ release }, ctx) => void (await ctx.http.expectOk(`${release.url ?? serviceUrl(release)}${resolveConfigRefs(def.path, ctx.config)}`)),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- **`kind`**: a label, required for a step that carries its implementation (`custom` is a step with a handler file). Prefix a package's kinds (`acme/smokeTest`).
|
|
198
|
+
- **`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.
|
|
199
|
+
- **Identity**: `implement` and `step` record the module that called them (from the stack; a tail call can name a module further up, which only adds to what is hashed). The build bundles those modules, minus `pipeline.ts` and `models.ts`, like a handler, and the kind's hash (`kindHashes` in the generated `handlers.ts`) goes into each such step's `handlerHash`. A step that carries its implementation and has no other module (defined and implemented in a contract file) is a build error.
|
|
200
|
+
- `implement` refuses a step that already carries an implementation; a `handlers/*.ts` file for such a step is a describe error.
|
|
201
|
+
|
|
197
202
|
### Handlers
|
|
198
203
|
|
|
199
204
|
```ts
|
|
@@ -259,7 +264,7 @@ const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_K
|
|
|
259
264
|
expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
|
|
260
265
|
```
|
|
261
266
|
|
|
262
|
-
`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`
|
|
267
|
+
`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 handler module's default export, a library step or a reusable step.
|
|
263
268
|
|
|
264
269
|
## Harness contract (service ⇄ step binary)
|
|
265
270
|
|
|
@@ -369,7 +374,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
369
374
|
- **`apiVersion`** is `"2.0"`.
|
|
370
375
|
- **`harness`** is `{ bun, sdk, sdkHash }`.
|
|
371
376
|
- **`models[name]`** is `{ output, input? }`.
|
|
372
|
-
- **Step `kind`** is `custom` or the
|
|
377
|
+
- **Step `kind`** is `custom` (a handler file) or the kind of a step that carries its implementation (`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.
|
|
373
378
|
- `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
|
|
374
379
|
- `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`.
|
|
375
380
|
- **Step `input`** maps each input key to a mapping:
|
|
@@ -381,7 +386,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
381
386
|
- **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
|
|
382
387
|
- **`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.
|
|
383
388
|
- 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.
|
|
384
|
-
-
|
|
389
|
+
- Steps that carry their implementation: `sha256(canonical {library: kind, config, harness, code})`, `code` being the hash of the modules that define and implement steps of that kind (see [Reusable steps](#reusable-steps)).
|
|
385
390
|
- Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
|
|
386
391
|
- **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
|
|
387
392
|
- **`triggers`**:
|
|
@@ -391,13 +396,13 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
391
396
|
- **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
|
|
392
397
|
- **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
|
|
393
398
|
- **`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
|
|
399
|
+
- **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`.
|
|
395
400
|
|
|
396
401
|
Describe runs these checks; reconcile should re-run the manifest-level ones:
|
|
397
402
|
|
|
398
403
|
- step name format and reserved names (`input`, `failure`), and duplicate step names;
|
|
399
404
|
- one schema per model name, and JSON-representable models;
|
|
400
|
-
- missing handler file,
|
|
405
|
+
- missing handler file, a handler file without a step (or for a step that carries its implementation), and a step that carries its implementation without a kind;
|
|
401
406
|
- availability, ambiguity, maybe-available as required, `optional` without a producer, plain schema without a mapper;
|
|
402
407
|
- mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
|
|
403
408
|
- `when` on an unknown or non-literal field, or with an invalid value;
|
|
@@ -413,7 +418,7 @@ olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl]
|
|
|
413
418
|
olve-build <pipelineDir> --hash-only # print handler hashes (diagnostics)
|
|
414
419
|
```
|
|
415
420
|
|
|
416
|
-
1. Pair each `handlers/*.ts` with the step its `implement(step, …)` names, and hash it (see `handlerHash`).
|
|
421
|
+
1. Pair each `handlers/*.ts` with the step its `implement(step, …)` names, and hash it (see `handlerHash`); hash the modules of each kind of step that carries its implementation.
|
|
417
422
|
2. Generate `<pipelineDir>/.olve/gen/handlers.ts` (a registry of literal `import()` paths, plus hashes and harness info) and `entry.ts`. Add `.olve/` to `.gitignore`.
|
|
418
423
|
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.
|
|
419
424
|
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.
|
package/manifest.schema.json
CHANGED
|
@@ -382,7 +382,7 @@
|
|
|
382
382
|
"additionalProperties": {
|
|
383
383
|
"$ref": "#/$defs/Json"
|
|
384
384
|
},
|
|
385
|
-
"description": "
|
|
385
|
+
"description": "The step's settings (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries.",
|
|
386
386
|
"propertyNames": {
|
|
387
387
|
"type": "string"
|
|
388
388
|
},
|
|
@@ -402,7 +402,7 @@
|
|
|
402
402
|
"type": "string"
|
|
403
403
|
},
|
|
404
404
|
"handlerHash": {
|
|
405
|
-
"description": "Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for
|
|
405
|
+
"description": "Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for a step that carries its implementation kind + config + harness + the hash of the code of its kind.",
|
|
406
406
|
"pattern": "^sha256:[0-9a-f]{64}$",
|
|
407
407
|
"type": "string"
|
|
408
408
|
},
|
|
@@ -432,7 +432,7 @@
|
|
|
432
432
|
"type": "object"
|
|
433
433
|
},
|
|
434
434
|
"kind": {
|
|
435
|
-
"description": "'custom' = handlers
|
|
435
|
+
"description": "'custom' = the handler is a handlers/*.ts file; any other kind is a step that carries its implementation (an SDK or package step). A label: the service accepts any kind.",
|
|
436
436
|
"minLength": 1,
|
|
437
437
|
"type": "string"
|
|
438
438
|
},
|
package/package.json
CHANGED
package/src/build/build.ts
CHANGED
|
@@ -7,8 +7,8 @@ import type { HarnessInfo } from "../util/handler-hash.ts";
|
|
|
7
7
|
import { writeTarZst } from "../util/tar.ts";
|
|
8
8
|
import { MANIFEST_BEGIN, MANIFEST_END, uploadFile } from "../harness/result.ts";
|
|
9
9
|
import { GLIBC_INTERP, MUSL_INTERP, readInterpreter } from "./elf.ts";
|
|
10
|
-
import { handlerHashes, type HandlerHash } from "./hashes.ts";
|
|
11
|
-
import { harnessInfo, loadProject, relPosix, type Project } from "./project.ts";
|
|
10
|
+
import { handlerHashes, kindHashes, type HandlerHash } from "./hashes.ts";
|
|
11
|
+
import { harnessInfo, loadPipeline, loadProject, relPosix, type Project } from "./project.ts";
|
|
12
12
|
|
|
13
13
|
export const BUILD_FLAGS = ["--no-compile-autoload-dotenv", "--no-compile-autoload-bunfig", "--bytecode", "--format=esm"] as const;
|
|
14
14
|
export type Target = "bun-linux-x64" | "bun-linux-x64-musl";
|
|
@@ -41,7 +41,7 @@ export interface BuildResult {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** Writes `<dir>/.olve/gen/{handlers,entry}.ts`: the literal-import handler registry + binary entry. */
|
|
44
|
-
export function generateEntry(project: Project, hashes: Record<string, string>, harness: HarnessInfo): string {
|
|
44
|
+
export function generateEntry(project: Project, hashes: Record<string, string>, harness: HarnessInfo, kinds: Record<string, string> = {}): string {
|
|
45
45
|
const gen = join(project.dir, ".olve", "gen");
|
|
46
46
|
mkdirSync(gen, { recursive: true });
|
|
47
47
|
const registry = project.handlers.map(([name, file]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(relPosix(gen, file))}),`).join("\n");
|
|
@@ -51,6 +51,7 @@ export function generateEntry(project: Project, hashes: Record<string, string>,
|
|
|
51
51
|
`import type { HandlerRegistry, HarnessInfo } from "@olivervea/pipelines/harness";\n\n` +
|
|
52
52
|
`export const handlers: HandlerRegistry = {\n${registry}\n};\n\n` +
|
|
53
53
|
`export const handlerHashes: Record<string, string> = ${JSON.stringify(hashes, null, 2)};\n\n` +
|
|
54
|
+
`export const kindHashes: Record<string, string> = ${JSON.stringify(kinds, null, 2)};\n\n` +
|
|
54
55
|
`export const harness: HarnessInfo = ${JSON.stringify(harness, null, 2)};\n`,
|
|
55
56
|
);
|
|
56
57
|
const entry = join(gen, "entry.ts");
|
|
@@ -59,8 +60,8 @@ export function generateEntry(project: Project, hashes: Record<string, string>,
|
|
|
59
60
|
`// GENERATED by olve-build — do not edit. Entry point of the step binary.\n` +
|
|
60
61
|
`import pipeline from ${JSON.stringify(relPosix(gen, project.pipelineFile))};\n` +
|
|
61
62
|
`import { runHarness } from "@olivervea/pipelines/harness";\n` +
|
|
62
|
-
`import { handlers, handlerHashes, harness } from "./handlers.ts";\n\n` +
|
|
63
|
-
`process.exit(await runHarness({ pipeline, handlers, handlerHashes, harness }, process.argv.slice(2)));\n`,
|
|
63
|
+
`import { handlers, handlerHashes, kindHashes, harness } from "./handlers.ts";\n\n` +
|
|
64
|
+
`process.exit(await runHarness({ pipeline, handlers, handlerHashes, kindHashes, harness }, process.argv.slice(2)));\n`,
|
|
64
65
|
);
|
|
65
66
|
return entry;
|
|
66
67
|
}
|
|
@@ -76,7 +77,8 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
|
|
|
76
77
|
|
|
77
78
|
const t0 = performance.now();
|
|
78
79
|
const hashes = await handlerHashes(project, harness);
|
|
79
|
-
const
|
|
80
|
+
const kinds = await kindHashes(project, await loadPipeline(project));
|
|
81
|
+
const entry = generateEntry(project, Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])), harness, kinds);
|
|
80
82
|
log(`olve-build: ${project.handlers.length} handler(s) hashed in ${((performance.now() - t0) / 1000).toFixed(2)}s`);
|
|
81
83
|
|
|
82
84
|
// 1. compile
|
|
@@ -112,7 +114,7 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
|
|
|
112
114
|
await writeTarZst(archive, [{ path: "step", type: "file", mode: 0o755, source: binary }]);
|
|
113
115
|
writeFileSync(
|
|
114
116
|
join(out, "build.json"),
|
|
115
|
-
canonicalJson({ target, flags: BUILD_FLAGS, harness, handlers: hashes, binarySha256: sha256File(binary), archiveSha256: sha256File(archive) }),
|
|
117
|
+
canonicalJson({ target, flags: BUILD_FLAGS, harness, handlers: hashes, kinds, binarySha256: sha256File(binary), archiveSha256: sha256File(archive) }),
|
|
116
118
|
);
|
|
117
119
|
log(`olve-build: wrote ${archive}`);
|
|
118
120
|
|
package/src/build/hashes.ts
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
// (tree-shaken → function-level granularity; embedded scripts are bundle outputs too), then hash the
|
|
3
3
|
// outputs. Contract changes are versioned through the manifest's model/step entries, SDK changes
|
|
4
4
|
// through `harness`; both are combined in composeHandlerHash.
|
|
5
|
-
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import type { BunPlugin } from "bun";
|
|
7
|
+
import { isImplementation } from "../core/handlers.ts";
|
|
8
|
+
import { originsOf } from "../core/origin.ts";
|
|
9
|
+
import { pipelineData } from "../builder/pipeline.ts";
|
|
10
|
+
import { allSteps } from "../harness/main.ts";
|
|
7
11
|
import { composeHandlerHash, type HarnessInfo } from "../util/handler-hash.ts";
|
|
8
12
|
import { sha256Hex } from "../util/hash.ts";
|
|
9
13
|
import type { Project } from "./project.ts";
|
|
@@ -33,11 +37,12 @@ function externals(project: Project): BunPlugin {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export async function bundleHash(project: Project, entry: string): Promise<{ hash: string; outputs: string[] }> {
|
|
40
|
+
const inside = !relative(project.dir, entry).startsWith("..");
|
|
36
41
|
const r = await Bun.build({
|
|
37
42
|
entrypoints: [entry],
|
|
38
43
|
target: "bun",
|
|
39
44
|
format: "esm",
|
|
40
|
-
root: project.dir,
|
|
45
|
+
root: inside ? project.dir : dirname(entry),
|
|
41
46
|
naming: { entry: "[dir]/[name].[ext]", chunk: "[dir]/[name].[ext]", asset: "[dir]/[name].[ext]" },
|
|
42
47
|
plugins: [externals(project)],
|
|
43
48
|
throw: false,
|
|
@@ -65,3 +70,27 @@ export async function handlerHashes(project: Project, harness: HarnessInfo): Pro
|
|
|
65
70
|
}
|
|
66
71
|
return out;
|
|
67
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Kind → hash of the modules that implement the steps of that kind which carry their implementation
|
|
76
|
+
* (each module bundled like a handler: the SDK and contract files external).
|
|
77
|
+
*/
|
|
78
|
+
export async function kindHashes(project: Project, pipeline: unknown): Promise<Record<string, string>> {
|
|
79
|
+
const origins = new Map<string, Set<string>>();
|
|
80
|
+
for (const s of allSteps(pipelineData(pipeline))) {
|
|
81
|
+
if (!isImplementation(s)) continue;
|
|
82
|
+
const files = originsOf(s).filter((f) => !project.contractFiles.includes(f));
|
|
83
|
+
if (files.length === 0)
|
|
84
|
+
throw new Error(`step '${s.name}' carries its implementation but is defined in ${project.contractFiles.map((f) => relative(project.dir, f)).join(" or ")}; define and implement it in a module of its own (or a package)`);
|
|
85
|
+
const set = origins.get(s.kind) ?? new Set<string>();
|
|
86
|
+
for (const f of files) set.add(f);
|
|
87
|
+
origins.set(s.kind, set);
|
|
88
|
+
}
|
|
89
|
+
const out: Record<string, string> = {};
|
|
90
|
+
for (const [kind, files] of [...origins].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
91
|
+
const parts: string[] = [];
|
|
92
|
+
for (const f of [...files].sort()) parts.push((await bundleHash(project, f)).hash);
|
|
93
|
+
out[kind] = "sha256:" + sha256Hex(parts.join("\n"));
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
package/src/build/project.ts
CHANGED
|
@@ -35,6 +35,13 @@ export async function loadProject(dirArg: string): Promise<Project> {
|
|
|
35
35
|
return { dir, pipelineFile, contractFiles, handlers: await resolveHandlers(hdir, files) };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** The default export of pipeline.ts. */
|
|
39
|
+
export async function loadPipeline(project: Project): Promise<unknown> {
|
|
40
|
+
const mod = (await import(project.pipelineFile)) as { default?: unknown };
|
|
41
|
+
if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
|
|
42
|
+
return mod.default;
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
/** Pairs each handler file with the step its default export implements (file names don't matter). */
|
|
39
46
|
async function resolveHandlers(hdir: string, files: string[]): Promise<[string, string][]> {
|
|
40
47
|
const byStep = new Map<string, string>();
|
package/src/core/brands.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// a dev runner and the user's installed package — still recognise each other's objects.
|
|
3
3
|
export const STEP = Symbol.for("olve.pipelines.step");
|
|
4
4
|
export const IMPLEMENTATION = Symbol.for("olve.pipelines.implementation");
|
|
5
|
+
export const ORIGIN = Symbol.for("olve.pipelines.origin");
|
|
5
6
|
export const SKIPPED = Symbol.for("olve.pipelines.skipped");
|
|
6
7
|
export const PIPELINE = Symbol.for("olve.pipelines.pipeline");
|
|
7
8
|
export const REF_PATH = Symbol.for("olve.pipelines.refPath");
|
|
@@ -11,3 +12,4 @@ export declare const ModelBrand: unique symbol;
|
|
|
11
12
|
export declare const RefBrand: unique symbol;
|
|
12
13
|
export declare const StepBrand: unique symbol;
|
|
13
14
|
export declare const SkippedBrand: unique symbol;
|
|
15
|
+
export declare const ImplementationBrand: unique symbol;
|
package/src/core/config.ts
CHANGED
|
@@ -12,7 +12,7 @@ interface RefTarget {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* A typed reference to a config value (`TARGET.beta.host`), accepted
|
|
15
|
+
* A typed reference to a config value (`TARGET.beta.host`), accepted in a step's settings in place of a
|
|
16
16
|
* literal. It is recorded in the manifest as `{"$config": "target.beta.host"}` and resolved from the
|
|
17
17
|
* run's config when the step runs; the step receives the entry automatically.
|
|
18
18
|
*/
|
package/src/core/handlers.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { IMPLEMENTATION, SKIPPED, type SkippedBrand } from "./brands.ts";
|
|
1
|
+
import { IMPLEMENTATION, ORIGIN, SKIPPED, type ImplementationBrand, type SkippedBrand } from "./brands.ts";
|
|
2
2
|
import type { Ctx } from "../ctx/types.ts";
|
|
3
|
-
import
|
|
3
|
+
import { callerFile, originsOf } from "./origin.ts";
|
|
4
|
+
import type { AnyStep, ConfigOf, OutputValue, SecretsOf, StepInput } from "./steps.ts";
|
|
4
5
|
import type { Awaitable } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
/** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
|
|
@@ -18,10 +19,13 @@ export function isSkipped(v: unknown): v is Skipped {
|
|
|
18
19
|
export type HandlerResult<S extends AnyStep> = OutputValue<S> | Skipped;
|
|
19
20
|
export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<HandlerResult<S>>;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/** A step with its handler. It is itself a step: stage it directly, or default-export it from `handlers/*.ts`. */
|
|
23
|
+
export type Implementation<S extends AnyStep = AnyStep> = S & {
|
|
24
|
+
readonly [ImplementationBrand]: true;
|
|
25
|
+
/** The step as defined. */
|
|
22
26
|
readonly step: S;
|
|
23
27
|
readonly handler: Handler<S>;
|
|
24
|
-
}
|
|
28
|
+
};
|
|
25
29
|
|
|
26
30
|
/** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
|
|
27
31
|
type NoExtraFields<R, T> = R extends Skipped | void
|
|
@@ -29,14 +33,16 @@ type NoExtraFields<R, T> = R extends Skipped | void
|
|
|
29
33
|
: { [K in Exclude<keyof R, keyof T>]: `❌ '${K & string}' is not a field of the output model` };
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
* Implement a
|
|
33
|
-
*
|
|
36
|
+
* Implement a step. Input, ctx and return types come from the step definition alone. Default-export
|
|
37
|
+
* the result from a `handlers/*.ts` file, or stage it directly (a reusable step: give it a `kind`).
|
|
34
38
|
*/
|
|
35
39
|
export function implement<S extends AnyStep, R extends HandlerResult<S>>(
|
|
36
|
-
step: S & (S extends
|
|
40
|
+
step: S & (S extends { readonly [ImplementationBrand]: true } ? "❌ this step already carries its implementation" : unknown),
|
|
37
41
|
handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
|
|
38
42
|
): Implementation<S> {
|
|
39
|
-
|
|
43
|
+
if (isImplementation(step)) throw new TypeError(`step '${(step as AnyStep).name}' already carries its implementation`);
|
|
44
|
+
const origins = [...new Set([...originsOf(step), callerFile()].filter((f): f is string => f !== undefined))];
|
|
45
|
+
return Object.freeze({ ...step, [IMPLEMENTATION]: true, [ORIGIN]: origins, step, handler: handler as unknown as Handler<S> }) as unknown as Implementation<S>;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export function isImplementation(v: unknown): v is Implementation {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ORIGIN } from "./brands.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The file that called the function calling this one, from the stack. A tail call drops its caller's
|
|
5
|
+
* frame, so this can name a module further up; the build only uses it to choose what to hash.
|
|
6
|
+
*/
|
|
7
|
+
export function callerFile(): string | undefined {
|
|
8
|
+
const files = (new Error().stack ?? "").split("\n").slice(1).map((f) => f.match(FRAME)?.[1]);
|
|
9
|
+
const own = files[0];
|
|
10
|
+
const callee = files[1];
|
|
11
|
+
const file = files.slice(2).find((f) => f && f !== own && f !== callee) ?? undefined;
|
|
12
|
+
return file?.startsWith("file://") ? new URL(file).pathname : file;
|
|
13
|
+
}
|
|
14
|
+
const FRAME = /\(?((?:file:\/\/)?\/[^()]*?):\d+(?::\d+)?\)?\s*$/;
|
|
15
|
+
|
|
16
|
+
/** The modules that defined and implemented a step (where `step` and `implement` were called). */
|
|
17
|
+
export function originsOf(v: unknown): string[] {
|
|
18
|
+
const o = (v as Record<symbol, unknown>)[ORIGIN];
|
|
19
|
+
return Array.isArray(o) ? o.filter((x): x is string => typeof x === "string") : [];
|
|
20
|
+
}
|
package/src/core/steps.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
-
import { STEP, type StepBrand } from "./brands.ts";
|
|
2
|
+
import { ORIGIN, STEP, type StepBrand } from "./brands.ts";
|
|
3
|
+
import { callerFile } from "./origin.ts";
|
|
3
4
|
import type { AnyModel, Infer, Optional } from "./models.ts";
|
|
4
5
|
import type { AnyConfigEntry } from "./config.ts";
|
|
5
6
|
import { configRefsIn } from "./config-refs.ts";
|
|
@@ -18,8 +19,12 @@ export type InferEntry<E> =
|
|
|
18
19
|
/** What a handler receives: the output view of every input entry. */
|
|
19
20
|
export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* A label for what a step is, shown in the UI. `custom` (the default) is a step whose handler is in
|
|
24
|
+
* `handlers/`; a step that carries its implementation (`implement(step, …)` staged directly) names its
|
|
25
|
+
* kind, e.g. `gitHubSource`. Packages prefix theirs (`acme/deploy`).
|
|
26
|
+
*/
|
|
27
|
+
export type StepKind = string;
|
|
23
28
|
|
|
24
29
|
export interface Step<
|
|
25
30
|
N extends string = string,
|
|
@@ -42,17 +47,9 @@ export interface Step<
|
|
|
42
47
|
readonly retries?: number;
|
|
43
48
|
/** The step pod gets the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`). */
|
|
44
49
|
readonly clusterAccess?: boolean;
|
|
45
|
-
/**
|
|
46
|
-
readonly
|
|
50
|
+
/** How the step is set up, as written in the definition (may hold config references). */
|
|
51
|
+
readonly settings?: Readonly<Record<string, unknown>>;
|
|
47
52
|
}
|
|
48
|
-
/** Runtime part of a library step (not part of the public contract). */
|
|
49
|
-
export interface LibraryImplementation {
|
|
50
|
-
/** Config as emitted in the manifest (JSON only; must not contain paths). */
|
|
51
|
-
readonly manifestConfig: () => Record<string, unknown>;
|
|
52
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
-
readonly run: (input: any, ctx: any) => Promise<unknown>;
|
|
54
|
-
}
|
|
55
|
-
export type LibraryStep = { readonly builtin: LibraryImplementation };
|
|
56
53
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
54
|
export type AnyStep = Step<string, any, AnyModel | null, any, any>;
|
|
58
55
|
|
|
@@ -89,16 +86,23 @@ export interface StepDef<N extends string, I extends InputSpec, O extends AnyMod
|
|
|
89
86
|
* (`Kubernetes:StepServiceAccount`), if it grants one. Default false (no token).
|
|
90
87
|
*/
|
|
91
88
|
clusterAccess?: boolean;
|
|
89
|
+
/** A label for the step's kind (default `custom`). */
|
|
90
|
+
kind?: StepKind;
|
|
91
|
+
/**
|
|
92
|
+
* How the step is set up, shown in the manifest and part of the step's identity: the parameters of
|
|
93
|
+
* a reusable step. JSON values and config references (`target.prod.host`); every config entry it
|
|
94
|
+
* references is delivered to the step, and `resolveConfigRefs(value, ctx.config)` resolves them.
|
|
95
|
+
*/
|
|
96
|
+
settings?: Readonly<Record<string, unknown>>;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
|
|
95
|
-
export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?: LibraryImplementation, configFrom?: unknown): AnyStep {
|
|
99
|
+
function makeStep(def: Record<string, unknown>, origin: string | undefined): AnyStep {
|
|
96
100
|
const config = new Map<string, AnyConfigEntry>();
|
|
97
101
|
for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? []) if (!config.has(e.name)) config.set(e.name, e);
|
|
98
|
-
configRefsIn(
|
|
102
|
+
configRefsIn(def.settings, config);
|
|
99
103
|
const s = {
|
|
100
104
|
[STEP]: true,
|
|
101
|
-
kind,
|
|
105
|
+
kind: def.kind ?? "custom",
|
|
102
106
|
name: def.name,
|
|
103
107
|
description: def.description,
|
|
104
108
|
image: def.image,
|
|
@@ -109,7 +113,8 @@ export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?:
|
|
|
109
113
|
...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
|
|
110
114
|
...(def.retries !== undefined ? { retries: def.retries } : {}),
|
|
111
115
|
...(def.clusterAccess === true ? { clusterAccess: true } : {}),
|
|
112
|
-
...(
|
|
116
|
+
...(def.settings !== undefined ? { settings: def.settings } : {}),
|
|
117
|
+
[ORIGIN]: origin === undefined ? [] : [origin],
|
|
113
118
|
};
|
|
114
119
|
return Object.freeze(s) as unknown as AnyStep;
|
|
115
120
|
}
|
|
@@ -118,7 +123,10 @@ export function isStep(v: unknown): v is AnyStep {
|
|
|
118
123
|
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[STEP] === true;
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Define a step: a contract (name, image, inputs, output, secrets). Its handler is a `handlers/*.ts`
|
|
128
|
+
* default-exporting `implement(step, …)`, or, for a reusable step, `implement(step, …)` staged directly.
|
|
129
|
+
*/
|
|
122
130
|
export function step<
|
|
123
131
|
const N extends string,
|
|
124
132
|
const I extends InputSpec = {},
|
|
@@ -126,5 +134,5 @@ export function step<
|
|
|
126
134
|
const Sec extends readonly Secret[] = [],
|
|
127
135
|
const Cfg extends readonly AnyConfigEntry[] = [],
|
|
128
136
|
>(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]["name"], Cfg[number]> {
|
|
129
|
-
return makeStep(
|
|
137
|
+
return makeStep(def as never, callerFile()) as never;
|
|
130
138
|
}
|