@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -13
- package/manifest.schema.json +87 -9
- package/package.json +1 -1
- package/src/builder/pipeline.ts +55 -2
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +3 -3
- package/src/core/steps.ts +29 -6
- package/src/ctx/index.ts +3 -0
- package/src/ctx/types.ts +4 -1
- package/src/describe/describe.ts +182 -8
- package/src/describe/manifest-schema.ts +36 -3
- package/src/dev/cli.ts +10 -3
- package/src/harness/execute.ts +7 -4
- package/src/harness/main.ts +45 -6
- package/src/harness/validate.ts +19 -1
- package/src/index.ts +3 -2
- package/src/library/compose-deploy.ts +93 -0
- package/src/library/github-source.ts +23 -4
- package/src/library/helm-deploy-ssh.ts +166 -58
- package/src/library/helm-deploy.ts +268 -0
- package/src/library/image-build.ts +223 -39
- package/src/library/index.ts +7 -2
- package/src/library/models.ts +7 -1
- package/src/library/npm.ts +33 -0
- package/src/testing/index.ts +5 -3
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Secret } from "./secrets.ts";
|
|
3
|
+
|
|
4
|
+
const CONFIG_REF = Symbol.for("olve.configRef");
|
|
5
|
+
declare const VALUE: unique symbol;
|
|
6
|
+
|
|
7
|
+
interface RefTarget {
|
|
8
|
+
readonly entry: AnyConfigEntry;
|
|
9
|
+
readonly path: readonly string[];
|
|
10
|
+
/** Set on a reference narrowed by a `select` case: `<ref>.<discriminator>=<value>`. */
|
|
11
|
+
readonly case?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A typed reference to a config value (`TARGET.beta.host`), accepted by library steps in place of a
|
|
16
|
+
* literal. It is recorded in the manifest as `{"$config": "target.beta.host"}` and resolved from the
|
|
17
|
+
* run's config when the step runs; the step receives the entry automatically.
|
|
18
|
+
*/
|
|
19
|
+
export interface ConfigRef<T = unknown> {
|
|
20
|
+
readonly [CONFIG_REF]: RefTarget;
|
|
21
|
+
readonly [VALUE]?: T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A literal or a reference to a config value of the same type. */
|
|
25
|
+
export type Configurable<T> = T | ConfigRef<T>;
|
|
26
|
+
|
|
27
|
+
type Fields<T> = T extends readonly unknown[] ? never : T extends object ? T : never;
|
|
28
|
+
type Children<T> = [Fields<NonNullable<T>>] extends [never]
|
|
29
|
+
? {}
|
|
30
|
+
: { readonly [K in keyof Fields<NonNullable<T>> & string]-?: ConfigNode<Fields<NonNullable<T>>[K] | (undefined extends T ? undefined : never)> };
|
|
31
|
+
/** A reference to a config value, and to each of its fields. */
|
|
32
|
+
export type ConfigNode<T> = ConfigRef<T> & Children<T>;
|
|
33
|
+
|
|
34
|
+
/** Fields of an entry that are its own properties; reach config fields with these names through `$`. */
|
|
35
|
+
export type ReservedConfigField = "kind" | "name" | "description" | "schema" | "$";
|
|
36
|
+
|
|
37
|
+
/** A named, typed value stored on the instance (one YAML document per pipeline), declared by the steps that use it. */
|
|
38
|
+
export interface ConfigEntryBase<N extends string = string, S extends z.ZodType = z.ZodType> {
|
|
39
|
+
readonly kind: "config";
|
|
40
|
+
readonly name: N;
|
|
41
|
+
readonly description: string;
|
|
42
|
+
readonly schema: S;
|
|
43
|
+
}
|
|
44
|
+
export type ConfigEntry<N extends string = string, S extends z.ZodType = z.ZodType> = ConfigEntryBase<N, S> &
|
|
45
|
+
ConfigRef<z.output<S>> &
|
|
46
|
+
Omit<Children<z.output<S>>, ReservedConfigField> & { readonly $: ConfigNode<z.output<S>> };
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
export type AnyConfigEntry = ConfigEntryBase<string, any>;
|
|
49
|
+
|
|
50
|
+
/** Config names are YAML keys: camelCase identifiers. */
|
|
51
|
+
export const CONFIG_NAME = /^[a-z][a-zA-Z0-9]*$/;
|
|
52
|
+
|
|
53
|
+
const NOT_REFS = new Set<PropertyKey>(["then", "toJSON", "constructor", "valueOf", "toString", "asymmetricMatch", "$$typeof", "nodeType"]);
|
|
54
|
+
|
|
55
|
+
function node(entry: AnyConfigEntry, path: readonly string[], kase?: string): unknown {
|
|
56
|
+
const target = Object.freeze({ [CONFIG_REF]: Object.freeze({ entry, path, ...(kase ? { case: kase } : {}) }) });
|
|
57
|
+
return new Proxy(target, {
|
|
58
|
+
get(t, prop) {
|
|
59
|
+
if (prop === CONFIG_REF) return t[CONFIG_REF];
|
|
60
|
+
if (typeof prop === "symbol" || NOT_REFS.has(prop)) return undefined;
|
|
61
|
+
return node(entry, [...path, prop], kase);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `ref` narrowed to the union member whose `discriminator` is `value` (a `select` case). */
|
|
67
|
+
export function narrowConfigRef(ref: ConfigRef, discriminator: string, value: string): unknown {
|
|
68
|
+
const { entry, path } = ref[CONFIG_REF];
|
|
69
|
+
return node(entry, path, `${[entry.name, ...path].join(".")}.${discriminator}=${value}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function config<const N extends string, S extends z.ZodType>(name: N, description: string, schema: S): ConfigEntry<N, S> {
|
|
73
|
+
const ref = { entry: undefined as unknown as AnyConfigEntry, path: [] as readonly string[] };
|
|
74
|
+
const target = Object.freeze({ kind: "config" as const, name, description, schema, [CONFIG_REF]: ref });
|
|
75
|
+
const entry: unknown = new Proxy(target, {
|
|
76
|
+
get(t, prop) {
|
|
77
|
+
if (prop in t) return t[prop as keyof typeof t];
|
|
78
|
+
if (typeof prop === "symbol" || NOT_REFS.has(prop)) return undefined;
|
|
79
|
+
if (prop === "$") return node(entry as AnyConfigEntry, []);
|
|
80
|
+
return node(entry as AnyConfigEntry, [prop]);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
ref.entry = entry as AnyConfigEntry;
|
|
84
|
+
Object.freeze(ref);
|
|
85
|
+
return entry as ConfigEntry<N, S>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isConfigEntry(v: unknown): v is AnyConfigEntry {
|
|
89
|
+
return typeof v === "object" && v !== null && (v as { kind?: unknown }).kind === "config" && (v as { schema?: unknown }).schema instanceof z.ZodType;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isConfigRef(v: unknown): v is ConfigRef {
|
|
93
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[CONFIG_REF] !== undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The entry and field path a reference points at, and the `select` case it was narrowed by. */
|
|
97
|
+
export function configRefTarget(ref: ConfigRef): { entry: AnyConfigEntry; path: readonly string[]; case?: string } {
|
|
98
|
+
return ref[CONFIG_REF];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `target.beta.host` */
|
|
102
|
+
export function configRefPath(ref: ConfigRef): string {
|
|
103
|
+
const { entry, path } = configRefTarget(ref);
|
|
104
|
+
return [entry.name, ...path].join(".");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const SECRET_REF_KIND = "secretRef";
|
|
108
|
+
|
|
109
|
+
/** A config field that names a secret: `{ secret: NAME }` in YAML. The value never enters the config. */
|
|
110
|
+
export function secretRef<const N extends string>(s: Secret<N>) {
|
|
111
|
+
return z.strictObject({ secret: z.literal(s.name) }).meta({ "x-olve-kind": SECRET_REF_KIND });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `ctx.config`: exactly the entries a step declares, after parsing (defaults applied). */
|
|
115
|
+
export type ConfigValues<C extends AnyConfigEntry> = { readonly [E in C as E["name"]]: z.output<E["schema"]> };
|
|
116
|
+
|
|
117
|
+
// ─── select: branching on a config union ────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
type IsLiteralString<T> = [T] extends [string] ? (string extends T ? false : true) : false;
|
|
120
|
+
/** The fields of union `U` whose type is a string literal in every member: its discriminator(s). */
|
|
121
|
+
export type DiscriminatorOf<U> = { [K in keyof U & string]-?: IsLiteralString<U[K]> extends true ? K : never }[keyof U & string];
|
|
122
|
+
/** The member of `U` whose discriminator `D` is `V`. */
|
|
123
|
+
export type VariantOf<U, D extends string, V> = Extract<U, { readonly [K in D]: V }>;
|
|
124
|
+
|
|
125
|
+
function unwrap(schema: z.ZodType): z.ZodType {
|
|
126
|
+
let s = schema as unknown as { _zod: { def: { innerType?: z.ZodType } } };
|
|
127
|
+
for (let i = 0; i < 16 && s._zod.def.innerType; i++) s = s._zod.def.innerType as never;
|
|
128
|
+
return s as unknown as z.ZodType;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The discriminated union a reference points at: its discriminator and, per value, nothing else.
|
|
133
|
+
* Undefined when the referenced value is not a `z.discriminatedUnion`.
|
|
134
|
+
*/
|
|
135
|
+
export function unionAt(ref: ConfigRef): { discriminator: string; values: string[] } | undefined {
|
|
136
|
+
const { entry, path } = configRefTarget(ref);
|
|
137
|
+
let s = unwrap(entry.schema as z.ZodType) as unknown as { _zod: { def: Record<string, unknown> } };
|
|
138
|
+
for (const seg of path) {
|
|
139
|
+
const shape = s._zod.def.shape as Record<string, z.ZodType> | undefined;
|
|
140
|
+
if (!shape || !(seg in shape)) return undefined;
|
|
141
|
+
s = unwrap(shape[seg]!) as never;
|
|
142
|
+
}
|
|
143
|
+
const def = s._zod.def as { discriminator?: string; options?: { _zod: { propValues?: Record<string, Set<unknown>> } }[] };
|
|
144
|
+
if (typeof def.discriminator !== "string" || !Array.isArray(def.options)) return undefined;
|
|
145
|
+
const values = def.options.flatMap((o) => [...(o._zod.propValues?.[def.discriminator!] ?? [])]).filter((v): v is string => typeof v === "string");
|
|
146
|
+
return { discriminator: def.discriminator, values };
|
|
147
|
+
}
|
package/src/core/handlers.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IMPLEMENTATION, SKIPPED, type SkippedBrand } from "./brands.ts";
|
|
2
2
|
import type { Ctx } from "../ctx/types.ts";
|
|
3
|
-
import type { AnyStep, LibraryStep, OutputValue, SecretsOf, StepInput } from "./steps.ts";
|
|
3
|
+
import type { AnyStep, ConfigOf, LibraryStep, OutputValue, SecretsOf, StepInput } from "./steps.ts";
|
|
4
4
|
import type { Awaitable } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
/** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
|
|
@@ -16,7 +16,7 @@ export function isSkipped(v: unknown): v is Skipped {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export type HandlerResult<S extends AnyStep> = OutputValue<S> | Skipped;
|
|
19
|
-
export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>>) => Awaitable<HandlerResult<S>>;
|
|
19
|
+
export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<HandlerResult<S>>;
|
|
20
20
|
|
|
21
21
|
export interface Implementation<S extends AnyStep = AnyStep> {
|
|
22
22
|
readonly step: S;
|
|
@@ -34,7 +34,7 @@ type NoExtraFields<R, T> = R extends Skipped | void
|
|
|
34
34
|
*/
|
|
35
35
|
export function implement<S extends AnyStep, R extends HandlerResult<S>>(
|
|
36
36
|
step: S & (S extends LibraryStep ? "❌ library steps already carry their implementation" : unknown),
|
|
37
|
-
handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
|
|
37
|
+
handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
|
|
38
38
|
): Implementation<S> {
|
|
39
39
|
return Object.freeze({ [IMPLEMENTATION]: true, step, handler: handler as unknown as Handler<S> }) as Implementation<S>;
|
|
40
40
|
}
|
package/src/core/steps.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import { STEP, type StepBrand } from "./brands.ts";
|
|
3
3
|
import type { AnyModel, Infer, Optional } from "./models.ts";
|
|
4
|
+
import type { AnyConfigEntry } from "./config.ts";
|
|
5
|
+
import { configRefsIn } from "./config-refs.ts";
|
|
4
6
|
import type { Secret } from "./secrets.ts";
|
|
5
7
|
import type { Simplify } from "./types.ts";
|
|
6
8
|
|
|
@@ -17,13 +19,14 @@ export type InferEntry<E> =
|
|
|
17
19
|
export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
|
|
18
20
|
|
|
19
21
|
/** `custom` steps have a handler file; the others are library steps implemented by the SDK. */
|
|
20
|
-
export type StepKind = "custom" | "script" | "gitHubSource" | "imageBuild" | "helmDeployOverSsh";
|
|
22
|
+
export type StepKind = "custom" | "script" | "gitHubSource" | "imageBuild" | "helmDeployOverSsh" | "helmDeployInCluster" | "composeDeploy";
|
|
21
23
|
|
|
22
24
|
export interface Step<
|
|
23
25
|
N extends string = string,
|
|
24
26
|
I extends InputSpec = InputSpec,
|
|
25
27
|
O extends AnyModel | null = AnyModel | null,
|
|
26
28
|
Sec extends string = string,
|
|
29
|
+
Cfg extends AnyConfigEntry = never,
|
|
27
30
|
> {
|
|
28
31
|
readonly [StepBrand]: true;
|
|
29
32
|
readonly kind: StepKind;
|
|
@@ -33,8 +36,12 @@ export interface Step<
|
|
|
33
36
|
readonly input: I;
|
|
34
37
|
readonly output: O;
|
|
35
38
|
readonly secrets: readonly Secret<Sec>[];
|
|
39
|
+
/** Config entries the step receives as `ctx.config`. */
|
|
40
|
+
readonly config: readonly Cfg[];
|
|
36
41
|
readonly timeoutSeconds?: number;
|
|
37
42
|
readonly retries?: number;
|
|
43
|
+
/** The step pod gets the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`). */
|
|
44
|
+
readonly clusterAccess?: boolean;
|
|
38
45
|
/** Library steps carry their own implementation and config. */
|
|
39
46
|
readonly builtin?: LibraryImplementation;
|
|
40
47
|
}
|
|
@@ -47,10 +54,12 @@ export interface LibraryImplementation {
|
|
|
47
54
|
}
|
|
48
55
|
export type LibraryStep = { readonly builtin: LibraryImplementation };
|
|
49
56
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
-
export type AnyStep = Step<string, any, AnyModel | null, any>;
|
|
57
|
+
export type AnyStep = Step<string, any, AnyModel | null, any, any>;
|
|
51
58
|
|
|
52
59
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
-
export type SecretsOf<S> = S extends Step<any, any, any, infer Sec> ? Sec : never;
|
|
60
|
+
export type SecretsOf<S> = S extends Step<any, any, any, infer Sec, any> ? Sec : never;
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
export type ConfigOf<S> = S extends Step<any, any, any, any, infer C> ? C : never;
|
|
54
63
|
export type OutputValue<S extends AnyStep> = S["output"] extends AnyModel ? Infer<S["output"]> : void;
|
|
55
64
|
|
|
56
65
|
/** Step names must be literals (identity, duplicate detection and mapper keys depend on it). */
|
|
@@ -61,7 +70,7 @@ export const STEP_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
|
61
70
|
/** Names that cannot be steps: `input` is the pipeline input's root in mappings. */
|
|
62
71
|
export const RESERVED_STEP_NAMES: readonly string[] = ["input"];
|
|
63
72
|
|
|
64
|
-
export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[]> {
|
|
73
|
+
export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[], Cfg extends readonly AnyConfigEntry[] = []> {
|
|
65
74
|
name: LiteralName<N>;
|
|
66
75
|
/** What the step does, shown in the UI. Required; may be empty. */
|
|
67
76
|
description: string;
|
|
@@ -70,12 +79,23 @@ export interface StepDef<N extends string, I extends InputSpec, O extends AnyMod
|
|
|
70
79
|
input?: I;
|
|
71
80
|
output?: O;
|
|
72
81
|
secrets?: Sec;
|
|
82
|
+
/** Config entries delivered as `ctx.config` (typed to exactly these). */
|
|
83
|
+
config?: Cfg;
|
|
73
84
|
timeoutSeconds?: number;
|
|
74
85
|
/** Automatic retries (an idempotency claim). Default 0. */
|
|
75
86
|
retries?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Ask for a Kubernetes API token in the step pod: the instance's step ServiceAccount
|
|
89
|
+
* (`Kubernetes:StepServiceAccount`), if it grants one. Default false (no token).
|
|
90
|
+
*/
|
|
91
|
+
clusterAccess?: boolean;
|
|
76
92
|
}
|
|
77
93
|
|
|
78
|
-
|
|
94
|
+
/** `configFrom`: a library step's definition; the config entries it references are added to the step's `config`. */
|
|
95
|
+
export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?: LibraryImplementation, configFrom?: unknown): AnyStep {
|
|
96
|
+
const config = new Map<string, AnyConfigEntry>();
|
|
97
|
+
for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? []) if (!config.has(e.name)) config.set(e.name, e);
|
|
98
|
+
configRefsIn(configFrom, config);
|
|
79
99
|
const s = {
|
|
80
100
|
[STEP]: true,
|
|
81
101
|
kind,
|
|
@@ -85,8 +105,10 @@ export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?:
|
|
|
85
105
|
input: Object.freeze({ ...((def.input as object | undefined) ?? {}) }),
|
|
86
106
|
output: def.output ?? null,
|
|
87
107
|
secrets: Object.freeze([...((def.secrets as readonly Secret[] | undefined) ?? [])]),
|
|
108
|
+
config: Object.freeze([...config.values()]),
|
|
88
109
|
...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
|
|
89
110
|
...(def.retries !== undefined ? { retries: def.retries } : {}),
|
|
111
|
+
...(def.clusterAccess === true ? { clusterAccess: true } : {}),
|
|
90
112
|
...(builtin ? { builtin } : {}),
|
|
91
113
|
};
|
|
92
114
|
return Object.freeze(s) as unknown as AnyStep;
|
|
@@ -102,6 +124,7 @@ export function step<
|
|
|
102
124
|
const I extends InputSpec = {},
|
|
103
125
|
O extends AnyModel | null = null,
|
|
104
126
|
const Sec extends readonly Secret[] = [],
|
|
105
|
-
|
|
127
|
+
const Cfg extends readonly AnyConfigEntry[] = [],
|
|
128
|
+
>(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]["name"], Cfg[number]> {
|
|
106
129
|
return makeStep("custom", def as never) as never;
|
|
107
130
|
}
|
package/src/ctx/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface CtxOptions {
|
|
|
12
12
|
secrets: readonly Secret[];
|
|
13
13
|
/** Secret values that take precedence over the environment (tests). */
|
|
14
14
|
secretValues?: Readonly<Record<string, string>>;
|
|
15
|
+
/** Parsed config entries of the step (`ctx.config`). */
|
|
16
|
+
config?: Readonly<Record<string, unknown>>;
|
|
15
17
|
paths: { root: string; workFiles: string; tmp: string };
|
|
16
18
|
signal: AbortSignal;
|
|
17
19
|
/** Where log lines go (default stderr). */
|
|
@@ -102,6 +104,7 @@ export function createCtx(o: CtxOptions): Ctx {
|
|
|
102
104
|
const filesDeps = { run: o.run, workFiles: o.paths.workFiles, tmpDir: o.paths.tmp, root: o.paths.root, signal: o.signal, log };
|
|
103
105
|
return {
|
|
104
106
|
secrets: createSecrets(o.secrets, o.secretValues) as Ctx["secrets"],
|
|
107
|
+
config: Object.freeze({ ...o.config }) as Ctx["config"],
|
|
105
108
|
sh,
|
|
106
109
|
exec,
|
|
107
110
|
readText: async (p) => readFileSync(resolve(p), "utf8"),
|
package/src/ctx/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Skipped } from "../core/handlers.ts";
|
|
2
2
|
import type { FileRef } from "../core/models.ts";
|
|
3
|
+
import type { AnyConfigEntry, ConfigValues } from "../core/config.ts";
|
|
3
4
|
import type { DeclaredSecrets } from "../core/secrets.ts";
|
|
4
5
|
|
|
5
6
|
/** Facts about the current execution, from the service (see README "Environment"). */
|
|
@@ -79,9 +80,11 @@ export interface OutputFileOptions {
|
|
|
79
80
|
name?: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
export interface Ctx<Sec extends string = string> {
|
|
83
|
+
export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = never> {
|
|
83
84
|
/** Exactly the secrets the step declares, read from the environment. */
|
|
84
85
|
readonly secrets: DeclaredSecrets<Sec>;
|
|
86
|
+
/** Exactly the config entries the step declares, from the run's config snapshot (defaults applied). */
|
|
87
|
+
readonly config: ConfigValues<Cfg>;
|
|
85
88
|
readonly sh: Sh;
|
|
86
89
|
/** Run a binary directly, without a shell (works in shell-less images). Rejects on non-zero exit. */
|
|
87
90
|
exec(argv: readonly string[], opts?: ExecOptions): Promise<void>;
|
package/src/describe/describe.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// `describe`: evaluates the contracts (models + pipeline, never handlers) into the manifest and runs
|
|
2
2
|
// every describe-time check. All problems are collected and reported together.
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { CONFIG_NAME, isConfigEntry, SECRET_REF_KIND } from "../core/config.ts";
|
|
5
|
+
import { CONFIG_CASE_KEY, CONFIG_REF_KEY } from "../core/config-refs.ts";
|
|
6
|
+
import { configRefPath, configRefTarget, isConfigRef, unionAt } from "../core/config.ts";
|
|
4
7
|
import { isModel, isOptional, MODEL_NAME, type AnyModel } from "../core/models.ts";
|
|
5
8
|
import { SECRET_NAME, type Secret } from "../core/secrets.ts";
|
|
6
9
|
import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from "../core/steps.ts";
|
|
@@ -127,14 +130,46 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
127
130
|
return s.name;
|
|
128
131
|
};
|
|
129
132
|
|
|
133
|
+
// ── config entries ──
|
|
134
|
+
const configs = new Map<string, { description: string; required: boolean; schema: JsonSchema; key: string; secretRefs: string[] }>();
|
|
135
|
+
const useConfig = (e: unknown, at: string): string | undefined => {
|
|
136
|
+
if (!isConfigEntry(e)) return void problems.push(`${at}: config entries must be config("name", "description", schema)`);
|
|
137
|
+
if (!CONFIG_NAME.test(e.name)) problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
|
|
138
|
+
let schema: JsonSchema;
|
|
139
|
+
try {
|
|
140
|
+
schema = z.toJSONSchema(e.schema, { io: "input", target: "draft-2020-12" }) as JsonSchema;
|
|
141
|
+
delete schema.$schema;
|
|
142
|
+
schema = canonicalize(schema) as JsonSchema;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
problems.push(`${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`);
|
|
145
|
+
return e.name;
|
|
146
|
+
}
|
|
147
|
+
const key = JSON.stringify([e.description, schema]);
|
|
148
|
+
const prev = configs.get(e.name);
|
|
149
|
+
if (!prev) configs.set(e.name, { description: e.description, required: !e.schema.safeParse(undefined).success, schema, key, secretRefs: secretRefsOf(schema) });
|
|
150
|
+
else if (prev.key !== key) problems.push(`two different config entries are named '${e.name}' (one description and schema per config name)`);
|
|
151
|
+
return e.name;
|
|
152
|
+
};
|
|
153
|
+
|
|
130
154
|
// ── step identity ──
|
|
131
155
|
const steps = new Map<string, AnyStep>();
|
|
132
|
-
const
|
|
156
|
+
const placements = new Map<string, { step: AnyStep; cases: readonly CaseOf[] }[]>();
|
|
157
|
+
// Two places are mutually exclusive when they sit in different cases of the same select.
|
|
158
|
+
const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) => a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
|
|
159
|
+
const registerStep = (s: AnyStep, where: string, cases: readonly CaseOf[] = []) => {
|
|
133
160
|
if (typeof s.name !== "string" || !STEP_NAME.test(s.name))
|
|
134
161
|
problems.push(`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`);
|
|
135
162
|
if (RESERVED.has(s.name)) problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
const prev = placements.get(s.name) ?? [];
|
|
164
|
+
if (prev.some((p) => !exclusive(p.cases, cases))) problems.push(`duplicate step name '${s.name}'`);
|
|
165
|
+
else if (prev.length) {
|
|
166
|
+
if (prev.some((p) => p.step !== s && p.step.kind === "custom" && s.kind === "custom"))
|
|
167
|
+
problems.push(`step '${s.name}': two different custom steps share the name across select cases; handlers pair by name, so use one step or different names`);
|
|
168
|
+
if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
|
|
169
|
+
problems.push(`step '${s.name}': its select cases must give it the same output model`);
|
|
170
|
+
}
|
|
171
|
+
placements.set(s.name, [...prev, { step: s, cases }]);
|
|
172
|
+
if (!steps.has(s.name)) steps.set(s.name, s);
|
|
138
173
|
};
|
|
139
174
|
|
|
140
175
|
const inputModel = data.input;
|
|
@@ -144,7 +179,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
144
179
|
const outputViewOf = (modelName: string | null): JsonSchema | undefined => (modelName ? models.get(modelName)?.schemas.output : undefined);
|
|
145
180
|
|
|
146
181
|
// ── one step ──
|
|
147
|
-
const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean): StepDescriptorT => {
|
|
182
|
+
const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean, cases: readonly CaseOf[] = []): StepDescriptorT => {
|
|
148
183
|
const s = node.step;
|
|
149
184
|
const at = `step '${s.name}'`;
|
|
150
185
|
if (typeof s.image !== "string" || s.image.trim() === "") problems.push(`${at}: image must be a non-empty string`);
|
|
@@ -194,7 +229,26 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
194
229
|
}
|
|
195
230
|
}
|
|
196
231
|
|
|
232
|
+
for (const key of Object.keys(s.input as Record<string, unknown>))
|
|
233
|
+
if (key.startsWith("$")) problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
|
|
197
234
|
const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
|
|
235
|
+
const configNames = [...new Set((s.config ?? []).map((c) => useConfig(c, at)).filter((n): n is string => n !== undefined))].sort();
|
|
236
|
+
// A library step that references parts of an entry needs the secrets those parts name; any other
|
|
237
|
+
// step receiving an entry needs every secret the entry names.
|
|
238
|
+
let refs: { ref: string; case?: string }[] = [];
|
|
239
|
+
try {
|
|
240
|
+
refs = s.builtin ? recordedConfigRefs(s.builtin.manifestConfig()) : [];
|
|
241
|
+
} catch {
|
|
242
|
+
// reported with the library configuration below
|
|
243
|
+
}
|
|
244
|
+
for (const name of configNames) {
|
|
245
|
+
const into = refs.filter((r) => r.ref.split(".")[0] === name);
|
|
246
|
+
const needed = into.length === 0
|
|
247
|
+
? configs.get(name)?.secretRefs ?? []
|
|
248
|
+
: [...new Set(into.flatMap((r) => secretRefsOf(subSchemas(configs.get(name)?.schema, r.ref.split(".").slice(1), r.case, name))))].sort();
|
|
249
|
+
for (const ref of needed)
|
|
250
|
+
if (!secretNames.includes(ref)) problems.push(`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`);
|
|
251
|
+
}
|
|
198
252
|
let handlerHash: string;
|
|
199
253
|
let config: Record<string, unknown> | undefined;
|
|
200
254
|
if (s.kind === "custom") {
|
|
@@ -208,6 +262,13 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
208
262
|
problems.push(`${at}: invalid ${s.kind} configuration (${(e as Error).message})`);
|
|
209
263
|
config = {};
|
|
210
264
|
}
|
|
265
|
+
for (const { ref, case: kase } of recordedConfigRefs(config)) {
|
|
266
|
+
const [entry, ...path] = ref.split(".") as [string, ...string[]];
|
|
267
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
268
|
+
problems.push(`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`);
|
|
269
|
+
if (!configNames.includes(entry)) problems.push(`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`);
|
|
270
|
+
else if (!resolveField(configs.get(entry)?.schema, path)) problems.push(`${at}: config reference '${ref}': config '${entry}' has no field '${path.join(".")}'`);
|
|
271
|
+
}
|
|
211
272
|
handlerHash = libraryHandlerHash(s.kind, config, opts.harness);
|
|
212
273
|
} else {
|
|
213
274
|
problems.push(`${at}: library step of kind '${s.kind}' has no implementation`);
|
|
@@ -230,7 +291,10 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
230
291
|
...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
|
|
231
292
|
...(s.retries !== undefined ? { retries: s.retries } : {}),
|
|
232
293
|
...(config !== undefined ? { config: config as Record<string, z.core.util.JSONType> } : {}),
|
|
294
|
+
...(configNames.length ? { configEntries: configNames } : {}),
|
|
295
|
+
...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
|
|
233
296
|
};
|
|
297
|
+
if (s.clusterAccess !== undefined && typeof s.clusterAccess !== "boolean") problems.push(`${at}: clusterAccess must be a boolean`);
|
|
234
298
|
if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
|
|
235
299
|
return out;
|
|
236
300
|
};
|
|
@@ -275,17 +339,22 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
275
339
|
};
|
|
276
340
|
|
|
277
341
|
// ── stages ──
|
|
278
|
-
|
|
342
|
+
let selects = 0;
|
|
343
|
+
const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>, cases: readonly CaseOf[] = []): Stage[] => {
|
|
279
344
|
const out: Stage[] = [];
|
|
280
345
|
for (const n of nodes) {
|
|
346
|
+
if (n.kind === "select") {
|
|
347
|
+
out.push(describeSelect(n, avail, cases, ++selects));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
281
350
|
if (n.kind === "stage") {
|
|
282
351
|
const descs = n.steps.map((ss) => {
|
|
283
352
|
if (!isStep(ss.step)) {
|
|
284
353
|
problems.push("a stage entry is not a step");
|
|
285
354
|
return undefined;
|
|
286
355
|
}
|
|
287
|
-
registerStep(ss.step, "stage");
|
|
288
|
-
return describeStep(ss, avail, false);
|
|
356
|
+
registerStep(ss.step, "stage", cases);
|
|
357
|
+
return describeStep(ss, avail, false, cases);
|
|
289
358
|
}).filter((d): d is StepDescriptorT => d !== undefined);
|
|
290
359
|
// Outputs become available only after the barrier (same-stage siblings can't see each other).
|
|
291
360
|
for (const ss of n.steps) if (isStep(ss.step)) avail.set(ss.step.name, { model: ss.step.output?.name ?? null, maybe: false });
|
|
@@ -305,13 +374,43 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
305
374
|
when[field] = values;
|
|
306
375
|
}
|
|
307
376
|
const inner = new Map(avail);
|
|
308
|
-
const stages = walk(n.nodes, inner);
|
|
377
|
+
const stages = walk(n.nodes, inner, cases);
|
|
309
378
|
for (const [name, a] of inner) if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
|
|
310
379
|
out.push({ when, stages });
|
|
311
380
|
}
|
|
312
381
|
}
|
|
313
382
|
return out;
|
|
314
383
|
};
|
|
384
|
+
const describeSelect = (n: Extract<StageNode, { kind: "select" }>, avail: Map<string, Avail>, cases: readonly CaseOf[], id: number): Stage => {
|
|
385
|
+
if (!isConfigRef(n.on)) {
|
|
386
|
+
problems.push("select: the first argument must be a config reference");
|
|
387
|
+
return { select: "", discriminator: "", cases: {} };
|
|
388
|
+
}
|
|
389
|
+
const ref = configRefPath(n.on);
|
|
390
|
+
const { entry, case: kase } = configRefTarget(n.on);
|
|
391
|
+
useConfig(entry, `select '${ref}'`);
|
|
392
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
393
|
+
problems.push(`select '${ref}': the reference is narrowed to the case '${kase}' and can only be used inside it`);
|
|
394
|
+
const union = unionAt(n.on);
|
|
395
|
+
if (!union) problems.push(`select '${ref}': it must reference a z.discriminatedUnion in config '${entry.name}'`);
|
|
396
|
+
else {
|
|
397
|
+
for (const v of union.values) if (!Object.hasOwn(n.cases, v)) problems.push(`select '${ref}': no case for ${union.discriminator} '${v}'`);
|
|
398
|
+
for (const v of Object.keys(n.cases)) if (!union.values.includes(v)) problems.push(`select '${ref}': '${v}' is not a value of ${union.discriminator} (${union.values.join(", ")})`);
|
|
399
|
+
}
|
|
400
|
+
const discriminator = union?.discriminator ?? "";
|
|
401
|
+
const branches = Object.entries(n.cases).map(([value, nodes]) => {
|
|
402
|
+
const inner = new Map(avail);
|
|
403
|
+
const stages = walk(nodes, inner, [...cases, { select: id, value, cond: `${ref}.${discriminator}=${value}` }]);
|
|
404
|
+
return { value, inner, stages };
|
|
405
|
+
});
|
|
406
|
+
// A step every case has is as available as in its cases; one only some cases have is maybe-available.
|
|
407
|
+
const added = new Set(branches.flatMap((b) => [...b.inner.keys()].filter((k) => !avail.has(k))));
|
|
408
|
+
for (const name of added) {
|
|
409
|
+
const present = branches.map((b) => b.inner.get(name)).filter((a): a is Avail => a !== undefined);
|
|
410
|
+
avail.set(name, { model: present[0]!.model, maybe: present.length < branches.length || present.some((a) => a.maybe) });
|
|
411
|
+
}
|
|
412
|
+
return { select: ref, discriminator, cases: Object.fromEntries(branches.map((b) => [b.value, b.stages])) };
|
|
413
|
+
};
|
|
315
414
|
const stages = walk(data.nodes, new Map([["input", { model: inputModel.name, maybe: false }]]));
|
|
316
415
|
|
|
317
416
|
// ── failure handlers ──
|
|
@@ -369,6 +468,13 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
369
468
|
harness: opts.harness,
|
|
370
469
|
models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
|
|
371
470
|
secrets: [...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([name, description]) => ({ name, description })),
|
|
471
|
+
...(configs.size
|
|
472
|
+
? {
|
|
473
|
+
config: [...configs]
|
|
474
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
475
|
+
.map(([name, c]) => ({ name, description: c.description, required: c.required, schema: c.schema })),
|
|
476
|
+
}
|
|
477
|
+
: {}),
|
|
372
478
|
triggers,
|
|
373
479
|
stages,
|
|
374
480
|
failureHandlers,
|
|
@@ -381,6 +487,74 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
381
487
|
/** Canonical manifest text (what `describe` prints and the service hashes). */
|
|
382
488
|
export const manifestText = (m: ManifestT): string => canonicalJson(m);
|
|
383
489
|
|
|
490
|
+
/**
|
|
491
|
+
* The schemas at `path` inside an entry's schema: every union branch that has the field, except where
|
|
492
|
+
* `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
|
|
493
|
+
*/
|
|
494
|
+
export function subSchemas(schema: unknown, path: readonly string[], kase: string | undefined, entry: string): unknown[] {
|
|
495
|
+
let narrow: { at: string; discriminator: string; value: string } | undefined;
|
|
496
|
+
if (kase) {
|
|
497
|
+
const eq = kase.lastIndexOf("=");
|
|
498
|
+
const left = kase.slice(0, eq);
|
|
499
|
+
const dot = left.lastIndexOf(".");
|
|
500
|
+
narrow = { at: left.slice(0, dot), discriminator: left.slice(dot + 1), value: kase.slice(eq + 1) };
|
|
501
|
+
}
|
|
502
|
+
const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
|
|
503
|
+
if (node === null || typeof node !== "object") return [];
|
|
504
|
+
const o = node as JsonSchema;
|
|
505
|
+
const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
|
|
506
|
+
if (Array.isArray(branches)) {
|
|
507
|
+
const chosen = narrow && narrow.at === here
|
|
508
|
+
? branches.filter((b) => ((b.properties as Record<string, JsonSchema> | undefined)?.[narrow!.discriminator] as JsonSchema | undefined)?.const === narrow!.value)
|
|
509
|
+
: branches;
|
|
510
|
+
return chosen.flatMap((b) => walk(b, rest, here));
|
|
511
|
+
}
|
|
512
|
+
if (rest.length === 0) return [node];
|
|
513
|
+
const props = o.properties as Record<string, JsonSchema> | undefined;
|
|
514
|
+
if (props && Object.hasOwn(props, rest[0]!)) return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
|
|
515
|
+
return [];
|
|
516
|
+
};
|
|
517
|
+
return walk(schema, path, entry);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Secret names of every secretRef inside a config entry's JSON Schema. */
|
|
521
|
+
export function secretRefsOf(schema: unknown): string[] {
|
|
522
|
+
const out = new Set<string>();
|
|
523
|
+
const walk = (v: unknown) => {
|
|
524
|
+
if (Array.isArray(v)) return v.forEach(walk);
|
|
525
|
+
if (v === null || typeof v !== "object") return;
|
|
526
|
+
const o = v as JsonSchema;
|
|
527
|
+
if (o["x-olve-kind"] === SECRET_REF_KIND) {
|
|
528
|
+
const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret?.const;
|
|
529
|
+
if (typeof name === "string") out.add(name);
|
|
530
|
+
}
|
|
531
|
+
Object.values(o).forEach(walk);
|
|
532
|
+
};
|
|
533
|
+
walk(schema);
|
|
534
|
+
return [...out].sort();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a library step's recorded configuration. */
|
|
538
|
+
export function recordedConfigRefs(v: unknown, out: { ref: string; case?: string }[] = []): { ref: string; case?: string }[] {
|
|
539
|
+
if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
|
|
540
|
+
else if (v !== null && typeof v === "object") {
|
|
541
|
+
const o = v as Record<string, unknown>;
|
|
542
|
+
const keys = Object.keys(o).sort();
|
|
543
|
+
const isRef = typeof o[CONFIG_REF_KEY] === "string"
|
|
544
|
+
&& (keys.length === 1 ? keys[0] === CONFIG_REF_KEY : keys.length === 2 && keys[0] === CONFIG_CASE_KEY && keys[1] === CONFIG_REF_KEY && typeof o[CONFIG_CASE_KEY] === "string");
|
|
545
|
+
if (isRef) out.push({ ref: o[CONFIG_REF_KEY] as string, ...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}) });
|
|
546
|
+
else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
|
|
552
|
+
interface CaseOf {
|
|
553
|
+
select: number;
|
|
554
|
+
value: string;
|
|
555
|
+
cond: string;
|
|
556
|
+
}
|
|
557
|
+
|
|
384
558
|
function camel(name: string): string {
|
|
385
559
|
return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
386
560
|
}
|