@olivervea/pipelines 2.0.0-beta.26 → 2.0.0-beta.28

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 CHANGED
@@ -201,6 +201,7 @@ import deployScript from "./scripts/deploy.sh" with { type: "file" };
201
201
  export const deployBeta = implement(step({ name: "deploy-beta", /* … */ }), async ({ artifact }, ctx) => {
202
202
  await ctx.sh(deployScript, { env: { IMAGE_TAR: await ctx.files.fetch(artifact.image), CHART_DIR: await ctx.files.fetch(artifact.chart) } });
203
203
  await ctx.retry({ attempts: 5, delaySeconds: 5 }, () => ctx.http.expectOk("https://pipelines-beta.ovea.pro/api/health"));
204
+ ctx.link("https://pipelines-beta.ovea.pro", "beta"); // shown on the step card and in pl run get
204
205
  return { release: "olve-pipelines", version: artifact.version, url: "https://pipelines-beta.ovea.pro" }; // or ctx.skip("reason")
205
206
  });
206
207
  ```
@@ -324,6 +325,8 @@ It retries network errors, 5xx, 408 and 429. It always verifies `sha256`.
324
325
  { "status": "failed", "error": { "kind": "handler" | "input" | "output", "message": "…" } }
325
326
  ```
326
327
 
328
+ Any of the three may carry `"links": [{ "url": "https://…", "label": "…" }]`, what the handler passed to `ctx.link` (absolute http(s) URLs of at most 2048 characters, labels of at most 100, at most 10). The key is left out when there are none. The server refuses an envelope whose links break these limits.
329
+
327
330
  For `map-trigger`, `output` is the pipeline input after it has been parsed by the input model, so defaults are applied. Validate it again server-side.
328
331
 
329
332
  ### Exit codes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.26",
3
+ "version": "2.0.0-beta.28",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ctx/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { skipped } from '../core/handlers.ts';
4
+ import { createLinks } from './links.ts';
4
5
  import type { Secret } from '../core/secrets.ts';
5
6
  import { createFetch, createOutput, redactUrl } from './files.ts';
6
7
  import { createSh } from './sh.ts';
