@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.10
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/LICENSE +21 -0
- package/README.md +467 -2
- package/manifest.schema.json +674 -0
- package/package.json +60 -4
- package/src/assets.d.ts +10 -0
- package/src/build/build.ts +127 -0
- package/src/build/cli.ts +52 -0
- package/src/build/elf.ts +35 -0
- package/src/build/hashes.ts +96 -0
- package/src/build/project.ts +107 -0
- package/src/builder/pipeline.ts +373 -0
- package/src/builder/refs.ts +26 -0
- package/src/core/brands.ts +15 -0
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +50 -0
- package/src/core/models.ts +69 -0
- package/src/core/origin.ts +20 -0
- package/src/core/secrets.ts +13 -0
- package/src/core/steps.ts +138 -0
- package/src/core/types.ts +6 -0
- package/src/core/webhooks.ts +30 -0
- package/src/ctx/files.ts +161 -0
- package/src/ctx/index.ts +122 -0
- package/src/ctx/sh.ts +197 -0
- package/src/ctx/types.ts +116 -0
- package/src/describe/describe.ts +565 -0
- package/src/describe/manifest-schema.ts +179 -0
- package/src/describe/mapper-source.ts +238 -0
- package/src/describe/record.ts +107 -0
- package/src/dev/cli.ts +87 -0
- package/src/harness/env.ts +58 -0
- package/src/harness/execute.ts +97 -0
- package/src/harness/exit.ts +19 -0
- package/src/harness/index.ts +9 -0
- package/src/harness/main.ts +270 -0
- package/src/harness/result.ts +75 -0
- package/src/harness/validate.ts +92 -0
- package/src/index.ts +15 -0
- package/src/library/checks.ts +23 -0
- package/src/library/compose-deploy.ts +96 -0
- package/src/library/github-source.ts +100 -0
- package/src/library/helm-deploy-ssh.ts +358 -0
- package/src/library/helm-deploy.ts +271 -0
- package/src/library/http-health.ts +34 -0
- package/src/library/image-build.ts +418 -0
- package/src/library/index.ts +18 -0
- package/src/library/models.ts +79 -0
- package/src/library/ssh.ts +50 -0
- package/src/testing/index.ts +82 -0
- package/src/util/canonical.ts +19 -0
- package/src/util/handler-hash.ts +22 -0
- package/src/util/hash.ts +22 -0
- package/src/util/tar.ts +167 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { IMPLEMENTATION, ORIGIN, SKIPPED, type ImplementationBrand, type SkippedBrand } from "./brands.ts";
|
|
2
|
+
import type { Ctx } from "../ctx/types.ts";
|
|
3
|
+
import { callerFile, originsOf } from "./origin.ts";
|
|
4
|
+
import type { AnyStep, ConfigOf, OutputValue, SecretsOf, StepInput } from "./steps.ts";
|
|
5
|
+
import type { Awaitable } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
|
|
8
|
+
export interface Skipped {
|
|
9
|
+
readonly [SkippedBrand]: true;
|
|
10
|
+
readonly reason: string;
|
|
11
|
+
}
|
|
12
|
+
export function skipped(reason: string): Skipped {
|
|
13
|
+
return Object.freeze({ [SKIPPED]: true, reason }) as unknown as Skipped;
|
|
14
|
+
}
|
|
15
|
+
export function isSkipped(v: unknown): v is Skipped {
|
|
16
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[SKIPPED] === true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type HandlerResult<S extends AnyStep> = OutputValue<S> | Skipped;
|
|
20
|
+
export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<HandlerResult<S>>;
|
|
21
|
+
|
|
22
|
+
/** A step with its handler. It is itself a step: stage it directly, or default-export it from `handlers/*.ts`. */
|
|
23
|
+
export type Implementation<S extends AnyStep = AnyStep> = S & {
|
|
24
|
+
readonly [ImplementationBrand]: true;
|
|
25
|
+
/** The step as defined. */
|
|
26
|
+
readonly step: S;
|
|
27
|
+
readonly handler: Handler<S>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
|
|
31
|
+
type NoExtraFields<R, T> = R extends Skipped | void
|
|
32
|
+
? unknown
|
|
33
|
+
: { [K in Exclude<keyof R, keyof T>]: `❌ '${K & string}' is not a field of the output model` };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Implement a step. Input, ctx and return types come from the step definition alone. Default-export
|
|
37
|
+
* the result from a `handlers/*.ts` file, or stage it directly (a reusable step: give it a `kind`).
|
|
38
|
+
*/
|
|
39
|
+
export function implement<S extends AnyStep, R extends HandlerResult<S>>(
|
|
40
|
+
step: S & (S extends { readonly [ImplementationBrand]: true } ? "❌ this step already carries its implementation" : unknown),
|
|
41
|
+
handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
|
|
42
|
+
): Implementation<S> {
|
|
43
|
+
if (isImplementation(step)) throw new TypeError(`step '${(step as AnyStep).name}' already carries its implementation`);
|
|
44
|
+
const origins = [...new Set([...originsOf(step), callerFile()].filter((f): f is string => f !== undefined))];
|
|
45
|
+
return Object.freeze({ ...step, [IMPLEMENTATION]: true, [ORIGIN]: origins, step, handler: handler as unknown as Handler<S> }) as unknown as Implementation<S>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isImplementation(v: unknown): v is Implementation {
|
|
49
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[IMPLEMENTATION] === true;
|
|
50
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ModelBrand } from "./brands.ts";
|
|
3
|
+
|
|
4
|
+
/** A named, nominal zod schema. The name is its identity in the manifest (one schema per name). */
|
|
5
|
+
export interface Model<N extends string = string, S extends z.ZodType = z.ZodType> {
|
|
6
|
+
readonly kind: "model";
|
|
7
|
+
readonly name: N;
|
|
8
|
+
readonly schema: S;
|
|
9
|
+
}
|
|
10
|
+
export type AnyModel = Model<string, z.ZodType>;
|
|
11
|
+
|
|
12
|
+
/** Output view of a model (`z.output`), nominally branded with the model name. The brand is optional, so plain literals still assign. */
|
|
13
|
+
export type Infer<M extends AnyModel> = z.output<M["schema"]> & { readonly [ModelBrand]?: M["name"] };
|
|
14
|
+
/** Input view of a model (`z.input`: fields with defaults are optional). What trigger mappers produce. */
|
|
15
|
+
export type InferInput<M extends AnyModel> = z.input<M["schema"]>;
|
|
16
|
+
/** Output view without the nominal brand. */
|
|
17
|
+
export type Plain<M extends AnyModel> = z.output<M["schema"]>;
|
|
18
|
+
|
|
19
|
+
/** Model names: identifier-like, so they are safe as JSON keys, C# names and URL segments. */
|
|
20
|
+
export const MODEL_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
21
|
+
|
|
22
|
+
export function model<const N extends string, S extends z.ZodType>(name: N, schema: S): Model<N, S> {
|
|
23
|
+
return Object.freeze({ kind: "model" as const, name, schema });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isModel(v: unknown): v is AnyModel {
|
|
27
|
+
return typeof v === "object" && v !== null && (v as { kind?: unknown }).kind === "model" && (v as { schema?: unknown }).schema instanceof z.ZodType;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── File references ─────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One path segment: no `/`, `\` or NUL, and not `.` / `..`. A path is segments joined by `/`;
|
|
34
|
+
* a trailing `/` marks a directory reference (stored as a tar archive, see README).
|
|
35
|
+
*/
|
|
36
|
+
const SEGMENT = String.raw`(?!\.\.?(?:/|$))[^/\\\x00]+`;
|
|
37
|
+
export const FILE_PATH_PATTERN = `^(?:${SEGMENT}/)*${SEGMENT}/?$`;
|
|
38
|
+
export const FILE_PATH = new RegExp(FILE_PATH_PATTERN);
|
|
39
|
+
|
|
40
|
+
const FileRefSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
run: z.number().int().nonnegative(),
|
|
43
|
+
step: z.string().min(1),
|
|
44
|
+
attempt: z.number().int().positive(),
|
|
45
|
+
path: z.string().regex(FILE_PATH),
|
|
46
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
47
|
+
})
|
|
48
|
+
.brand<"FileRef">()
|
|
49
|
+
.meta({ "x-olve-kind": "fileRef" });
|
|
50
|
+
|
|
51
|
+
/** A file or directory in some step attempt's output (`runs/<p>/<run>/<step>/<attempt>/files/<path>`). Branded: only `ctx.output.*` or a mapper ref produce one. */
|
|
52
|
+
export type FileRef = z.output<typeof FileRefSchema>;
|
|
53
|
+
/** Schema for a file reference field inside a model. Marked `"x-olve-kind": "fileRef"` in JSON Schema. */
|
|
54
|
+
export const fileRef = () => FileRefSchema;
|
|
55
|
+
export const fileRefSchema = FileRefSchema;
|
|
56
|
+
|
|
57
|
+
// ─── optional() ──────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** A step input that may be missing because its producer was *maybe skipped* (inside a `when`). There must still be a producer. */
|
|
60
|
+
export interface Optional<M extends AnyModel = AnyModel> {
|
|
61
|
+
readonly kind: "optional";
|
|
62
|
+
readonly model: M;
|
|
63
|
+
}
|
|
64
|
+
export function optional<M extends AnyModel>(model: M): Optional<M> {
|
|
65
|
+
return Object.freeze({ kind: "optional" as const, model });
|
|
66
|
+
}
|
|
67
|
+
export function isOptional(v: unknown): v is Optional {
|
|
68
|
+
return typeof v === "object" && v !== null && (v as { kind?: unknown }).kind === "optional" && isModel((v as { model?: unknown }).model);
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ORIGIN } from "./brands.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The file that called the function calling this one, from the stack. A tail call drops its caller's
|
|
5
|
+
* frame, so this can name a module further up; the build only uses it to choose what to hash.
|
|
6
|
+
*/
|
|
7
|
+
export function callerFile(): string | undefined {
|
|
8
|
+
const files = (new Error().stack ?? "").split("\n").slice(1).map((f) => f.match(FRAME)?.[1]);
|
|
9
|
+
const own = files[0];
|
|
10
|
+
const callee = files[1];
|
|
11
|
+
const file = files.slice(2).find((f) => f && f !== own && f !== callee) ?? undefined;
|
|
12
|
+
return file?.startsWith("file://") ? new URL(file).pathname : file;
|
|
13
|
+
}
|
|
14
|
+
const FRAME = /\(?((?:file:\/\/)?\/[^()]*?):\d+(?::\d+)?\)?\s*$/;
|
|
15
|
+
|
|
16
|
+
/** The modules that defined and implemented a step (where `step` and `implement` were called). */
|
|
17
|
+
export function originsOf(v: unknown): string[] {
|
|
18
|
+
const o = (v as Record<symbol, unknown>)[ORIGIN];
|
|
19
|
+
return Array.isArray(o) ? o.filter((x): x is string => typeof x === "string") : [];
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A named pipeline secret. Steps (and poll triggers) declare the secrets they use; only those are mounted. */
|
|
2
|
+
export interface Secret<N extends string = string> {
|
|
3
|
+
readonly kind: "secret";
|
|
4
|
+
readonly name: N;
|
|
5
|
+
readonly description: string;
|
|
6
|
+
}
|
|
7
|
+
/** Secret names double as environment variable names in the step container. */
|
|
8
|
+
export const SECRET_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
9
|
+
|
|
10
|
+
export function secret<const N extends string>(name: N, description: string): Secret<N> {
|
|
11
|
+
return Object.freeze({ kind: "secret" as const, name, description });
|
|
12
|
+
}
|
|
13
|
+
export type DeclaredSecrets<N extends string> = { readonly [K in N]: string };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { ORIGIN, STEP, type StepBrand } from "./brands.ts";
|
|
3
|
+
import { callerFile } from "./origin.ts";
|
|
4
|
+
import type { AnyModel, Infer, Optional } from "./models.ts";
|
|
5
|
+
import type { AnyConfigEntry } from "./config.ts";
|
|
6
|
+
import { configRefsIn } from "./config-refs.ts";
|
|
7
|
+
import type { Secret } from "./secrets.ts";
|
|
8
|
+
import type { Simplify } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/** A step input entry: a model (matchable by model), `optional(model)`, or a plain zod schema (mapper required). */
|
|
11
|
+
export type InputEntry = AnyModel | Optional | z.ZodType;
|
|
12
|
+
export type InputSpec = { readonly [key: string]: InputEntry };
|
|
13
|
+
|
|
14
|
+
export type InferEntry<E> =
|
|
15
|
+
E extends Optional<infer M> ? Infer<M> | undefined
|
|
16
|
+
: E extends AnyModel ? Infer<E>
|
|
17
|
+
: E extends z.ZodType ? z.output<E>
|
|
18
|
+
: never;
|
|
19
|
+
/** What a handler receives: the output view of every input entry. */
|
|
20
|
+
export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A label for what a step is, shown in the UI. `custom` (the default) is a step whose handler is in
|
|
24
|
+
* `handlers/`; a step that carries its implementation (`implement(step, …)` staged directly) names its
|
|
25
|
+
* kind, e.g. `gitHubSource`. Packages prefix theirs (`acme/deploy`).
|
|
26
|
+
*/
|
|
27
|
+
export type StepKind = string;
|
|
28
|
+
|
|
29
|
+
export interface Step<
|
|
30
|
+
N extends string = string,
|
|
31
|
+
I extends InputSpec = InputSpec,
|
|
32
|
+
O extends AnyModel | null = AnyModel | null,
|
|
33
|
+
Sec extends string = string,
|
|
34
|
+
Cfg extends AnyConfigEntry = never,
|
|
35
|
+
> {
|
|
36
|
+
readonly [StepBrand]: true;
|
|
37
|
+
readonly kind: StepKind;
|
|
38
|
+
readonly name: N;
|
|
39
|
+
readonly description: string;
|
|
40
|
+
readonly image: string;
|
|
41
|
+
readonly input: I;
|
|
42
|
+
readonly output: O;
|
|
43
|
+
readonly secrets: readonly Secret<Sec>[];
|
|
44
|
+
/** Config entries the step receives as `ctx.config`. */
|
|
45
|
+
readonly config: readonly Cfg[];
|
|
46
|
+
readonly timeoutSeconds?: number;
|
|
47
|
+
readonly retries?: number;
|
|
48
|
+
/** The step pod gets the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`). */
|
|
49
|
+
readonly clusterAccess?: boolean;
|
|
50
|
+
/** How the step is set up, as written in the definition (may hold config references). */
|
|
51
|
+
readonly settings?: Readonly<Record<string, unknown>>;
|
|
52
|
+
}
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
export type AnyStep = Step<string, any, AnyModel | null, any, any>;
|
|
55
|
+
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
export type SecretsOf<S> = S extends Step<any, any, any, infer Sec, any> ? Sec : never;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
export type ConfigOf<S> = S extends Step<any, any, any, any, infer C> ? C : never;
|
|
60
|
+
export type OutputValue<S extends AnyStep> = S["output"] extends AnyModel ? Infer<S["output"]> : void;
|
|
61
|
+
|
|
62
|
+
/** Step names must be literals (identity, duplicate detection and mapper keys depend on it). */
|
|
63
|
+
export type LiteralName<N extends string> = N & (string extends N ? "❌ step name must be a string literal" : unknown);
|
|
64
|
+
|
|
65
|
+
/** Step names: lower-case kebab, ≤ 63 chars (usable as K8s label values and URL segments). */
|
|
66
|
+
export const STEP_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
67
|
+
/** Names that cannot be steps: `input` is the pipeline input's root in mappings. */
|
|
68
|
+
export const RESERVED_STEP_NAMES: readonly string[] = ["input"];
|
|
69
|
+
|
|
70
|
+
export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[], Cfg extends readonly AnyConfigEntry[] = []> {
|
|
71
|
+
name: LiteralName<N>;
|
|
72
|
+
/** What the step does, shown in the UI. Required; may be empty. */
|
|
73
|
+
description: string;
|
|
74
|
+
/** Any image: the harness brings its own runtime. */
|
|
75
|
+
image: string;
|
|
76
|
+
input?: I;
|
|
77
|
+
output?: O;
|
|
78
|
+
secrets?: Sec;
|
|
79
|
+
/** Config entries delivered as `ctx.config` (typed to exactly these). */
|
|
80
|
+
config?: Cfg;
|
|
81
|
+
timeoutSeconds?: number;
|
|
82
|
+
/** Automatic retries (an idempotency claim). Default 0. */
|
|
83
|
+
retries?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Ask for a Kubernetes API token in the step pod: the instance's step ServiceAccount
|
|
86
|
+
* (`Kubernetes:StepServiceAccount`), if it grants one. Default false (no token).
|
|
87
|
+
*/
|
|
88
|
+
clusterAccess?: boolean;
|
|
89
|
+
/** A label for the step's kind (default `custom`). */
|
|
90
|
+
kind?: StepKind;
|
|
91
|
+
/**
|
|
92
|
+
* How the step is set up, shown in the manifest and part of the step's identity: the parameters of
|
|
93
|
+
* a reusable step. JSON values and config references (`target.prod.host`); every config entry it
|
|
94
|
+
* references is delivered to the step, and `resolveConfigRefs(value, ctx.config)` resolves them.
|
|
95
|
+
*/
|
|
96
|
+
settings?: Readonly<Record<string, unknown>>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeStep(def: Record<string, unknown>, origin: string | undefined): AnyStep {
|
|
100
|
+
const config = new Map<string, AnyConfigEntry>();
|
|
101
|
+
for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? []) if (!config.has(e.name)) config.set(e.name, e);
|
|
102
|
+
configRefsIn(def.settings, config);
|
|
103
|
+
const s = {
|
|
104
|
+
[STEP]: true,
|
|
105
|
+
kind: def.kind ?? "custom",
|
|
106
|
+
name: def.name,
|
|
107
|
+
description: def.description,
|
|
108
|
+
image: def.image,
|
|
109
|
+
input: Object.freeze({ ...((def.input as object | undefined) ?? {}) }),
|
|
110
|
+
output: def.output ?? null,
|
|
111
|
+
secrets: Object.freeze([...((def.secrets as readonly Secret[] | undefined) ?? [])]),
|
|
112
|
+
config: Object.freeze([...config.values()]),
|
|
113
|
+
...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
|
|
114
|
+
...(def.retries !== undefined ? { retries: def.retries } : {}),
|
|
115
|
+
...(def.clusterAccess === true ? { clusterAccess: true } : {}),
|
|
116
|
+
...(def.settings !== undefined ? { settings: def.settings } : {}),
|
|
117
|
+
[ORIGIN]: origin === undefined ? [] : [origin],
|
|
118
|
+
};
|
|
119
|
+
return Object.freeze(s) as unknown as AnyStep;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isStep(v: unknown): v is AnyStep {
|
|
123
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[STEP] === true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Define a step: a contract (name, image, inputs, output, secrets). Its handler is a `handlers/*.ts`
|
|
128
|
+
* default-exporting `implement(step, …)`, or, for a reusable step, `implement(step, …)` staged directly.
|
|
129
|
+
*/
|
|
130
|
+
export function step<
|
|
131
|
+
const N extends string,
|
|
132
|
+
const I extends InputSpec = {},
|
|
133
|
+
O extends AnyModel | null = null,
|
|
134
|
+
const Sec extends readonly Secret[] = [],
|
|
135
|
+
const Cfg extends readonly AnyConfigEntry[] = [],
|
|
136
|
+
>(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]["name"], Cfg[number]> {
|
|
137
|
+
return makeStep(def as never, callerFile()) as never;
|
|
138
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type Primitive = string | number | boolean | bigint | null | undefined;
|
|
2
|
+
export type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
3
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
4
|
+
export type IsUnion<T, U = T> = T extends unknown ? ([U] extends [T] ? false : true) : never;
|
|
5
|
+
/** `Ok` when there is no error, else the error (a readable string-literal type). */
|
|
6
|
+
export type OrError<Err, Ok> = [Err] extends [never] ? Ok : Err;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Secret } from "./secrets.ts";
|
|
2
|
+
|
|
3
|
+
/** How a webhook trigger authenticates deliveries. Build one with `hmacSha256`, `githubSignature` or `bearer`. */
|
|
4
|
+
export type WebhookVerify =
|
|
5
|
+
| { readonly scheme: "hmac-sha256"; readonly secret: Secret; readonly header: string; readonly prefix: string }
|
|
6
|
+
| { readonly scheme: "bearer"; readonly secret: Secret };
|
|
7
|
+
|
|
8
|
+
/** HMAC-SHA256 of the raw body keyed by `secret`, hex, in `header` after `prefix`. */
|
|
9
|
+
export function hmacSha256(o: { secret: Secret; header: string; prefix?: string }): WebhookVerify {
|
|
10
|
+
return Object.freeze({ scheme: "hmac-sha256", secret: o.secret, header: o.header, prefix: o.prefix ?? "" });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** GitHub's delivery signature: `X-Hub-Signature-256: sha256=<hex>`. */
|
|
14
|
+
export function githubSignature(secret: Secret): WebhookVerify {
|
|
15
|
+
return hmacSha256({ secret, header: "X-Hub-Signature-256", prefix: "sha256=" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `Authorization: Bearer <secret>`. */
|
|
19
|
+
export function bearer(secret: Secret): WebhookVerify {
|
|
20
|
+
return Object.freeze({ scheme: "bearer", secret });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a webhook trigger mapper receives: the request headers (lower-case names, without `authorization`
|
|
25
|
+
* and the signature header) and the body (parsed JSON, or the raw text when it isn't JSON).
|
|
26
|
+
*/
|
|
27
|
+
export interface WebhookEvent {
|
|
28
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
29
|
+
readonly body: unknown;
|
|
30
|
+
}
|
package/src/ctx/files.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// File references: producing them (`ctx.output.*`) and fetching them (`ctx.files.fetch`).
|
|
2
|
+
//
|
|
3
|
+
// Storage rule (shared with the service): a ref's *object path* is `path` for files and
|
|
4
|
+
// `path` minus the trailing "/" plus ".tar" for directories (a deterministic tar). The step writes
|
|
5
|
+
// objects to `$OLVE_DIR/work/files/<object path>`; the service stores them under
|
|
6
|
+
// `runs/<p>/<run>/<step>/<attempt>/files/<object path>`.
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
|
+
import { fileRefSchema, FILE_PATH, type FileRef } from "../core/models.ts";
|
|
10
|
+
import { ENV } from "../harness/env.ts";
|
|
11
|
+
import { sha256File } from "../util/hash.ts";
|
|
12
|
+
import { dirEntries, extractTar, writeTar } from "../util/tar.ts";
|
|
13
|
+
import type { OutputFileOptions, RunInfo } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export const objectPath = (refPath: string): string => (refPath.endsWith("/") ? refPath.slice(0, -1) + ".tar" : refPath);
|
|
16
|
+
export const fileKey = (ref: Pick<FileRef, "run" | "step" | "attempt" | "path">): string => `${ref.run}/${ref.step}/${ref.attempt}/${ref.path}`;
|
|
17
|
+
|
|
18
|
+
/** Strips everything from `?` on (pre-signed credentials). */
|
|
19
|
+
export const redactUrl = (url: string): string => url.replace(/[?#].*$/, "?…");
|
|
20
|
+
|
|
21
|
+
export interface FilesDeps {
|
|
22
|
+
run: RunInfo;
|
|
23
|
+
workFiles: string;
|
|
24
|
+
tmpDir: string;
|
|
25
|
+
root: string;
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
log: (message: string, fields?: Record<string, unknown>) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function checkName(name: string, what: string): void {
|
|
31
|
+
if (!FILE_PATH.test(name) || name.endsWith("/")) throw new Error(`${what}: '${name}' is not a valid relative file name (no leading '/', no '..', no trailing '/')`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createOutput(deps: FilesDeps) {
|
|
35
|
+
const used = new Set<string>();
|
|
36
|
+
const claim = (obj: string) => {
|
|
37
|
+
if (used.has(obj) || existsSync(join(deps.workFiles, obj))) throw new Error(`output file '${obj}' already exists`);
|
|
38
|
+
used.add(obj);
|
|
39
|
+
const dest = join(deps.workFiles, obj);
|
|
40
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
41
|
+
return dest;
|
|
42
|
+
};
|
|
43
|
+
const ref = (path: string, dest: string): FileRef =>
|
|
44
|
+
({ run: deps.run.number, step: deps.run.step, attempt: deps.run.attempt, path, sha256: sha256File(dest) }) as FileRef;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
async file(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
|
|
48
|
+
const src = resolve(path);
|
|
49
|
+
if (!statSync(src).isFile()) throw new Error(`ctx.output.file: '${path}' is not a file`);
|
|
50
|
+
const name = opts.name ?? basename(src);
|
|
51
|
+
checkName(name, "ctx.output.file");
|
|
52
|
+
const dest = claim(name);
|
|
53
|
+
copyFileSync(src, dest);
|
|
54
|
+
return ref(name, dest);
|
|
55
|
+
},
|
|
56
|
+
async dir(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
|
|
57
|
+
const src = resolve(path);
|
|
58
|
+
if (!statSync(src).isDirectory()) throw new Error(`ctx.output.dir: '${path}' is not a directory`);
|
|
59
|
+
const name = opts.name ?? basename(src);
|
|
60
|
+
checkName(name, "ctx.output.dir");
|
|
61
|
+
const dest = claim(objectPath(name + "/"));
|
|
62
|
+
writeTar(dest, dirEntries(src));
|
|
63
|
+
return ref(name + "/", dest);
|
|
64
|
+
},
|
|
65
|
+
async write(name: string, content: string | Uint8Array): Promise<FileRef> {
|
|
66
|
+
checkName(name, "ctx.output.write");
|
|
67
|
+
const dest = claim(name);
|
|
68
|
+
writeFileSync(dest, content);
|
|
69
|
+
return ref(name, dest);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function urlMap(root: string): Record<string, string> {
|
|
75
|
+
const raw = process.env[ENV.fileUrls] ?? (existsSync(join(root, "files.json")) ? readFileSync(join(root, "files.json"), "utf8") : undefined);
|
|
76
|
+
if (!raw) return {};
|
|
77
|
+
try {
|
|
78
|
+
const m = JSON.parse(raw) as unknown;
|
|
79
|
+
if (typeof m !== "object" || m === null || Array.isArray(m)) throw new Error("not an object");
|
|
80
|
+
return m as Record<string, string>;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
throw new Error(`${ENV.fileUrls} / files.json is not a JSON object of file key → URL (${(e as Error).message})`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const TRANSIENT = (status: number) => status >= 500 || status === 408 || status === 429;
|
|
87
|
+
|
|
88
|
+
async function download(url: string, dest: string, headers: Record<string, string>, signal: AbortSignal, log: FilesDeps["log"]): Promise<void> {
|
|
89
|
+
const attempts = 4;
|
|
90
|
+
for (let i = 1; ; i++) {
|
|
91
|
+
let status = 0;
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(url, { headers, signal });
|
|
94
|
+
status = res.status;
|
|
95
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
96
|
+
await Bun.write(dest + ".partial", res);
|
|
97
|
+
renameSync(dest + ".partial", dest);
|
|
98
|
+
return;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
rmSync(dest + ".partial", { force: true });
|
|
101
|
+
const transient = status === 0 || TRANSIENT(status);
|
|
102
|
+
if (signal.aborted || !transient || i >= attempts) throw new Error(`download of ${redactUrl(url)} failed: ${(e as Error).message}`);
|
|
103
|
+
log("download failed, retrying", { url: redactUrl(url), attempt: i, error: (e as Error).message });
|
|
104
|
+
await Bun.sleep(1000 * 2 ** (i - 1));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createFetch(deps: FilesDeps) {
|
|
110
|
+
const cache = new Map<string, string>();
|
|
111
|
+
return async (input: FileRef): Promise<string> => {
|
|
112
|
+
const parsed = fileRefSchema.safeParse(input);
|
|
113
|
+
if (!parsed.success) throw new Error(`ctx.files.fetch: not a valid file reference: ${JSON.stringify(input)}`);
|
|
114
|
+
const ref = parsed.data;
|
|
115
|
+
const key = fileKey(ref);
|
|
116
|
+
const hit = cache.get(key);
|
|
117
|
+
if (hit) return hit;
|
|
118
|
+
|
|
119
|
+
const obj = objectPath(ref.path);
|
|
120
|
+
const base = join(deps.tmpDir, "files", String(ref.run), ref.step, String(ref.attempt));
|
|
121
|
+
const objFile = join(base, ".objects", obj);
|
|
122
|
+
mkdirSync(dirname(objFile), { recursive: true });
|
|
123
|
+
|
|
124
|
+
const own = ref.run === deps.run.number && ref.step === deps.run.step && ref.attempt === deps.run.attempt;
|
|
125
|
+
const urls = urlMap(deps.root);
|
|
126
|
+
const localDir = process.env[ENV.filesDir];
|
|
127
|
+
const baseUrl = process.env[ENV.filesBaseUrl];
|
|
128
|
+
if (own && existsSync(join(deps.workFiles, obj))) copyFileSync(join(deps.workFiles, obj), objFile);
|
|
129
|
+
else if (urls[key]) await download(urls[key], objFile, {}, deps.signal, deps.log);
|
|
130
|
+
else if (localDir && existsSync(join(localDir, String(ref.run), ref.step, String(ref.attempt), "files", obj)))
|
|
131
|
+
copyFileSync(join(localDir, String(ref.run), ref.step, String(ref.attempt), "files", obj), objFile);
|
|
132
|
+
else if (baseUrl) {
|
|
133
|
+
const token = process.env[ENV.filesToken];
|
|
134
|
+
const url = `${baseUrl.replace(/\/+$/, "")}/${ref.run}/${encodeURIComponent(ref.step)}/${ref.attempt}/${obj.split("/").map(encodeURIComponent).join("/")}`;
|
|
135
|
+
await download(url, objFile, token ? { authorization: `Bearer ${token}` } : {}, deps.signal, deps.log);
|
|
136
|
+
} else {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`ctx.files.fetch: no source for ${key} (set ${ENV.fileUrls} / $OLVE_DIR/files.json, ${ENV.filesBaseUrl} or ${ENV.filesDir})`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const actual = sha256File(objFile);
|
|
143
|
+
if (actual !== ref.sha256) {
|
|
144
|
+
rmSync(objFile, { force: true });
|
|
145
|
+
throw new Error(`ctx.files.fetch: ${key} has sha256 ${actual}, expected ${ref.sha256}`);
|
|
146
|
+
}
|
|
147
|
+
let result = objFile;
|
|
148
|
+
if (ref.path.endsWith("/")) {
|
|
149
|
+
result = join(base, ref.path.slice(0, -1));
|
|
150
|
+
rmSync(result, { recursive: true, force: true });
|
|
151
|
+
mkdirSync(result, { recursive: true });
|
|
152
|
+
await extractTar(objFile, result);
|
|
153
|
+
} else {
|
|
154
|
+
result = join(base, ref.path);
|
|
155
|
+
mkdirSync(dirname(result), { recursive: true });
|
|
156
|
+
renameSync(objFile, result);
|
|
157
|
+
}
|
|
158
|
+
cache.set(key, result);
|
|
159
|
+
return result;
|
|
160
|
+
};
|
|
161
|
+
}
|
package/src/ctx/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { skipped } from "../core/handlers.ts";
|
|
4
|
+
import type { Secret } from "../core/secrets.ts";
|
|
5
|
+
import { createFetch, createOutput, redactUrl } from "./files.ts";
|
|
6
|
+
import { createSh } from "./sh.ts";
|
|
7
|
+
import type { Ctx, Http, RetryOptions, RunInfo } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export interface CtxOptions {
|
|
10
|
+
run: RunInfo;
|
|
11
|
+
/** Declared secrets of the step. */
|
|
12
|
+
secrets: readonly Secret[];
|
|
13
|
+
/** Secret values that take precedence over the environment (tests). */
|
|
14
|
+
secretValues?: Readonly<Record<string, string>>;
|
|
15
|
+
/** Parsed config entries of the step (`ctx.config`). */
|
|
16
|
+
config?: Readonly<Record<string, unknown>>;
|
|
17
|
+
paths: { root: string; workFiles: string; tmp: string };
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
/** Where log lines go (default stderr). */
|
|
20
|
+
logSink?: (line: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatLog(message: string, fields?: Record<string, unknown>): string {
|
|
24
|
+
const parts = Object.entries(fields ?? {}).map(([k, v]) => `${k}=${typeof v === "string" && !/\s|"/.test(v) ? v : JSON.stringify(v)}`);
|
|
25
|
+
return parts.length ? `${message} ${parts.join(" ")}` : message;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createSecrets(declared: readonly Secret[], values?: Readonly<Record<string, string>>): Record<string, string> {
|
|
29
|
+
const obj: Record<string, string> = {};
|
|
30
|
+
for (const s of declared) {
|
|
31
|
+
Object.defineProperty(obj, s.name, {
|
|
32
|
+
enumerable: true,
|
|
33
|
+
get() {
|
|
34
|
+
const v = values?.[s.name] ?? process.env[s.name];
|
|
35
|
+
if (v === undefined) throw new Error(`secret ${s.name} is not set (the harness reads it from the environment variable ${s.name})`);
|
|
36
|
+
return v;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return Object.freeze(obj);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function httpError(res: Response, url: string): Promise<Error> {
|
|
44
|
+
let body = "";
|
|
45
|
+
try {
|
|
46
|
+
body = (await res.text()).slice(0, 500);
|
|
47
|
+
} catch {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
50
|
+
return new Error(`HTTP ${res.status} ${res.statusText} from ${redactUrl(url)}${body ? `: ${body}` : ""}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createHttp(signal: AbortSignal): Http {
|
|
54
|
+
const f = (url: string, init?: RequestInit) => fetch(url, { ...init, signal: init?.signal ?? signal });
|
|
55
|
+
return {
|
|
56
|
+
fetch: f,
|
|
57
|
+
async getJson<T>(url: string, init?: { headers?: Record<string, string> }): Promise<T> {
|
|
58
|
+
const res = await f(url, { headers: { accept: "application/json", ...init?.headers } });
|
|
59
|
+
if (!res.ok) throw await httpError(res, url);
|
|
60
|
+
return (await res.json()) as T;
|
|
61
|
+
},
|
|
62
|
+
async expectOk(url: string, init?: { headers?: Record<string, string>; method?: string }): Promise<Response> {
|
|
63
|
+
const res = await f(url, { method: init?.method ?? "GET", headers: init?.headers });
|
|
64
|
+
if (!res.ok) throw await httpError(res, url);
|
|
65
|
+
return res;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createRetry(signal: AbortSignal, log: (m: string, f?: Record<string, unknown>) => void) {
|
|
71
|
+
return async <T>(opts: RetryOptions, fn: (attempt: number) => Promise<T>): Promise<T> => {
|
|
72
|
+
if (!(Number.isInteger(opts.attempts) && opts.attempts >= 1)) throw new Error("retry: attempts must be a positive integer");
|
|
73
|
+
let delay = Math.max(0, opts.delaySeconds) * 1000;
|
|
74
|
+
for (let attempt = 1; ; attempt++) {
|
|
75
|
+
try {
|
|
76
|
+
return await fn(attempt);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (attempt >= opts.attempts || signal.aborted) throw e;
|
|
79
|
+
log("retrying", { attempt, of: opts.attempts, error: (e as Error)?.message ?? String(e) });
|
|
80
|
+
await new Promise<void>((res, rej) => {
|
|
81
|
+
const t = setTimeout(() => {
|
|
82
|
+
signal.removeEventListener("abort", onAbort);
|
|
83
|
+
res();
|
|
84
|
+
}, delay);
|
|
85
|
+
const onAbort = () => {
|
|
86
|
+
clearTimeout(t);
|
|
87
|
+
rej(new Error("retry cancelled: the step was cancelled"));
|
|
88
|
+
};
|
|
89
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
90
|
+
});
|
|
91
|
+
delay *= opts.backoff ?? 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The real ctx handed to handlers by the harness (and by testHandler). */
|
|
98
|
+
export function createCtx(o: CtxOptions): Ctx {
|
|
99
|
+
mkdirSync(o.paths.tmp, { recursive: true });
|
|
100
|
+
mkdirSync(o.paths.workFiles, { recursive: true });
|
|
101
|
+
const sink = o.logSink ?? ((line: string) => process.stderr.write(line + "\n"));
|
|
102
|
+
const log = (message: string, fields?: Record<string, unknown>) => sink(formatLog(message, fields));
|
|
103
|
+
const { sh, exec } = createSh({ tmpDir: o.paths.tmp, signal: o.signal });
|
|
104
|
+
const filesDeps = { run: o.run, workFiles: o.paths.workFiles, tmpDir: o.paths.tmp, root: o.paths.root, signal: o.signal, log };
|
|
105
|
+
return {
|
|
106
|
+
secrets: createSecrets(o.secrets, o.secretValues) as Ctx["secrets"],
|
|
107
|
+
config: Object.freeze({ ...o.config }) as Ctx["config"],
|
|
108
|
+
sh,
|
|
109
|
+
exec,
|
|
110
|
+
readText: async (p) => readFileSync(resolve(p), "utf8"),
|
|
111
|
+
readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p), "utf8")) as T,
|
|
112
|
+
files: { fetch: createFetch(filesDeps) },
|
|
113
|
+
output: createOutput(filesDeps),
|
|
114
|
+
http: createHttp(o.signal),
|
|
115
|
+
retry: createRetry(o.signal, log),
|
|
116
|
+
log,
|
|
117
|
+
signal: o.signal,
|
|
118
|
+
run: Object.freeze({ ...o.run }),
|
|
119
|
+
tmpDir: o.paths.tmp,
|
|
120
|
+
skip: skipped,
|
|
121
|
+
};
|
|
122
|
+
}
|