@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 +3 -0
- package/package.json +1 -1
- package/src/ctx/index.ts +2 -0
- package/src/ctx/links.ts +30 -0
- package/src/ctx/types.ts +5 -0
- package/src/describe/config-schema.ts +98 -0
- package/src/describe/context.ts +182 -0
- package/src/describe/describe.ts +37 -851
- package/src/describe/failure-handlers.ts +34 -0
- package/src/describe/schemas.ts +71 -0
- package/src/describe/stages.ts +165 -0
- package/src/describe/step.ts +395 -0
- package/src/describe/triggers.ts +72 -0
- package/src/harness/execute.ts +22 -7
- package/src/index.ts +1 -0
- package/src/library/compose-deploy.ts +2 -0
- package/src/library/helm-deploy-ssh.ts +6 -0
- package/src/library/helm-deploy.ts +14 -0
- package/src/library/models.ts +2 -2
- package/src/testing/index.ts +3 -0
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
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}`];
|
package/src/ctx/links.ts
ADDED
|
@@ -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
|
+
}
|