@@ -165,6 +166,7 @@ export function createCtx(o: CtxOptions): Ctx {
165
166
  run: Object.freeze({ ...o.run }),
166
167
  tmpDir: o.paths.tmp,
167
168
  skip: skipped,
169
+ link: createLinks().link,
168
170
  inputUrl: (step, input) => {
169
171
  const name = typeof step === 'string' ? step : step.name;
170
172
  const url = o.callbackUrls?.[`${name}/${input}`];
@@ -0,0 +1,30 @@
1
+ export interface StepLink {
2
+ url: string;
3
+ label?: string;
4
+ }
5
+
6
+ export const MAX_LINKS = 10;
7
+ export const MAX_LINK_URL = 2048;
8
+ export const MAX_LINK_LABEL = 100;
9
+
10
+ /** `ctx.link`, collecting into `links`. */
11
+ export function createLinks(): {
12
+ links: StepLink[];
13
+ link: (url: string, label?: string) => void;
14
+ } {
15
+ const links: StepLink[] = [];
16
+ const link = (url: string, label?: string) => {
17
+ if (typeof url !== 'string' || !/^https?:\/\/[^/\s]/i.test(url) || !URL.canParse(url))
18
+ throw new Error(`ctx.link: '${String(url)}' is not an absolute http or https URL`);
19
+ if (url.length > MAX_LINK_URL)
20
+ throw new Error(`ctx.link: the URL is longer than ${MAX_LINK_URL} characters`);
21
+ if (label !== undefined && typeof label !== 'string')
22
+ throw new Error('ctx.link: the label must be a string');
23
+ if (label !== undefined && label.length > MAX_LINK_LABEL)
24
+ throw new Error(`ctx.link: the label is longer than ${MAX_LINK_LABEL} characters`);
25
+ if (links.length >= MAX_LINKS)
26
+ throw new Error(`ctx.link: a step can add at most ${MAX_LINKS} links`);
27
+ links.push(label ? { url, label } : { url });
28
+ };
29
+ return { links, link };
30
+ }
package/src/ctx/types.ts CHANGED
@@ -111,6 +111,11 @@ export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = n
111
111
  readonly run: RunInfo;
112
112
  /** A writable scratch directory (`$OLVE_DIR/tmp`; images like kaniko have no /tmp). */
113
113
  readonly tmpDir: string;
114
+ /**
115
+ * Link to what the step made or deployed (a site, a release, an image), shown with the attempt in the
116
+ * UI and `pl run get`. Absolute http(s) URL; at most 10 per attempt.
117
+ */
118
+ link(url: string, label?: string): void;
114
119
  /** Runtime escape hatch: produce no output. Prefer `when` blocks. */
115
120
  skip(reason: string): Skipped;
116
121
  /**
@@ -0,0 +1,98 @@
1
+ import { SECRET_REF_KIND } from '../core/config.ts';
2
+ import { CONFIG_CASE_KEY, CONFIG_REF_KEY } from '../core/config-refs.ts';
3
+ import type { JsonSchema } from './schemas.ts';
4
+
5
+ /**
6
+ * The schemas at `path` inside an entry's schema (through properties and records): every union branch that has the field, except where
7
+ * `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
8
+ */
9
+ export function subSchemas(
10
+ schema: unknown,
11
+ path: readonly string[],
12
+ kase: string | undefined,
13
+ entry: string,
14
+ ): unknown[] {
15
+ let narrow: { at: string; discriminator: string; value: string } | undefined;
16
+ if (kase) {
17
+ const eq = kase.lastIndexOf('=');
18
+ const left = kase.slice(0, eq);
19
+ const dot = left.lastIndexOf('.');
20
+ narrow = {
21
+ at: left.slice(0, dot),
22
+ discriminator: left.slice(dot + 1),
23
+ value: kase.slice(eq + 1),
24
+ };
25
+ }
26
+ const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
27
+ if (node === null || typeof node !== 'object') return [];
28
+ const o = node as JsonSchema;
29
+ const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
30
+ if (Array.isArray(branches)) {
31
+ const chosen =
32
+ narrow && narrow.at === here
33
+ ? branches.filter(
34
+ (b) =>
35
+ (
36
+ (b.properties as Record<string, JsonSchema> | undefined)?.[
37
+ narrow!.discriminator
38
+ ] as JsonSchema | undefined
39
+ )?.const === narrow!.value,
40
+ )
41
+ : branches;
42
+ return chosen.flatMap((b) => walk(b, rest, here));
43
+ }
44
+ if (rest.length === 0) return [node];
45
+ const props = o.properties as Record<string, JsonSchema> | undefined;
46
+ if (props && Object.hasOwn(props, rest[0]!))
47
+ return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
48
+ if (o.additionalProperties && typeof o.additionalProperties === 'object')
49
+ return walk(o.additionalProperties, rest.slice(1), `${here}.${rest[0]}`);
50
+ return [];
51
+ };
52
+ return walk(schema, path, entry);
53
+ }
54
+
55
+ /** Secret names of every secretRef inside a config entry's JSON Schema. */
56
+ export function secretRefsOf(schema: unknown): string[] {
57
+ const out = new Set<string>();
58
+ const walk = (v: unknown) => {
59
+ if (Array.isArray(v)) return v.forEach(walk);
60
+ if (v === null || typeof v !== 'object') return;
61
+ const o = v as JsonSchema;
62
+ if (o['x-olve-kind'] === SECRET_REF_KIND) {
63
+ const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret
64
+ ?.const;
65
+ if (typeof name === 'string') out.add(name);
66
+ }
67
+ Object.values(o).forEach(walk);
68
+ };
69
+ walk(schema);
70
+ return [...out].sort();
71
+ }
72
+
73
+ /** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
74
+ export function recordedConfigRefs(
75
+ v: unknown,
76
+ out: { ref: string; case?: string }[] = [],
77
+ ): { ref: string; case?: string }[] {
78
+ if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
79
+ else if (v !== null && typeof v === 'object') {
80
+ const o = v as Record<string, unknown>;
81
+ const keys = Object.keys(o).sort();
82
+ const isRef =
83
+ typeof o[CONFIG_REF_KEY] === 'string' &&
84
+ (keys.length === 1
85
+ ? keys[0] === CONFIG_REF_KEY
86
+ : keys.length === 2 &&
87
+ keys[0] === CONFIG_CASE_KEY &&
88
+ keys[1] === CONFIG_REF_KEY &&
89
+ typeof o[CONFIG_CASE_KEY] === 'string');
90
+ if (isRef)
91
+ out.push({
92
+ ref: o[CONFIG_REF_KEY] as string,
93
+ ...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}),
94
+ });
95
+ else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
96
+ }
97
+ return out;
98
+ }
@@ -0,0 +1,182 @@
1
+ import { z } from 'zod';
2
+ import { CONFIG_NAME, isConfigEntry } from '../core/config.ts';
3
+ import { MODEL_NAME, type AnyModel } from '../core/models.ts';
4
+ import { SECRET_NAME, type Secret } from '../core/secrets.ts';
5
+ import { RESERVED_STEP_NAMES, STEP_NAME, type AnyStep } from '../core/steps.ts';
6
+ import { canonicalize } from '../util/canonical.ts';
7
+ import { secretRefsOf } from './config-schema.ts';
8
+ import type { ManifestT, ModelSchemasT } from './manifest-schema.ts';
9
+ import { schemaViews, type JsonSchema } from './schemas.ts';
10
+
11
+ /** Root name used in failure-handler mappings (the service supplies FailureContext under it). */
12
+ export const FAILURE_ROOT = 'failure';
13
+ const RESERVED = new Set([...RESERVED_STEP_NAMES, FAILURE_ROOT]);
14
+
15
+ /** What a step can read at a point of the pipeline: its output model, and whether it may have been skipped. */
16
+ export interface Avail {
17
+ model: string | null;
18
+ maybe: boolean;
19
+ }
20
+
21
+ /** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
22
+ export interface CaseOf {
23
+ select: number;
24
+ value: string;
25
+ cond: string;
26
+ }
27
+
28
+ interface ConfigUse {
29
+ description: string;
30
+ required: boolean;
31
+ schema: JsonSchema;
32
+ key: string;
33
+ secretRefs: string[];
34
+ }
35
+
36
+ // Two places are mutually exclusive when they sit in different cases of the same select.
37
+ const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) =>
38
+ a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
39
+
40
+ const byName = ([a]: [string, unknown], [b]: [string, unknown]) =>
41
+ a < b ? -1 : a > b ? 1 : 0;
42
+
43
+ /** The problems found so far and the models, secrets, config entries and step names the pipeline uses. */
44
+ export class DescribeContext {
45
+ readonly problems: string[];
46
+ readonly models = new Map<string, { schemas: ModelSchemasT; key: string }>();
47
+ readonly secrets = new Map<string, string>();
48
+ readonly configs = new Map<string, ConfigUse>();
49
+ readonly steps = new Map<string, AnyStep>();
50
+ private readonly placements = new Map<
51
+ string,
52
+ { step: AnyStep; cases: readonly CaseOf[] }[]
53
+ >();
54
+ private selects = 0;
55
+
56
+ constructor(problems: readonly string[]) {
57
+ this.problems = [...problems];
58
+ }
59
+
60
+ nextSelectId(): number {
61
+ return ++this.selects;
62
+ }
63
+
64
+ useModel(m: AnyModel, at: string): string {
65
+ const problems = this.problems;
66
+ if (!MODEL_NAME.test(m.name))
67
+ problems.push(`${at}: model name '${m.name}' must match ${MODEL_NAME}`);
68
+ let schemas: ModelSchemasT;
69
+ try {
70
+ schemas = schemaViews(m.schema);
71
+ } catch (e) {
72
+ problems.push(
73
+ `${at}: model '${m.name}' is not representable as JSON Schema (${(e as Error).message})`,
74
+ );
75
+ return m.name;
76
+ }
77
+ const key = JSON.stringify(schemas);
78
+ const prev = this.models.get(m.name);
79
+ if (!prev) this.models.set(m.name, { schemas, key });
80
+ else if (prev.key !== key)
81
+ problems.push(
82
+ `two different schemas are registered under the model name '${m.name}' (one schema per model name)`,
83
+ );
84
+ return m.name;
85
+ }
86
+
87
+ outputViewOf(modelName: string | null): JsonSchema | undefined {
88
+ return modelName ? this.models.get(modelName)?.schemas.output : undefined;
89
+ }
90
+
91
+ useSecret(s: Secret, at: string): string {
92
+ if (!SECRET_NAME.test(s.name))
93
+ this.problems.push(`${at}: secret name '${s.name}' must match ${SECRET_NAME}`);
94
+ const prev = this.secrets.get(s.name);
95
+ if (prev === undefined) this.secrets.set(s.name, s.description);
96
+ else if (prev !== s.description)
97
+ this.problems.push(
98
+ `secret '${s.name}' is declared twice with different descriptions`,
99
+ );
100
+ return s.name;
101
+ }
102
+
103
+ useConfig(e: unknown, at: string): string | undefined {
104
+ const problems = this.problems;
105
+ if (!isConfigEntry(e))
106
+ return void problems.push(
107
+ `${at}: config entries must be config("name", "description", schema)`,
108
+ );
109
+ if (!CONFIG_NAME.test(e.name))
110
+ problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
111
+ let schema: JsonSchema;
112
+ try {
113
+ schema = z.toJSONSchema(e.schema, {
114
+ io: 'input',
115
+ target: 'draft-2020-12',
116
+ }) as JsonSchema;
117
+ delete schema.$schema;
118
+ schema = canonicalize(schema) as JsonSchema;
119
+ } catch (err) {
120
+ problems.push(
121
+ `${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`,
122
+ );
123
+ return e.name;
124
+ }
125
+ const key = JSON.stringify([e.description, schema]);
126
+ const prev = this.configs.get(e.name);
127
+ if (!prev)
128
+ this.configs.set(e.name, {
129
+ description: e.description,
130
+ required: !e.schema.safeParse(undefined).success,
131
+ schema,
132
+ key,
133
+ secretRefs: secretRefsOf(schema),
134
+ });
135
+ else if (prev.key !== key)
136
+ problems.push(
137
+ `two different config entries are named '${e.name}' (one description and schema per config name)`,
138
+ );
139
+ return e.name;
140
+ }
141
+
142
+ registerStep(s: AnyStep, where: string, cases: readonly CaseOf[] = []): void {
143
+ const problems = this.problems;
144
+ if (typeof s.name !== 'string' || !STEP_NAME.test(s.name))
145
+ problems.push(
146
+ `${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`,
147
+ );
148
+ if (RESERVED.has(s.name))
149
+ problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
150
+ const prev = this.placements.get(s.name) ?? [];
151
+ if (prev.some((p) => !exclusive(p.cases, cases)))
152
+ problems.push(`duplicate step name '${s.name}'`);
153
+ else if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
154
+ problems.push(
155
+ `step '${s.name}': its select cases must give it the same output model`,
156
+ );
157
+ this.placements.set(s.name, [...prev, { step: s, cases }]);
158
+ if (!this.steps.has(s.name)) this.steps.set(s.name, s);
159
+ }
160
+
161
+ modelsSection(): ManifestT['models'] {
162
+ return Object.fromEntries([...this.models].map(([k, v]) => [k, v.schemas]));
163
+ }
164
+
165
+ secretsSection(): ManifestT['secrets'] {
166
+ return [...this.secrets]
167
+ .sort(byName)
168
+ .map(([name, description]) => ({ name, description }));
169
+ }
170
+
171
+ configSection(): Pick<ManifestT, 'config'> {
172
+ if (!this.configs.size) return {};
173
+ return {
174
+ config: [...this.configs].sort(byName).map(([name, c]) => ({
175
+ name,
176
+ description: c.description,
177
+ required: c.required,
178
+ schema: c.schema,
179
+ })),
180
+ };
181
+ }
182
+ }