@olivervea/pipelines 2.0.0-beta.14 → 2.0.0-beta.16
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 +4 -3
- package/manifest.schema.json +57 -10
- package/package.json +1 -1
- package/src/core/external.ts +26 -1
- package/src/ctx/index.ts +9 -0
- package/src/ctx/types.ts +5 -0
- package/src/describe/describe.ts +10 -1
- package/src/describe/manifest-schema.ts +15 -1
- package/src/harness/env.ts +15 -1
- package/src/harness/main.ts +2 -2
- package/src/index.ts +1 -1
- package/src/library/models.ts +1 -1
- package/src/testing/index.ts +3 -0
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ Rules:
|
|
|
103
103
|
- **Trigger mappers** produce `z.input`, so fields with defaults may be left out. **Handlers, mapper refs and `when`** see `z.output`.
|
|
104
104
|
- A **plain-schema input** has no model to match on, so the step needs a mapper.
|
|
105
105
|
- **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
|
|
106
|
-
- **`external(M, { title, description?, from? })`** is answered from outside the run (`from
|
|
106
|
+
- **`external(M, { title, description?, from? })`** is answered from outside the run; `from` lists `callback()` (another system POSTs to the URL an earlier step gets from `ctx.inputUrl(step, input)`), `poll({ url, valuePath, intervalSeconds? })` and `person()` (the default). It needs no producer and no mapper entry; the step waits until it is answered, then the value (validated against `M`) is in its input like any other. Failure handlers can't have one.
|
|
107
107
|
|
|
108
108
|
### Config
|
|
109
109
|
|
|
@@ -309,11 +309,12 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
309
309
|
| `OLVE_CASES` | run | Only for a step inside a `select`: a JSON array of the case conditions it sits in (`["target.prod.kind=ssh"]`); `run <step>` uses it to pick the variant of a name several cases use. |
|
|
310
310
|
| `<SECRET_NAME>` | per step | Each declared secret as an env var of the **same name**: `secretKeyRef` key = name. Mount only the step's `secrets` (for poll triggers, the poll's `secrets`). Child processes inherit them. The mount is optional: an unset secret leaves the variable unset and `ctx.secrets.<NAME>` throws on access, so a handler can check `process.env.<NAME>` first and skip. |
|
|
311
311
|
| `OLVE_RESULT_URL` | optional | Pre-signed **PUT** for `result.tar.gz` (see below). Unset means no upload. |
|
|
312
|
+
| `OLVE_CALLBACK_URLS` | optional | JSON object mapping `<step>/<input>` → this run's callback URL of that external input (`ctx.inputUrl`). Set when the pipeline has `callback()` inputs and the instance has a public base URL. |
|
|
312
313
|
| `OLVE_FILE_URLS` | optional | JSON object mapping file key → pre-signed **GET** URL, one entry per file ref in the input. The key is `<run>/<step>/<attempt>/<path>` (`path` exactly as in the ref, including a trailing `/` for directories). The URL points at the stored object (see the storage rule). |
|
|
313
314
|
| `OLVE_FILES_BASE_URL` (+ `OLVE_FILES_TOKEN`) | optional | Fallback: `GET <base>/<run>/<step>/<attempt>/<object path>` with an optional `Authorization: Bearer <token>`. |
|
|
314
315
|
| `OLVE_FILES_DIR` | dev | Local pool laid out as `<run>/<step>/<attempt>/files/<object path>`. |
|
|
315
316
|
|
|
316
|
-
`OLVE_RESULT_URL`, `OLVE_FILE_URLS` and `
|
|
317
|
+
`OLVE_RESULT_URL`, `OLVE_FILE_URLS`, `OLVE_FILES_TOKEN` and `OLVE_CALLBACK_URLS` are removed from the environment of child processes. URLs are never logged past `?`.
|
|
317
318
|
|
|
318
319
|
`ctx.files.fetch` looks for a file in this order:
|
|
319
320
|
|
|
@@ -384,7 +385,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
384
385
|
- a nested object of mappings.
|
|
385
386
|
|
|
386
387
|
An object whose only key is `ref` (with a string value) or `const` is a tag. Describe never emits a nested object with exactly that key set. Model-matched inputs are emitted already resolved (`{"ref": "build"}`).
|
|
387
|
-
- **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from
|
|
388
|
+
- **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from}`, `from` being `{kind: "person"}`, `{kind: "callback"}` or `{kind: "poll", url, valuePath, intervalSeconds}`. Those keys are not in `input` or `inputTypes`; the service merges the answers into `input.json`.
|
|
388
389
|
- **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
|
|
389
390
|
- **`handlerHash`** is the step's implementation identity. A step version can be computed from image + `inputTypes`/model versions + `output` model version + `secrets` + `configEntries` (and those entries' schemas) + `input` mappings + `handlerHash`. Stage placement, `when` and the step's `description` are not part of it.
|
|
390
391
|
- Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts, so it changes only when that handler's code (or code it uses) changes.
|
package/manifest.schema.json
CHANGED
|
@@ -411,17 +411,64 @@
|
|
|
411
411
|
"from": {
|
|
412
412
|
"description": "Who or what may answer.",
|
|
413
413
|
"items": {
|
|
414
|
-
"
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
"
|
|
418
|
-
|
|
414
|
+
"oneOf": [
|
|
415
|
+
{
|
|
416
|
+
"additionalProperties": false,
|
|
417
|
+
"properties": {
|
|
418
|
+
"kind": {
|
|
419
|
+
"const": "person",
|
|
420
|
+
"type": "string"
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
"required": [
|
|
424
|
+
"kind"
|
|
425
|
+
],
|
|
426
|
+
"type": "object"
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"additionalProperties": false,
|
|
430
|
+
"properties": {
|
|
431
|
+
"kind": {
|
|
432
|
+
"const": "callback",
|
|
433
|
+
"type": "string"
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
"required": [
|
|
437
|
+
"kind"
|
|
438
|
+
],
|
|
439
|
+
"type": "object"
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
"additionalProperties": false,
|
|
443
|
+
"properties": {
|
|
444
|
+
"intervalSeconds": {
|
|
445
|
+
"maximum": 9007199254740991,
|
|
446
|
+
"minimum": 10,
|
|
447
|
+
"type": "integer"
|
|
448
|
+
},
|
|
449
|
+
"kind": {
|
|
450
|
+
"const": "poll",
|
|
451
|
+
"type": "string"
|
|
452
|
+
},
|
|
453
|
+
"url": {
|
|
454
|
+
"description": "GET while waiting; {run.id} and {run.number} are filled in.",
|
|
455
|
+
"type": "string"
|
|
456
|
+
},
|
|
457
|
+
"valuePath": {
|
|
458
|
+
"description": "Dot path to the value; '.' is the whole body.",
|
|
459
|
+
"minLength": 1,
|
|
460
|
+
"type": "string"
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
"required": [
|
|
464
|
+
"kind",
|
|
465
|
+
"url",
|
|
466
|
+
"valuePath",
|
|
467
|
+
"intervalSeconds"
|
|
468
|
+
],
|
|
469
|
+
"type": "object"
|
|
419
470
|
}
|
|
420
|
-
|
|
421
|
-
"required": [
|
|
422
|
-
"kind"
|
|
423
|
-
],
|
|
424
|
-
"type": "object"
|
|
471
|
+
]
|
|
425
472
|
},
|
|
426
473
|
"minItems": 1,
|
|
427
474
|
"type": "array"
|
package/package.json
CHANGED
package/src/core/external.ts
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
import { isModel, type AnyModel } from "./models.ts";
|
|
2
2
|
|
|
3
3
|
/** Who or what may supply an external input's value. */
|
|
4
|
-
export type ExternalSource =
|
|
4
|
+
export type ExternalSource =
|
|
5
|
+
| { readonly kind: "person" }
|
|
6
|
+
| { readonly kind: "callback" }
|
|
7
|
+
| { readonly kind: "poll"; readonly url: string; readonly valuePath: string; readonly intervalSeconds: number };
|
|
5
8
|
|
|
6
9
|
/** A signed-in user answers, in the UI or with `pl input`. */
|
|
7
10
|
export function person(): ExternalSource {
|
|
8
11
|
return Object.freeze({ kind: "person" as const });
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Another system answers by POSTing to this run's callback URL for the input, which an earlier step hands it
|
|
16
|
+
* with `ctx.inputUrl(step, input)`. The body is the value as JSON, or `{"$decline": "reason"}`.
|
|
17
|
+
*/
|
|
18
|
+
export function callback(): ExternalSource {
|
|
19
|
+
return Object.freeze({ kind: "callback" as const });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PollOptions {
|
|
23
|
+
/** GET while the step waits; `{run.id}` and `{run.number}` are filled in. */
|
|
24
|
+
url: string;
|
|
25
|
+
/** Dot path to the value in the JSON response; `.` is the whole body. */
|
|
26
|
+
valuePath: string;
|
|
27
|
+
/** Default 30; at least 10. */
|
|
28
|
+
intervalSeconds?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The service polls `url` while the step waits and answers with the value at `valuePath` once it validates. */
|
|
32
|
+
export function poll(options: PollOptions): ExternalSource {
|
|
33
|
+
return Object.freeze({ kind: "poll" as const, url: options.url, valuePath: options.valuePath, intervalSeconds: options.intervalSeconds ?? 30 });
|
|
34
|
+
}
|
|
35
|
+
|
|
11
36
|
/**
|
|
12
37
|
* A step input whose value comes from outside the run. The step waits until it is answered (the value is
|
|
13
38
|
* validated against `model`), then runs with the value in its input like any other.
|
package/src/ctx/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface CtxOptions {
|
|
|
18
18
|
signal: AbortSignal;
|
|
19
19
|
/** Where log lines go (default stderr). */
|
|
20
20
|
logSink?: (line: string) => void;
|
|
21
|
+
/** `<step>/<input>` → callback URL (`ctx.inputUrl`). */
|
|
22
|
+
callbackUrls?: Readonly<Record<string, string>>;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export function formatLog(message: string, fields?: Record<string, unknown>): string {
|
|
@@ -118,5 +120,12 @@ export function createCtx(o: CtxOptions): Ctx {
|
|
|
118
120
|
run: Object.freeze({ ...o.run }),
|
|
119
121
|
tmpDir: o.paths.tmp,
|
|
120
122
|
skip: skipped,
|
|
123
|
+
inputUrl: (step, input) => {
|
|
124
|
+
const name = typeof step === "string" ? step : step.name;
|
|
125
|
+
const url = o.callbackUrls?.[`${name}/${input}`];
|
|
126
|
+
if (!url)
|
|
127
|
+
throw new Error(`no callback URL for input '${input}' of step '${name}': it must list callback() in from, and the instance needs a public base URL`);
|
|
128
|
+
return url;
|
|
129
|
+
},
|
|
121
130
|
};
|
|
122
131
|
}
|
package/src/ctx/types.ts
CHANGED
|
@@ -113,4 +113,9 @@ export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = n
|
|
|
113
113
|
readonly tmpDir: string;
|
|
114
114
|
/** Runtime escape hatch: produce no output. Prefer `when` blocks. */
|
|
115
115
|
skip(reason: string): Skipped;
|
|
116
|
+
/**
|
|
117
|
+
* This run's callback URL for an external input with `from: [callback()]` (of any step of the pipeline):
|
|
118
|
+
* hand it to the system that will answer. POST the value as JSON, or `{"$decline": "reason"}`.
|
|
119
|
+
*/
|
|
120
|
+
inputUrl(step: string | { readonly name: string }, input: string): string;
|
|
116
121
|
}
|
package/src/describe/describe.ts
CHANGED
|
@@ -197,11 +197,20 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
197
197
|
if (failureHandler) problems.push(`${at}: failure handlers run unattended and cannot wait for input ('${key}')`);
|
|
198
198
|
if (typeof e.title !== "string" || e.title.trim() === "") problems.push(`${at} external input '${key}': title must not be empty`);
|
|
199
199
|
if (e.from.length === 0) problems.push(`${at} external input '${key}': from must list at least one source`);
|
|
200
|
+
if (new Set(e.from.map((f) => f.kind)).size !== e.from.length) problems.push(`${at} external input '${key}': each source kind may be listed once`);
|
|
201
|
+
if (e.from.some((f) => f.kind === "callback") && !/^[A-Za-z0-9_-]+$/.test(key))
|
|
202
|
+
problems.push(`${at} external input '${key}': a callback input's key must be letters, digits, '_' or '-' (it is part of the URL)`);
|
|
203
|
+
for (const f of e.from) {
|
|
204
|
+
if (f.kind !== "poll") continue;
|
|
205
|
+
if (!/^https?:\/\//.test(f.url)) problems.push(`${at} external input '${key}': poll url must be an absolute http(s) URL`);
|
|
206
|
+
if (!Number.isInteger(f.intervalSeconds) || f.intervalSeconds < 10) problems.push(`${at} external input '${key}': poll intervalSeconds must be an integer of at least 10`);
|
|
207
|
+
if (f.valuePath.trim() === "") problems.push(`${at} external input '${key}': poll valuePath must not be empty`);
|
|
208
|
+
}
|
|
200
209
|
externalInputs[key] = {
|
|
201
210
|
model: useModel(e.model, `${at} external input '${key}'`),
|
|
202
211
|
title: e.title,
|
|
203
212
|
description: e.description,
|
|
204
|
-
from: e.from.map((f) => ({
|
|
213
|
+
from: e.from.map((f) => ({ ...f })),
|
|
205
214
|
};
|
|
206
215
|
}
|
|
207
216
|
const entries = allEntries.filter(([, e]) => !isExternal(e));
|
|
@@ -79,7 +79,21 @@ export const StepDescriptor = z
|
|
|
79
79
|
model: modelName.describe("The value is validated against this model's input view."),
|
|
80
80
|
title: z.string().min(1).describe("What is asked, shown to whoever answers."),
|
|
81
81
|
description: z.string(),
|
|
82
|
-
from: z
|
|
82
|
+
from: z
|
|
83
|
+
.array(
|
|
84
|
+
z.discriminatedUnion("kind", [
|
|
85
|
+
z.strictObject({ kind: z.literal("person") }),
|
|
86
|
+
z.strictObject({ kind: z.literal("callback") }),
|
|
87
|
+
z.strictObject({
|
|
88
|
+
kind: z.literal("poll"),
|
|
89
|
+
url: z.string().describe("GET while waiting; {run.id} and {run.number} are filled in."),
|
|
90
|
+
valuePath: z.string().min(1).describe("Dot path to the value; '.' is the whole body."),
|
|
91
|
+
intervalSeconds: z.int().min(10),
|
|
92
|
+
}),
|
|
93
|
+
]),
|
|
94
|
+
)
|
|
95
|
+
.min(1)
|
|
96
|
+
.describe("Who or what may answer."),
|
|
83
97
|
}),
|
|
84
98
|
)
|
|
85
99
|
.optional()
|
package/src/harness/env.ts
CHANGED
|
@@ -13,6 +13,8 @@ export const ENV = {
|
|
|
13
13
|
resultUrl: "OLVE_RESULT_URL",
|
|
14
14
|
/** JSON object: file key (`<run>/<step>/<attempt>/<path>`) → pre-signed GET URL. Optional. */
|
|
15
15
|
fileUrls: "OLVE_FILE_URLS",
|
|
16
|
+
/** JSON object: `<step>/<input>` → callback URL of that external input in this run (`ctx.inputUrl`). Optional. */
|
|
17
|
+
callbackUrls: "OLVE_CALLBACK_URLS",
|
|
16
18
|
/** Base URL for file downloads: GET `<base>/<run>/<step>/<attempt>/<object path>`. Optional. */
|
|
17
19
|
filesBaseUrl: "OLVE_FILES_BASE_URL",
|
|
18
20
|
/** Bearer token sent with OLVE_FILES_BASE_URL requests. Optional. */
|
|
@@ -22,7 +24,19 @@ export const ENV = {
|
|
|
22
24
|
} as const;
|
|
23
25
|
|
|
24
26
|
/** Env vars never passed to child processes (they carry credentials for the harness only). */
|
|
25
|
-
export const PRIVATE_ENV: readonly string[] = [ENV.resultUrl, ENV.fileUrls, ENV.filesToken];
|
|
27
|
+
export const PRIVATE_ENV: readonly string[] = [ENV.resultUrl, ENV.fileUrls, ENV.filesToken, ENV.callbackUrls];
|
|
28
|
+
|
|
29
|
+
/** `OLVE_CALLBACK_URLS`, parsed; empty when unset or malformed. */
|
|
30
|
+
export function callbackUrlsFromEnv(): Record<string, string> {
|
|
31
|
+
const raw = process.env[ENV.callbackUrls];
|
|
32
|
+
if (!raw) return {};
|
|
33
|
+
try {
|
|
34
|
+
const parsed: unknown = JSON.parse(raw);
|
|
35
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, string>) : {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
26
40
|
|
|
27
41
|
export const olveDir = () => process.env[ENV.dir] || "/olve";
|
|
28
42
|
export const paths = () => {
|
package/src/harness/main.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { pipelineData, type PipelineData, type StageNode } from "../builder/pipe
|
|
|
12
12
|
import { createCtx, formatLog } from "../ctx/index.ts";
|
|
13
13
|
import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
|
|
14
14
|
import type { HarnessInfo } from "../util/handler-hash.ts";
|
|
15
|
-
import { ENV, paths, runInfoFromEnv } from "./env.ts";
|
|
15
|
+
import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from "./env.ts";
|
|
16
16
|
import { EXIT } from "./exit.ts";
|
|
17
17
|
import { executeStep, type Envelope, type Execution, type StepFunction } from "./execute.ts";
|
|
18
18
|
import { MANIFEST_BEGIN, MANIFEST_END, packResult, removeStale, sentinelBlock, uploadFile, writeEnvelope } from "./result.ts";
|
|
@@ -183,7 +183,7 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
|
|
|
183
183
|
exec = { envelope: { status: "failed", error: { kind: "input", message: raw.message } }, exitCode: EXIT.inputInvalid };
|
|
184
184
|
} else {
|
|
185
185
|
const run = runInfoFromEnv(step.name);
|
|
186
|
-
const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr });
|
|
186
|
+
const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr, callbackUrls: callbackUrlsFromEnv() });
|
|
187
187
|
exec = await executeStep({ step, fn, rawInput: raw.value, ctx, workFiles: p.files, log: io.stderr });
|
|
188
188
|
}
|
|
189
189
|
return await finish(exec, io);
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
|
|
|
6
6
|
export { config, secretRef, isConfigRef, type ConfigEntry, type AnyConfigEntry, type ConfigValues, type ConfigRef, type ConfigNode, type Configurable, type DiscriminatorOf, type VariantOf } from "./core/config.ts";
|
|
7
7
|
export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
|
|
8
8
|
export { resolveConfigRefs, isLiteral, type Resolved } from "./core/config-refs.ts";
|
|
9
|
-
export { external, person, type External, type ExternalOptions, type ExternalSource } from "./core/external.ts";
|
|
9
|
+
export { callback, external, person, poll, type External, type ExternalOptions, type ExternalSource, type PollOptions } from "./core/external.ts";
|
|
10
10
|
export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type ConfigOf, type InputEntry, type InputSpec, type LiteralName, type OutputValue } from "./core/steps.ts";
|
|
11
11
|
export type { Simplify } from "./core/types.ts";
|
|
12
12
|
export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
|
package/src/library/models.ts
CHANGED
|
@@ -64,7 +64,7 @@ export const FailureContext = model(
|
|
|
64
64
|
address: z.string(),
|
|
65
65
|
/** The run's derived version, `yyyy.MM.dd.<run>`. */
|
|
66
66
|
versionStamp: z.string(),
|
|
67
|
-
trigger: z.enum(["push", "poll", "manual"]),
|
|
67
|
+
trigger: z.enum(["push", "poll", "manual", "webhook"]),
|
|
68
68
|
/** The run (`GET …/api/pipelines/<id>/runs/<n>`). */
|
|
69
69
|
url: z.string(),
|
|
70
70
|
}),
|
package/src/testing/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface TestHandlerOptions<S extends AnyStep> {
|
|
|
23
23
|
dir?: string;
|
|
24
24
|
/** Abort signal handed to ctx.signal. */
|
|
25
25
|
signal?: AbortSignal;
|
|
26
|
+
/** `<step>/<input>` → URL returned by `ctx.inputUrl`. */
|
|
27
|
+
callbackUrls?: Record<string, string>;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export type TestResult<S extends AnyStep> = {
|
|
@@ -62,6 +64,7 @@ export async function testHandler<S extends AnyStep>(
|
|
|
62
64
|
paths: { root: dir, workFiles: filesDir, tmp: join(dir, "tmp") },
|
|
63
65
|
signal: opts.signal ?? new AbortController().signal,
|
|
64
66
|
logSink: (l) => logs.push(l),
|
|
67
|
+
callbackUrls: opts.callbackUrls,
|
|
65
68
|
});
|
|
66
69
|
const ctx = { ...real, ...opts.ctx } as Ctx;
|
|
67
70
|
// Round-trip through JSON like input.json does (drops undefined, rejects non-JSON values early).
|