@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.1
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 +407 -2
- package/manifest.schema.json +524 -0
- package/package.json +60 -4
- package/src/assets.d.ts +10 -0
- package/src/build/build.ts +125 -0
- package/src/build/cli.ts +52 -0
- package/src/build/elf.ts +35 -0
- package/src/build/hashes.ts +67 -0
- package/src/build/project.ts +79 -0
- package/src/builder/pipeline.ts +291 -0
- package/src/builder/refs.ts +26 -0
- package/src/core/brands.ts +13 -0
- package/src/core/handlers.ts +44 -0
- package/src/core/models.ts +69 -0
- package/src/core/secrets.ts +13 -0
- package/src/core/steps.ts +107 -0
- package/src/core/types.ts +6 -0
- package/src/ctx/files.ts +161 -0
- package/src/ctx/index.ts +119 -0
- package/src/ctx/sh.ts +196 -0
- package/src/ctx/types.ts +113 -0
- package/src/describe/describe.ts +379 -0
- package/src/describe/manifest-schema.ts +131 -0
- package/src/describe/mapper-source.ts +238 -0
- package/src/describe/record.ts +107 -0
- package/src/dev/cli.ts +80 -0
- package/src/harness/env.ts +58 -0
- package/src/harness/execute.ts +94 -0
- package/src/harness/exit.ts +19 -0
- package/src/harness/index.ts +9 -0
- package/src/harness/main.ts +219 -0
- package/src/harness/result.ts +75 -0
- package/src/harness/validate.ts +74 -0
- package/src/index.ts +11 -0
- package/src/library/github-source.ts +77 -0
- package/src/library/helm-deploy-ssh.ts +151 -0
- package/src/library/http-health.ts +34 -0
- package/src/library/image-build.ts +134 -0
- package/src/library/index.ts +14 -0
- package/src/library/models.ts +63 -0
- package/src/library/script.ts +85 -0
- package/src/testing/index.ts +86 -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 +154 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RefBrand } from "../core/brands.ts";
|
|
2
|
+
import type { AnyStep, InferEntry } from "../core/steps.ts";
|
|
3
|
+
import type { Primitive } from "../core/types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A typed reference into the run pool — what mappers see. `symbol &` makes template strings, `+`
|
|
7
|
+
* and `===` against values compile errors (TS2731 / TS2469 / TS2367); only property access and
|
|
8
|
+
* literals remain. Fields of a maybe-undefined ref are themselves maybe-undefined.
|
|
9
|
+
*/
|
|
10
|
+
export type Ref<T> = symbol & { readonly [RefBrand]: T } & RefFields<T>;
|
|
11
|
+
type RefFields<T> = [NonNullable<T>] extends [Primitive]
|
|
12
|
+
? {}
|
|
13
|
+
: NonNullable<T> extends readonly unknown[]
|
|
14
|
+
? {}
|
|
15
|
+
: { readonly [K in keyof NonNullable<T> & string]-?: Ref<NonNullable<T>[K] | (undefined extends T ? undefined : never)> };
|
|
16
|
+
|
|
17
|
+
/** What a mapper may put where a T is expected: a Ref<T>, a literal, or an object of those. */
|
|
18
|
+
export type Mappable<T> =
|
|
19
|
+
| Ref<T>
|
|
20
|
+
| (T extends Primitive
|
|
21
|
+
? T
|
|
22
|
+
: T extends readonly unknown[]
|
|
23
|
+
? never
|
|
24
|
+
: { [K in keyof T]: Mappable<T[K]> } & { readonly [RefBrand]?: never }); // a Ref of another model can't sneak in structurally
|
|
25
|
+
|
|
26
|
+
export type MapperResult<S extends AnyStep> = { [K in keyof S["input"]]: Mappable<InferEntry<S["input"][K]>> };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Runtime markers. `Symbol.for` (not `Symbol()`) so that two copies of the SDK in one process — e.g.
|
|
2
|
+
// a dev runner and the user's installed package — still recognise each other's objects.
|
|
3
|
+
export const STEP = Symbol.for("olve.pipelines.step");
|
|
4
|
+
export const IMPLEMENTATION = Symbol.for("olve.pipelines.implementation");
|
|
5
|
+
export const SKIPPED = Symbol.for("olve.pipelines.skipped");
|
|
6
|
+
export const PIPELINE = Symbol.for("olve.pipelines.pipeline");
|
|
7
|
+
export const REF_PATH = Symbol.for("olve.pipelines.refPath");
|
|
8
|
+
|
|
9
|
+
// Type-only brands (never exist at runtime).
|
|
10
|
+
export declare const ModelBrand: unique symbol;
|
|
11
|
+
export declare const RefBrand: unique symbol;
|
|
12
|
+
export declare const StepBrand: unique symbol;
|
|
13
|
+
export declare const SkippedBrand: unique symbol;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { IMPLEMENTATION, SKIPPED, type SkippedBrand } from "./brands.ts";
|
|
2
|
+
import type { Ctx } from "../ctx/types.ts";
|
|
3
|
+
import type { AnyStep, LibraryStep, OutputValue, SecretsOf, StepInput } from "./steps.ts";
|
|
4
|
+
import type { Awaitable } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
|
|
7
|
+
export interface Skipped {
|
|
8
|
+
readonly [SkippedBrand]: true;
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
}
|
|
11
|
+
export function skipped(reason: string): Skipped {
|
|
12
|
+
return Object.freeze({ [SKIPPED]: true, reason }) as unknown as Skipped;
|
|
13
|
+
}
|
|
14
|
+
export function isSkipped(v: unknown): v is Skipped {
|
|
15
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[SKIPPED] === true;
|
|
16
|
+
}
|
|
17
|
+
|
|
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>>;
|
|
20
|
+
|
|
21
|
+
export interface Implementation<S extends AnyStep = AnyStep> {
|
|
22
|
+
readonly step: S;
|
|
23
|
+
readonly handler: Handler<S>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
|
|
27
|
+
type NoExtraFields<R, T> = R extends Skipped | void
|
|
28
|
+
? unknown
|
|
29
|
+
: { [K in Exclude<keyof R, keyof T>]: `❌ '${K & string}' is not a field of the output model` };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Implement a custom step. Input, ctx and return types come from the step definition alone.
|
|
33
|
+
* Default-export the result from `handlers/<step-name>.ts`.
|
|
34
|
+
*/
|
|
35
|
+
export function implement<S extends AnyStep, R extends HandlerResult<S>>(
|
|
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>>>,
|
|
38
|
+
): Implementation<S> {
|
|
39
|
+
return Object.freeze({ [IMPLEMENTATION]: true, step, handler: handler as unknown as Handler<S> }) as Implementation<S>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isImplementation(v: unknown): v is Implementation {
|
|
43
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[IMPLEMENTATION] === true;
|
|
44
|
+
}
|
|
@@ -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,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,107 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { STEP, type StepBrand } from "./brands.ts";
|
|
3
|
+
import type { AnyModel, Infer, Optional } from "./models.ts";
|
|
4
|
+
import type { Secret } from "./secrets.ts";
|
|
5
|
+
import type { Simplify } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/** A step input entry: a model (matchable by model), `optional(model)`, or a plain zod schema (mapper required). */
|
|
8
|
+
export type InputEntry = AnyModel | Optional | z.ZodType;
|
|
9
|
+
export type InputSpec = { readonly [key: string]: InputEntry };
|
|
10
|
+
|
|
11
|
+
export type InferEntry<E> =
|
|
12
|
+
E extends Optional<infer M> ? Infer<M> | undefined
|
|
13
|
+
: E extends AnyModel ? Infer<E>
|
|
14
|
+
: E extends z.ZodType ? z.output<E>
|
|
15
|
+
: never;
|
|
16
|
+
/** What a handler receives: the output view of every input entry. */
|
|
17
|
+
export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
|
|
18
|
+
|
|
19
|
+
/** `custom` steps have a handler file; the others are library steps implemented by the SDK. */
|
|
20
|
+
export type StepKind = "custom" | "script" | "gitHubSource" | "imageBuild" | "helmDeployOverSsh";
|
|
21
|
+
|
|
22
|
+
export interface Step<
|
|
23
|
+
N extends string = string,
|
|
24
|
+
I extends InputSpec = InputSpec,
|
|
25
|
+
O extends AnyModel | null = AnyModel | null,
|
|
26
|
+
Sec extends string = string,
|
|
27
|
+
> {
|
|
28
|
+
readonly [StepBrand]: true;
|
|
29
|
+
readonly kind: StepKind;
|
|
30
|
+
readonly name: N;
|
|
31
|
+
readonly description: string;
|
|
32
|
+
readonly image: string;
|
|
33
|
+
readonly input: I;
|
|
34
|
+
readonly output: O;
|
|
35
|
+
readonly secrets: readonly Secret<Sec>[];
|
|
36
|
+
readonly timeoutSeconds?: number;
|
|
37
|
+
readonly retries?: number;
|
|
38
|
+
/** Library steps carry their own implementation and config. */
|
|
39
|
+
readonly builtin?: LibraryImplementation;
|
|
40
|
+
}
|
|
41
|
+
/** Runtime part of a library step (not part of the public contract). */
|
|
42
|
+
export interface LibraryImplementation {
|
|
43
|
+
/** Config as emitted in the manifest (JSON only; must not contain paths). */
|
|
44
|
+
readonly manifestConfig: () => Record<string, unknown>;
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
readonly run: (input: any, ctx: any) => Promise<unknown>;
|
|
47
|
+
}
|
|
48
|
+
export type LibraryStep = { readonly builtin: LibraryImplementation };
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
export type AnyStep = Step<string, any, AnyModel | null, any>;
|
|
51
|
+
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
export type SecretsOf<S> = S extends Step<any, any, any, infer Sec> ? Sec : never;
|
|
54
|
+
export type OutputValue<S extends AnyStep> = S["output"] extends AnyModel ? Infer<S["output"]> : void;
|
|
55
|
+
|
|
56
|
+
/** Step names must be literals (identity, duplicate detection and mapper keys depend on it). */
|
|
57
|
+
export type LiteralName<N extends string> = N & (string extends N ? "❌ step name must be a string literal" : unknown);
|
|
58
|
+
|
|
59
|
+
/** Step names: lower-case kebab, ≤ 63 chars (usable as K8s label values and URL segments). */
|
|
60
|
+
export const STEP_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
61
|
+
/** Names that cannot be steps: `input` is the pipeline input's root in mappings. */
|
|
62
|
+
export const RESERVED_STEP_NAMES: readonly string[] = ["input"];
|
|
63
|
+
|
|
64
|
+
export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[]> {
|
|
65
|
+
name: LiteralName<N>;
|
|
66
|
+
/** What the step does, shown in the UI. Required; may be empty. */
|
|
67
|
+
description: string;
|
|
68
|
+
/** Any image: the harness brings its own runtime. */
|
|
69
|
+
image: string;
|
|
70
|
+
input?: I;
|
|
71
|
+
output?: O;
|
|
72
|
+
secrets?: Sec;
|
|
73
|
+
timeoutSeconds?: number;
|
|
74
|
+
/** Automatic retries (an idempotency claim). Default 0. */
|
|
75
|
+
retries?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?: LibraryImplementation): AnyStep {
|
|
79
|
+
const s = {
|
|
80
|
+
[STEP]: true,
|
|
81
|
+
kind,
|
|
82
|
+
name: def.name,
|
|
83
|
+
description: def.description,
|
|
84
|
+
image: def.image,
|
|
85
|
+
input: Object.freeze({ ...((def.input as object | undefined) ?? {}) }),
|
|
86
|
+
output: def.output ?? null,
|
|
87
|
+
secrets: Object.freeze([...((def.secrets as readonly Secret[] | undefined) ?? [])]),
|
|
88
|
+
...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
|
|
89
|
+
...(def.retries !== undefined ? { retries: def.retries } : {}),
|
|
90
|
+
...(builtin ? { builtin } : {}),
|
|
91
|
+
};
|
|
92
|
+
return Object.freeze(s) as unknown as AnyStep;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isStep(v: unknown): v is AnyStep {
|
|
96
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[STEP] === true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Define a custom step: a contract (name, image, inputs, output, secrets). Its handler lives in `handlers/<name>.ts`. */
|
|
100
|
+
export function step<
|
|
101
|
+
const N extends string,
|
|
102
|
+
const I extends InputSpec = {},
|
|
103
|
+
O extends AnyModel | null = null,
|
|
104
|
+
const Sec extends readonly Secret[] = [],
|
|
105
|
+
>(def: StepDef<N, I, O, Sec>): Step<N, I, O, Sec[number]["name"]> {
|
|
106
|
+
return makeStep("custom", def as never) as never;
|
|
107
|
+
}
|
|
@@ -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;
|
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,119 @@
|
|
|
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
|
+
paths: { root: string; workFiles: string; tmp: string };
|
|
16
|
+
signal: AbortSignal;
|
|
17
|
+
/** Where log lines go (default stderr). */
|
|
18
|
+
logSink?: (line: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatLog(message: string, fields?: Record<string, unknown>): string {
|
|
22
|
+
const parts = Object.entries(fields ?? {}).map(([k, v]) => `${k}=${typeof v === "string" && !/\s|"/.test(v) ? v : JSON.stringify(v)}`);
|
|
23
|
+
return parts.length ? `${message} ${parts.join(" ")}` : message;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createSecrets(declared: readonly Secret[], values?: Readonly<Record<string, string>>): Record<string, string> {
|
|
27
|
+
const obj: Record<string, string> = {};
|
|
28
|
+
for (const s of declared) {
|
|
29
|
+
Object.defineProperty(obj, s.name, {
|
|
30
|
+
enumerable: true,
|
|
31
|
+
get() {
|
|
32
|
+
const v = values?.[s.name] ?? process.env[s.name];
|
|
33
|
+
if (v === undefined) throw new Error(`secret ${s.name} is not set (the harness reads it from the environment variable ${s.name})`);
|
|
34
|
+
return v;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return Object.freeze(obj);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function httpError(res: Response, url: string): Promise<Error> {
|
|
42
|
+
let body = "";
|
|
43
|
+
try {
|
|
44
|
+
body = (await res.text()).slice(0, 500);
|
|
45
|
+
} catch {
|
|
46
|
+
/* ignore */
|
|
47
|
+
}
|
|
48
|
+
return new Error(`HTTP ${res.status} ${res.statusText} from ${redactUrl(url)}${body ? `: ${body}` : ""}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createHttp(signal: AbortSignal): Http {
|
|
52
|
+
const f = (url: string, init?: RequestInit) => fetch(url, { ...init, signal: init?.signal ?? signal });
|
|
53
|
+
return {
|
|
54
|
+
fetch: f,
|
|
55
|
+
async getJson<T>(url: string, init?: { headers?: Record<string, string> }): Promise<T> {
|
|
56
|
+
const res = await f(url, { headers: { accept: "application/json", ...init?.headers } });
|
|
57
|
+
if (!res.ok) throw await httpError(res, url);
|
|
58
|
+
return (await res.json()) as T;
|
|
59
|
+
},
|
|
60
|
+
async expectOk(url: string, init?: { headers?: Record<string, string>; method?: string }): Promise<Response> {
|
|
61
|
+
const res = await f(url, { method: init?.method ?? "GET", headers: init?.headers });
|
|
62
|
+
if (!res.ok) throw await httpError(res, url);
|
|
63
|
+
return res;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createRetry(signal: AbortSignal, log: (m: string, f?: Record<string, unknown>) => void) {
|
|
69
|
+
return async <T>(opts: RetryOptions, fn: (attempt: number) => Promise<T>): Promise<T> => {
|
|
70
|
+
if (!(Number.isInteger(opts.attempts) && opts.attempts >= 1)) throw new Error("retry: attempts must be a positive integer");
|
|
71
|
+
let delay = Math.max(0, opts.delaySeconds) * 1000;
|
|
72
|
+
for (let attempt = 1; ; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
return await fn(attempt);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (attempt >= opts.attempts || signal.aborted) throw e;
|
|
77
|
+
log("retrying", { attempt, of: opts.attempts, error: (e as Error)?.message ?? String(e) });
|
|
78
|
+
await new Promise<void>((res, rej) => {
|
|
79
|
+
const t = setTimeout(() => {
|
|
80
|
+
signal.removeEventListener("abort", onAbort);
|
|
81
|
+
res();
|
|
82
|
+
}, delay);
|
|
83
|
+
const onAbort = () => {
|
|
84
|
+
clearTimeout(t);
|
|
85
|
+
rej(new Error("retry cancelled: the step was cancelled"));
|
|
86
|
+
};
|
|
87
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
88
|
+
});
|
|
89
|
+
delay *= opts.backoff ?? 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The real ctx handed to handlers by the harness (and by testHandler). */
|
|
96
|
+
export function createCtx(o: CtxOptions): Ctx {
|
|
97
|
+
mkdirSync(o.paths.tmp, { recursive: true });
|
|
98
|
+
mkdirSync(o.paths.workFiles, { recursive: true });
|
|
99
|
+
const sink = o.logSink ?? ((line: string) => process.stderr.write(line + "\n"));
|
|
100
|
+
const log = (message: string, fields?: Record<string, unknown>) => sink(formatLog(message, fields));
|
|
101
|
+
const { sh, exec } = createSh({ tmpDir: o.paths.tmp, signal: o.signal });
|
|
102
|
+
const filesDeps = { run: o.run, workFiles: o.paths.workFiles, tmpDir: o.paths.tmp, root: o.paths.root, signal: o.signal, log };
|
|
103
|
+
return {
|
|
104
|
+
secrets: createSecrets(o.secrets, o.secretValues) as Ctx["secrets"],
|
|
105
|
+
sh,
|
|
106
|
+
exec,
|
|
107
|
+
readText: async (p) => readFileSync(resolve(p), "utf8"),
|
|
108
|
+
readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p), "utf8")) as T,
|
|
109
|
+
files: { fetch: createFetch(filesDeps) },
|
|
110
|
+
output: createOutput(filesDeps),
|
|
111
|
+
http: createHttp(o.signal),
|
|
112
|
+
retry: createRetry(o.signal, log),
|
|
113
|
+
log,
|
|
114
|
+
signal: o.signal,
|
|
115
|
+
run: Object.freeze({ ...o.run }),
|
|
116
|
+
tmpDir: o.paths.tmp,
|
|
117
|
+
skip: skipped,
|
|
118
|
+
};
|
|
119
|
+
}
|