@olivervea/pipelines 2.0.0-beta.2 → 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 +89 -36
- package/manifest.schema.json +88 -10
- package/package.json +1 -1
- package/src/build/build.ts +3 -3
- package/src/build/cli.ts +1 -1
- package/src/build/hashes.ts +1 -1
- package/src/build/project.ts +26 -5
- package/src/builder/pipeline.ts +77 -9
- 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/sh.ts +2 -1
- package/src/ctx/types.ts +4 -1
- package/src/describe/describe.ts +187 -13
- package/src/describe/manifest-schema.ts +37 -4
- package/src/dev/cli.ts +11 -4
- package/src/harness/execute.ts +7 -4
- package/src/harness/index.ts +1 -1
- package/src/harness/main.ts +49 -10
- package/src/harness/validate.ts +19 -1
- package/src/index.ts +4 -3
- package/src/library/checks.ts +23 -0
- package/src/library/compose-deploy.ts +93 -0
- package/src/library/github-source.ts +23 -4
- package/src/library/helm-deploy-ssh.ts +240 -87
- package/src/library/helm-deploy.ts +268 -0
- package/src/library/image-build.ts +313 -33
- package/src/library/index.ts +10 -3
- package/src/library/models.ts +16 -1
- package/src/library/npm.ts +33 -0
- package/src/library/ssh.ts +45 -0
- package/src/testing/index.ts +6 -4
- package/src/util/handler-hash.ts +1 -1
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
// steps.imageBuild — build a container image from a `Source` with kaniko
|
|
2
|
-
//
|
|
3
|
-
// No registry push: the tarball travels as a file ref and a deploy step imports it on the host.
|
|
1
|
+
// steps.imageBuild — build a container image from a `Source` with kaniko: to an image tarball that
|
|
2
|
+
// travels as a file ref (a deploy imports it on the host), or pushed to a registry (`push: true`).
|
|
4
3
|
// Ported from v1's olve-lib.sh (olve_kaniko_build + build.sh's layer cache); footguns kept below.
|
|
5
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { join } from "node:path";
|
|
7
6
|
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
8
7
|
import type { FileRef } from "../core/models.ts";
|
|
8
|
+
import type { Secret } from "../core/secrets.ts";
|
|
9
|
+
import { isConfigRef, type ConfigRef, type Configurable } from "../core/config.ts";
|
|
10
|
+
import { isLiteral, recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
|
|
9
11
|
import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
10
|
-
import {
|
|
12
|
+
import { sha256File } from "../util/hash.ts";
|
|
13
|
+
import { dirEntries, writeTar } from "../util/tar.ts";
|
|
14
|
+
import { BuiltImage, PushedImage, Source } from "./models.ts";
|
|
11
15
|
|
|
12
16
|
/** kaniko's debug image (it has a busybox shell for debugging; the executor itself needs none). */
|
|
13
17
|
export const KANIKO_IMAGE = "gcr.io/kaniko-project/executor:debug";
|
|
@@ -22,24 +26,43 @@ export const KANIKO_CONTEXT = "/kaniko/build-context";
|
|
|
22
26
|
const input = { source: Source };
|
|
23
27
|
export type ImageBuildInput = typeof input;
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
/** Credentials for the registry of `repository`: a user name and a secret holding the password or token. */
|
|
30
|
+
export interface RegistryAuth<K extends string> {
|
|
31
|
+
username: string;
|
|
32
|
+
password: Secret<K>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Registry credentials as config: the password is a `secretRef` (`{ secret: NAME }`) the step must declare in `secrets`. */
|
|
36
|
+
export interface RegistryAuthConfig {
|
|
37
|
+
username: string;
|
|
38
|
+
password: { secret: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ImageBuildDef<N extends string, K extends string = never> {
|
|
26
42
|
name: LiteralName<N>;
|
|
27
43
|
/** What the step does, shown in the UI. Required; may be empty. */
|
|
28
44
|
description: string;
|
|
29
|
-
/**
|
|
30
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Image name, e.g. `olve-pipelines-v2`; with `push`, the registry repository to push to
|
|
47
|
+
* (`registry.ovea.pro/app/api`). The tag is the run's version (see `imageTag`) unless `contentTag`.
|
|
48
|
+
*/
|
|
49
|
+
repository: Configurable<string>;
|
|
50
|
+
/** Build context: a directory of the source tree (default: the root). */
|
|
51
|
+
context?: string;
|
|
52
|
+
/** Dockerfile path, relative to the context (default `Dockerfile`). */
|
|
53
|
+
dockerfile?: string;
|
|
31
54
|
/**
|
|
32
55
|
* Registry repository for kaniko's layer cache (`--cache=true --cache-repo=…`), so unchanged
|
|
33
56
|
* layers (restore, apt, npm, …) are reused. kaniko pushes cache layers even with `--no-push`.
|
|
34
57
|
* A registry without GC grows with every changed layer — prune it when needed.
|
|
35
58
|
*/
|
|
36
|
-
cacheRepo?: string
|
|
59
|
+
cacheRepo?: Configurable<string | undefined>;
|
|
37
60
|
/**
|
|
38
61
|
* kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
|
|
39
62
|
* the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
|
|
40
63
|
* a map replaces the default entirely, `{}` turns mapping off.
|
|
41
64
|
*/
|
|
42
|
-
registryMap?: Record<string, string>;
|
|
65
|
+
registryMap?: Configurable<Record<string, string> | undefined>;
|
|
43
66
|
timeoutSeconds?: number;
|
|
44
67
|
/**
|
|
45
68
|
* Kill kaniko when it writes no output for this long (default 300 s; 0 disables), failing the
|
|
@@ -48,12 +71,51 @@ export interface ImageBuildDef<N extends string> {
|
|
|
48
71
|
* the longest silent build phase (an AOT "Generating native code" runs ~2 min with no output).
|
|
49
72
|
*/
|
|
50
73
|
stallTimeoutSeconds?: number;
|
|
74
|
+
/**
|
|
75
|
+
* kaniko `--snapshot-mode`: `redo` (default) compares file metadata instead of hashing every file's
|
|
76
|
+
* content after each instruction; `full` hashes content (slower, and what kaniko defaults to).
|
|
77
|
+
*/
|
|
78
|
+
snapshotMode?: "redo" | "full" | "time";
|
|
79
|
+
/** kaniko `--use-new-run`: snapshot only what a RUN changed (faster for big images). Default true. */
|
|
80
|
+
useNewRun?: boolean;
|
|
81
|
+
/** kaniko `--compressed-caching`: compress layers while caching (saves memory, costs time). Default false. */
|
|
82
|
+
compressedCaching?: boolean;
|
|
51
83
|
/**
|
|
52
84
|
* Automatic retries of a failed attempt (a fresh pod: kaniko unpacks the base image over the
|
|
53
85
|
* container root and does not clean up when killed, so retrying inside the same pod is unsafe).
|
|
54
86
|
* A `--no-push` build is idempotent, so the default is 2 (three tries).
|
|
55
87
|
*/
|
|
56
88
|
retries?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Log in to the registry of `repository` (e.g. `ghcr.io` with a token that may write packages).
|
|
91
|
+
* Used for kaniko's push, base image and cache pulls from that registry, and the `contentTag` lookup.
|
|
92
|
+
*/
|
|
93
|
+
registryAuth?: RegistryAuth<K> | ConfigRef<RegistryAuthConfig | undefined>;
|
|
94
|
+
/** The registry of `repository` speaks plain HTTP (a local `registry:2`). */
|
|
95
|
+
insecureRegistry?: Configurable<boolean | undefined>;
|
|
96
|
+
/** Secrets a config-referenced `registryAuth` may name (the step declares them). */
|
|
97
|
+
secrets?: readonly Secret<K>[];
|
|
98
|
+
/** `false` skips the step (`ctx.skip`), e.g. `enabled: PUBLISH.harness.push`. Default true. */
|
|
99
|
+
enabled?: Configurable<boolean | undefined>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Tarball mode (default): output `BuiltImage` with the image tar as a file. */
|
|
103
|
+
export interface ImageBuildTarballDef<N extends string, K extends string = never> extends ImageBuildDef<N, K> {
|
|
104
|
+
push?: false;
|
|
105
|
+
contentTag?: never;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Registry mode: push to `repository` and output `PushedImage`, for images too big to travel as a
|
|
110
|
+
* tarball (the step's scratch space is ~1 GiB).
|
|
111
|
+
*/
|
|
112
|
+
export interface ImageBuildPushDef<N extends string, K extends string = never> extends ImageBuildDef<N, K> {
|
|
113
|
+
push: true;
|
|
114
|
+
/**
|
|
115
|
+
* Tag the image `src-<hash>`, the hash of the build context's content, and skip the build when the
|
|
116
|
+
* registry already has that tag: the image is rebuilt only when its context changes.
|
|
117
|
+
*/
|
|
118
|
+
contentTag?: boolean;
|
|
57
119
|
}
|
|
58
120
|
|
|
59
121
|
/**
|
|
@@ -74,10 +136,10 @@ export function registryHost(ref: string): string {
|
|
|
74
136
|
}
|
|
75
137
|
|
|
76
138
|
/** Whether a registry answers its API root: 200, or 401 (it wants a token). Never throws. */
|
|
77
|
-
export async function registryUp(http: Ctx["http"], host: string, signal?: AbortSignal, timeoutMs = CACHE_PROBE_TIMEOUT_MS): Promise<boolean> {
|
|
139
|
+
export async function registryUp(http: Ctx["http"], host: string, signal?: AbortSignal, timeoutMs = CACHE_PROBE_TIMEOUT_MS, insecure = false): Promise<boolean> {
|
|
78
140
|
try {
|
|
79
141
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
80
|
-
const res = await http.fetch(
|
|
142
|
+
const res = await http.fetch(`${insecure ? "http" : "https"}://${host}/v2/`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
|
|
81
143
|
await res.body?.cancel().catch(() => {});
|
|
82
144
|
return res.status === 200 || res.status === 401;
|
|
83
145
|
} catch {
|
|
@@ -92,9 +154,10 @@ export async function registryUp(http: Ctx["http"], host: string, signal?: Abort
|
|
|
92
154
|
export async function reachableCaches(
|
|
93
155
|
ctx: Pick<Ctx, "http" | "log" | "signal">,
|
|
94
156
|
caches: { registryMap: Record<string, string>; cacheRepo?: string },
|
|
157
|
+
insecureHosts: readonly string[] = [],
|
|
95
158
|
): Promise<{ registryMap: Record<string, string>; cacheRepo?: string }> {
|
|
96
159
|
const hosts = new Set([...Object.values(caches.registryMap), ...(caches.cacheRepo ? [caches.cacheRepo] : [])].map(registryHost));
|
|
97
|
-
const up = new Map(await Promise.all([...hosts].map(async (h) => [h, await registryUp(ctx.http, h, ctx.signal)] as const)));
|
|
160
|
+
const up = new Map(await Promise.all([...hosts].map(async (h) => [h, await registryUp(ctx.http, h, ctx.signal, undefined, insecureHosts.includes(h))] as const)));
|
|
98
161
|
const registryMap: Record<string, string> = {};
|
|
99
162
|
for (const [from, to] of Object.entries(caches.registryMap)) {
|
|
100
163
|
if (up.get(registryHost(to))) registryMap[from] = to;
|
|
@@ -107,6 +170,9 @@ export async function reachableCaches(
|
|
|
107
170
|
return { registryMap, ...(caches.cacheRepo ? { cacheRepo: caches.cacheRepo } : {}) };
|
|
108
171
|
}
|
|
109
172
|
|
|
173
|
+
/** Default kaniko `--snapshot-mode`. */
|
|
174
|
+
export const DEFAULT_SNAPSHOT_MODE = "redo";
|
|
175
|
+
|
|
110
176
|
/** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
|
|
111
177
|
export const DEFAULT_RETRIES = 2;
|
|
112
178
|
|
|
@@ -132,28 +198,194 @@ export function qualifyRepository(repository: string): string {
|
|
|
132
198
|
return `docker.io/${repository}`;
|
|
133
199
|
}
|
|
134
200
|
|
|
201
|
+
/** A relative path inside the source tree (no `..`, not absolute). */
|
|
202
|
+
function checkRelative(what: string, path: string): string {
|
|
203
|
+
const clean = path.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
204
|
+
if (clean.startsWith("/") || clean.split("/").includes("..")) throw new Error(`imageBuild ${what} '${path}' must be a relative path inside the source tree`);
|
|
205
|
+
return clean === "." ? "" : clean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The `src-<12 hex>` tag of a directory: the sha256 of its deterministic tar (as `ctx.output.dir` writes it). */
|
|
209
|
+
export function contentTagOf(dir: string, tmpDir: string): string {
|
|
210
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
211
|
+
const work = mkdtempSync(join(tmpDir, "content-tag-"));
|
|
212
|
+
try {
|
|
213
|
+
const tar = join(work, "context.tar");
|
|
214
|
+
writeTar(tar, dirEntries(dir));
|
|
215
|
+
return `src-${sha256File(tar).slice(0, 12)}`;
|
|
216
|
+
} finally {
|
|
217
|
+
rmSync(work, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const MANIFEST_TYPES = [
|
|
222
|
+
"application/vnd.oci.image.index.v1+json",
|
|
223
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
224
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
225
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
226
|
+
].join(", ");
|
|
227
|
+
|
|
228
|
+
/** Options of a registry API call: login (`username`/`password`) and plain HTTP. */
|
|
229
|
+
export interface RegistryAccess {
|
|
230
|
+
username?: string;
|
|
231
|
+
password?: string;
|
|
232
|
+
insecure?: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const basic = (username: string, password: string) => `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
236
|
+
|
|
237
|
+
/** `Bearer realm="…",service="…",scope="…"` → its parameters. */
|
|
238
|
+
export function parseChallenge(header: string): { scheme: string; params: Record<string, string> } {
|
|
239
|
+
const scheme = header.trim().split(/\s+/, 1)[0]!.toLowerCase();
|
|
240
|
+
const params: Record<string, string> = {};
|
|
241
|
+
for (const m of header.slice(scheme.length).matchAll(/([A-Za-z]+)="([^"]*)"/g)) params[m[1]!.toLowerCase()] = m[2]!;
|
|
242
|
+
return { scheme, params };
|
|
243
|
+
}
|
|
244
|
+
|
|
135
245
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
246
|
+
* `HEAD`/`GET` against a registry API path, answering its auth challenge: Basic (a `registry:2` with
|
|
247
|
+
* htpasswd) or a Bearer token from the realm (ghcr.io, Docker Hub; anonymous for public images).
|
|
138
248
|
*/
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const
|
|
249
|
+
async function registryFetch(http: Ctx["http"], host: string, path: string, init: RequestInit, access: RegistryAccess): Promise<Response> {
|
|
250
|
+
const url = `${access.insecure ? "http" : "https"}://${host}${path}`;
|
|
251
|
+
const res = await http.fetch(url, init);
|
|
252
|
+
if (res.status !== 401) return res;
|
|
253
|
+
await res.body?.cancel().catch(() => {});
|
|
254
|
+
const { scheme, params } = parseChallenge(res.headers.get("www-authenticate") ?? "");
|
|
255
|
+
const login = access.username !== undefined && access.password !== undefined ? basic(access.username, access.password) : undefined;
|
|
256
|
+
let authorization: string;
|
|
257
|
+
if (scheme === "basic") {
|
|
258
|
+
if (!login) return new Response(null, { status: 401 });
|
|
259
|
+
authorization = login;
|
|
260
|
+
} else if (scheme === "bearer" && params.realm) {
|
|
261
|
+
const q = new URLSearchParams();
|
|
262
|
+
if (params.service) q.set("service", params.service);
|
|
263
|
+
if (params.scope) q.set("scope", params.scope);
|
|
264
|
+
const tokenRes = await http.fetch(`${params.realm}?${q}`, { headers: login ? { Authorization: login } : {} });
|
|
265
|
+
if (!tokenRes.ok) {
|
|
266
|
+
await tokenRes.body?.cancel().catch(() => {});
|
|
267
|
+
return new Response(null, { status: tokenRes.status === 404 ? 401 : tokenRes.status });
|
|
268
|
+
}
|
|
269
|
+
const body = (await tokenRes.json()) as { token?: string; access_token?: string };
|
|
270
|
+
const token = body.token ?? body.access_token;
|
|
271
|
+
if (!token) throw new Error(`registry token endpoint ${params.realm} returned no token`);
|
|
272
|
+
authorization = `Bearer ${token}`;
|
|
273
|
+
} else {
|
|
274
|
+
return new Response(null, { status: 401 });
|
|
275
|
+
}
|
|
276
|
+
const headers = new Headers(init.headers);
|
|
277
|
+
headers.set("Authorization", authorization);
|
|
278
|
+
return http.fetch(url, { ...init, headers });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The digest of `repository:tag` in its registry (`HEAD …/manifests/<tag>`), or undefined if it has none. */
|
|
282
|
+
export async function registryDigest(http: Ctx["http"], repository: string, tag: string, access: RegistryAccess = {}): Promise<string | undefined> {
|
|
283
|
+
const [host, ...path] = repository.split("/");
|
|
284
|
+
const res = await registryFetch(http, host!, `/v2/${path.join("/")}/manifests/${tag}`, { method: "HEAD", headers: { Accept: MANIFEST_TYPES } }, access);
|
|
285
|
+
await res.body?.cancel().catch(() => {});
|
|
286
|
+
if (res.status === 404) return undefined;
|
|
287
|
+
// An anonymous lookup of a repository that doesn't exist yet (or is private) is denied, not 404.
|
|
288
|
+
if ((res.status === 401 || res.status === 403) && access.password === undefined) return undefined;
|
|
289
|
+
if (!res.ok) throw new Error(`registry lookup of ${repository}:${tag} answered HTTP ${res.status}`);
|
|
290
|
+
const digest = res.headers.get("docker-content-digest") ?? "";
|
|
291
|
+
return /^sha256:[0-9a-f]{64}$/.test(digest) ? digest : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** kaniko reads registry logins from `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in its image). */
|
|
295
|
+
export const KANIKO_DOCKER_CONFIG = "/kaniko/.docker";
|
|
296
|
+
|
|
297
|
+
/** A docker `config.json` that logs in to `host`. */
|
|
298
|
+
export function dockerConfigJson(host: string, username: string, password: string): string {
|
|
299
|
+
return JSON.stringify({ auths: { [host]: { auth: Buffer.from(`${username}:${password}`).toString("base64") } } });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Adds the login for `host` to `<dir>/config.json` (owner-only), keeping what is there (kaniko's credHelpers). */
|
|
303
|
+
export function writeDockerConfig(dir: string, host: string, username: string, password: string): void {
|
|
304
|
+
mkdirSync(dir, { recursive: true });
|
|
305
|
+
const file = join(dir, "config.json");
|
|
306
|
+
let config: { auths?: Record<string, unknown> } = {};
|
|
307
|
+
try {
|
|
308
|
+
config = JSON.parse(readFileSync(file, "utf8"));
|
|
309
|
+
} catch {
|
|
310
|
+
// absent or unreadable: start fresh
|
|
311
|
+
}
|
|
312
|
+
const login = JSON.parse(dockerConfigJson(host, username, password)) as { auths: Record<string, unknown> };
|
|
313
|
+
writeFileSync(file, JSON.stringify({ ...config, auths: { ...config.auths, ...login.auths } }), { mode: 0o600 });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Builds an image with kaniko from `<context>/<dockerfile>` in the source tree (default: `Dockerfile`
|
|
318
|
+
* at the root). Default: into an image tarball, output `BuiltImage { repository, tag, digest, tar }`.
|
|
319
|
+
* With `push: true`: pushed to `repository`, output `PushedImage { repository, tag, digest }`.
|
|
320
|
+
*/
|
|
321
|
+
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K>): Step<N, ImageBuildInput, typeof BuiltImage, K> & LibraryStep;
|
|
322
|
+
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildPushDef<N, K>): Step<N, ImageBuildInput, typeof PushedImage, K> & LibraryStep;
|
|
323
|
+
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K> | ImageBuildPushDef<N, K>): AnyImageBuild {
|
|
324
|
+
const push = def.push === true;
|
|
325
|
+
const contentTag = push && def.contentTag === true;
|
|
326
|
+
const context = checkRelative("context", def.context ?? "");
|
|
327
|
+
const dockerfile = checkRelative("dockerfile", def.dockerfile ?? "Dockerfile");
|
|
328
|
+
const authSecret = def.registryAuth && !isConfigRef(def.registryAuth) ? def.registryAuth : undefined;
|
|
329
|
+
if (isLiteral([def.repository, def.registryAuth, def.insecureRegistry])) checkRegistryHost(def.name, def.repository as string, push, def.registryAuth !== undefined, def.insecureRegistry === true);
|
|
330
|
+
const secrets = [...(authSecret ? [authSecret.password] : []), ...(def.secrets ?? [])].filter((s, i, all) => all.findIndex((x) => x.name === s.name) === i);
|
|
142
331
|
return makeStep(
|
|
143
332
|
"imageBuild",
|
|
144
|
-
{ name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: BuiltImage, secrets
|
|
333
|
+
{ name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: push ? PushedImage : BuiltImage, secrets, timeoutSeconds: def.timeoutSeconds, retries: def.retries ?? DEFAULT_RETRIES },
|
|
145
334
|
{
|
|
146
|
-
manifestConfig: () =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
335
|
+
manifestConfig: () => {
|
|
336
|
+
const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
|
|
337
|
+
const mapped = isConfigRef(registryMap) || Object.keys(registryMap).length > 0;
|
|
338
|
+
return {
|
|
339
|
+
repository: recordConfigRefs(def.repository),
|
|
340
|
+
...(context ? { context } : {}),
|
|
341
|
+
...(dockerfile !== "Dockerfile" ? { dockerfile } : {}),
|
|
342
|
+
...(push ? { push: true } : {}),
|
|
343
|
+
...(contentTag ? { contentTag: true } : {}),
|
|
344
|
+
...(def.cacheRepo !== undefined ? { cacheRepo: recordConfigRefs(def.cacheRepo) } : {}),
|
|
345
|
+
...(mapped ? { registryMap: recordConfigRefs(registryMap) } : {}),
|
|
346
|
+
...(authSecret ? { registryAuth: { username: authSecret.username, password: authSecret.password.name } } : {}),
|
|
347
|
+
...(def.registryAuth && isConfigRef(def.registryAuth) ? { registryAuth: recordConfigRefs(def.registryAuth) } : {}),
|
|
348
|
+
...(def.insecureRegistry !== undefined && def.insecureRegistry !== false ? { insecureRegistry: recordConfigRefs(def.insecureRegistry) } : {}),
|
|
349
|
+
...(def.enabled !== undefined ? { enabled: recordConfigRefs(def.enabled) } : {}),
|
|
350
|
+
...(def.snapshotMode !== undefined && def.snapshotMode !== DEFAULT_SNAPSHOT_MODE ? { snapshotMode: def.snapshotMode } : {}),
|
|
351
|
+
...(def.useNewRun === false ? { useNewRun: false } : {}),
|
|
352
|
+
...(def.compressedCaching === true ? { compressedCaching: true } : {}),
|
|
353
|
+
};
|
|
354
|
+
},
|
|
151
355
|
async run({ source }: { source: { tree: FileRef } }, ctx: Ctx) {
|
|
152
|
-
const
|
|
153
|
-
|
|
356
|
+
const d = resolveConfigRefs({
|
|
357
|
+
repository: def.repository, cacheRepo: def.cacheRepo, registryMap: def.registryMap, insecureRegistry: def.insecureRegistry,
|
|
358
|
+
registryAuth: def.registryAuth, enabled: def.enabled,
|
|
359
|
+
}, ctx.config ?? {});
|
|
360
|
+
const repository = d.repository as string;
|
|
361
|
+
if (d.enabled === false) return ctx.skip(`publishing ${repository} is turned off`);
|
|
362
|
+
const registryMap = (d.registryMap as Record<string, string> | undefined) ?? DEFAULT_REGISTRY_MAP;
|
|
363
|
+
const insecure = d.insecureRegistry === true;
|
|
364
|
+
const auth = registryLogin(d.registryAuth as RegistryAuth<string> | RegistryAuthConfig | undefined, ctx, secrets.map((s) => s.name));
|
|
365
|
+
checkRegistryHost(def.name, repository, push, auth !== undefined, insecure);
|
|
366
|
+
const host = registryHost(repository);
|
|
367
|
+
|
|
154
368
|
const tree = await ctx.files.fetch(source.tree);
|
|
369
|
+
const contextDir = context ? join(tree, context) : tree;
|
|
370
|
+
if (!existsSync(contextDir) || !statSync(contextDir).isDirectory()) throw new Error(`build context '${context}' not found in the source tree`);
|
|
371
|
+
if (!existsSync(join(contextDir, dockerfile))) throw new Error(`${dockerfile} not found in the build context${context ? ` '${context}'` : ""}`);
|
|
372
|
+
|
|
373
|
+
const access: RegistryAccess = { insecure, ...(auth ?? {}) };
|
|
374
|
+
if (auth) writeDockerConfig(process.env.DOCKER_CONFIG || KANIKO_DOCKER_CONFIG, host, auth.username, auth.password);
|
|
375
|
+
|
|
376
|
+
const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
|
|
377
|
+
if (contentTag) {
|
|
378
|
+
const digest = await registryDigest(ctx.http, repository, tag, access);
|
|
379
|
+
if (digest) {
|
|
380
|
+
ctx.log("image already in the registry", { image: `${repository}:${tag}`, digest });
|
|
381
|
+
return { repository, tag, digest };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const caches = await reachableCaches(ctx, { registryMap, cacheRepo: d.cacheRepo as string | undefined }, insecure ? [host] : []);
|
|
155
386
|
await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
|
|
156
387
|
await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
|
|
388
|
+
const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
|
|
157
389
|
|
|
158
390
|
// Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
|
|
159
391
|
// kaniko images have no /tmp), then into the step's files.
|
|
@@ -161,18 +393,22 @@ export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<
|
|
|
161
393
|
const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
|
|
162
394
|
const tarPath = join(out, "image.tar");
|
|
163
395
|
const digestPath = join(out, "digest");
|
|
396
|
+
const output = push ? [] : ["--no-push", `--tar-path=${tarPath}`];
|
|
164
397
|
// No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
|
|
165
398
|
// the build context intact. --skip-push-permission-check: with --no-push kaniko otherwise checks
|
|
166
399
|
// the cache repo at startup and exits if it is down; later cache lookup and push errors are
|
|
167
400
|
// only warnings.
|
|
168
401
|
await ctx.exec([
|
|
169
402
|
"/kaniko/executor",
|
|
170
|
-
`--context=${
|
|
171
|
-
`--dockerfile=${
|
|
172
|
-
|
|
173
|
-
`--tar-path=${tarPath}`,
|
|
403
|
+
`--context=${kanikoContext}`,
|
|
404
|
+
`--dockerfile=${kanikoContext}/${dockerfile}`,
|
|
405
|
+
...output,
|
|
174
406
|
`--digest-file=${digestPath}`,
|
|
175
|
-
`--destination=${
|
|
407
|
+
`--destination=${repository}:${tag}`,
|
|
408
|
+
...(insecure ? ["--insecure", `--insecure-registry=${host}`] : []),
|
|
409
|
+
`--snapshot-mode=${def.snapshotMode ?? DEFAULT_SNAPSHOT_MODE}`,
|
|
410
|
+
...((def.useNewRun ?? true) ? ["--use-new-run"] : []),
|
|
411
|
+
`--compressed-caching=${def.compressedCaching ?? false}`,
|
|
176
412
|
...(caches.cacheRepo ? ["--cache=true", `--cache-repo=${caches.cacheRepo}`, "--skip-push-permission-check"] : []),
|
|
177
413
|
...Object.entries(caches.registryMap).map(([from, to]) => `--registry-map=${from}=${to}`),
|
|
178
414
|
], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
|
|
@@ -184,11 +420,55 @@ export function imageBuild<const N extends string>(def: ImageBuildDef<N>): Step<
|
|
|
184
420
|
throw new Error(`kaniko did not write the image digest to ${digestPath}`);
|
|
185
421
|
}
|
|
186
422
|
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
|
|
423
|
+
if (push) {
|
|
424
|
+
ctx.log("image pushed", { image: `${repository}:${tag}`, digest });
|
|
425
|
+
return { repository, tag, digest };
|
|
426
|
+
}
|
|
187
427
|
const tar = await ctx.output.file(tarPath, { name: "image.tar" });
|
|
188
428
|
rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
|
|
189
|
-
ctx.log("image built", { image: `${
|
|
190
|
-
return { repository: qualifyRepository(
|
|
429
|
+
ctx.log("image built", { image: `${repository}:${tag}`, digest });
|
|
430
|
+
return { repository: qualifyRepository(repository), tag, digest, tar };
|
|
191
431
|
},
|
|
192
432
|
},
|
|
433
|
+
def,
|
|
193
434
|
) as never;
|
|
194
435
|
}
|
|
436
|
+
|
|
437
|
+
function checkRegistryHost(name: string, repository: string, push: boolean, auth: boolean, insecure: boolean): void {
|
|
438
|
+
const host = registryHost(repository);
|
|
439
|
+
if (push && !/[.:]|^localhost$/.test(host)) throw new Error(`imageBuild '${name}': push needs a repository with a registry host, got '${repository}'`);
|
|
440
|
+
if ((auth || insecure) && !/[.:]|^localhost$/.test(host)) throw new Error(`imageBuild '${name}': registryAuth and insecureRegistry need a repository with a registry host, got '${repository}'`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The login for `registryAuth` (a literal secret or a config `secretRef`), or undefined without one. */
|
|
444
|
+
function registryLogin(auth: RegistryAuth<string> | RegistryAuthConfig | undefined, ctx: Pick<Ctx, "secrets">, declared: readonly string[]): { username: string; password: string } | undefined {
|
|
445
|
+
if (!auth) return undefined;
|
|
446
|
+
const name = "name" in auth.password ? auth.password.name : auth.password.secret;
|
|
447
|
+
if (!declared.includes(name)) throw new Error(`registryAuth names secret ${name}, which the step does not declare (add it to secrets)`);
|
|
448
|
+
const password = (ctx.secrets as Record<string, string | undefined>)[name];
|
|
449
|
+
if (!password) throw new Error(`secret ${name} (registryAuth) is not set`);
|
|
450
|
+
return { username: auth.username, password };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Adds `tag` to the image `repository@digest` without rebuilding it: the manifest is fetched by
|
|
455
|
+
* digest and PUT back under the tag byte for byte, so the digest stays the same.
|
|
456
|
+
*/
|
|
457
|
+
export async function registryTag(http: Ctx["http"], repository: string, digest: string, tag: string, access: RegistryAccess = {}): Promise<void> {
|
|
458
|
+
const [host, ...path] = repository.split("/");
|
|
459
|
+
const name = path.join("/");
|
|
460
|
+
const got = await registryFetch(http, host!, `/v2/${name}/manifests/${digest}`, { method: "GET", headers: { Accept: MANIFEST_TYPES } }, access);
|
|
461
|
+
if (!got.ok) {
|
|
462
|
+
await got.body?.cancel().catch(() => {});
|
|
463
|
+
throw new Error(`registry fetch of ${repository}@${digest} answered HTTP ${got.status}`);
|
|
464
|
+
}
|
|
465
|
+
const type = got.headers.get("content-type") ?? "application/vnd.oci.image.manifest.v1+json";
|
|
466
|
+
const body = new Uint8Array(await got.arrayBuffer());
|
|
467
|
+
const put = await registryFetch(http, host!, `/v2/${name}/manifests/${encodeURIComponent(tag)}`, { method: "PUT", headers: { "Content-Type": type }, body }, access);
|
|
468
|
+
await put.body?.cancel().catch(() => {});
|
|
469
|
+
if (!put.ok) throw new Error(`registry tag ${repository}:${tag} → ${digest} answered HTTP ${put.status}`);
|
|
470
|
+
const written = put.headers.get("docker-content-digest");
|
|
471
|
+
if (written && written !== digest) throw new Error(`registry stored ${repository}:${tag} as ${written}, not ${digest}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
type AnyImageBuild = Step<string, ImageBuildInput, typeof BuiltImage | typeof PushedImage, any> & LibraryStep;
|
package/src/library/index.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { gitHubSource } from "./github-source.ts";
|
|
2
|
+
import { composeDeploy } from "./compose-deploy.ts";
|
|
3
|
+
import { helmDeployInCluster } from "./helm-deploy.ts";
|
|
2
4
|
import { helmDeployOverSsh } from "./helm-deploy-ssh.ts";
|
|
3
5
|
import { imageBuild } from "./image-build.ts";
|
|
4
6
|
import { script } from "./script.ts";
|
|
5
7
|
|
|
6
|
-
export { Source, BuiltImage, HelmRelease, FailureContext } from "./models.ts";
|
|
8
|
+
export { Source, BuiltImage, PushedImage, HelmRelease, FailureContext } from "./models.ts";
|
|
7
9
|
export { inputEnvName, type RunValue, type ScriptDef } from "./script.ts";
|
|
8
10
|
export type { GitHubSourceDef } from "./github-source.ts";
|
|
9
|
-
export type
|
|
11
|
+
export { registryTag, registryDigest, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type RegistryAuthConfig, type RegistryAccess } from "./image-build.ts";
|
|
10
12
|
export type { HelmDeployOverSshDef } from "./helm-deploy-ssh.ts";
|
|
13
|
+
export { type ComposeDeployDef, type ComposeTargetValue, type ComposeDeployResult } from "./compose-deploy.ts";
|
|
14
|
+
export { helmTarget, deployTarget, type DeployTargetOptions, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetDeployDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue } from "./helm-deploy.ts";
|
|
15
|
+
export { sshSession, shq, type SshSession } from "./ssh.ts";
|
|
16
|
+
export { serviceUrl, checkSpa } from "./checks.ts";
|
|
11
17
|
export { httpHealthCheck, HTTP_HEALTH_DEFAULTS, type HttpHealthCheckOptions } from "./http-health.ts";
|
|
18
|
+
export { npmDistTag, npmHasVersion, NPM_REGISTRY } from "./npm.ts";
|
|
12
19
|
|
|
13
20
|
/** SDK-provided steps (step + handler pairs with typed config). */
|
|
14
|
-
export const steps = { script, gitHubSource, imageBuild, helmDeployOverSsh };
|
|
21
|
+
export const steps = { script, gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy };
|
package/src/library/models.ts
CHANGED
|
@@ -14,10 +14,25 @@ export const BuiltImage = model(
|
|
|
14
14
|
z.object({ repository: z.string(), tag: z.string(), digest: z.string(), tar: fileRef() }),
|
|
15
15
|
);
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A container image pushed to a registry by a step (`imageBuild` with `push: true`), pulled from there
|
|
19
|
+
* by the deploy. `repository` includes the registry host.
|
|
20
|
+
*/
|
|
21
|
+
export const PushedImage = model(
|
|
22
|
+
"PushedImage",
|
|
23
|
+
z.object({ repository: z.string(), tag: z.string(), digest: z.string() }),
|
|
24
|
+
);
|
|
25
|
+
|
|
17
26
|
/** A Helm release that was installed or upgraded. */
|
|
18
27
|
export const HelmRelease = model(
|
|
19
28
|
"HelmRelease",
|
|
20
|
-
z.object({
|
|
29
|
+
z.object({
|
|
30
|
+
release: z.string(),
|
|
31
|
+
namespace: z.string(),
|
|
32
|
+
image: z.string(),
|
|
33
|
+
/** Where step pods reach the release (the target's `url`); absent = its in-cluster Service URL (`serviceUrl`). */
|
|
34
|
+
url: z.string().optional(),
|
|
35
|
+
}),
|
|
21
36
|
);
|
|
22
37
|
|
|
23
38
|
/** One failed step of a run, as seen by a failure handler. */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// npm registry calls for release steps (no npm CLI needed in the image).
|
|
2
|
+
import type { Ctx } from "../ctx/types.ts";
|
|
3
|
+
|
|
4
|
+
export const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
5
|
+
|
|
6
|
+
const packagePath = (name: string) => name.replace("/", "%2f");
|
|
7
|
+
|
|
8
|
+
/** Whether `name@version` is on the registry. */
|
|
9
|
+
export async function npmHasVersion(ctx: Pick<Ctx, "http">, name: string, version: string, registry = NPM_REGISTRY): Promise<boolean> {
|
|
10
|
+
const res = await ctx.http.fetch(`${registry}/${packagePath(name)}`, { headers: { "cache-control": "no-cache" } });
|
|
11
|
+
if (res.status === 404) {
|
|
12
|
+
await res.body?.cancel().catch(() => {});
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
if (!res.ok) throw new Error(`npm registry lookup of ${name} failed: HTTP ${res.status}`);
|
|
16
|
+
return Boolean(((await res.json()) as { versions?: Record<string, unknown> }).versions?.[version]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `npm dist-tag add <name>@<version> <tag>`: points the tag at an already published version. */
|
|
20
|
+
export async function npmDistTag(
|
|
21
|
+
ctx: Pick<Ctx, "http" | "log">,
|
|
22
|
+
o: { name: string; version: string; tag: string; token: string; registry?: string },
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const url = `${o.registry ?? NPM_REGISTRY}/-/package/${packagePath(o.name)}/dist-tags/${encodeURIComponent(o.tag)}`;
|
|
25
|
+
const res = await ctx.http.fetch(url, {
|
|
26
|
+
method: "PUT",
|
|
27
|
+
headers: { authorization: `Bearer ${o.token}`, "content-type": "application/json" },
|
|
28
|
+
body: JSON.stringify(o.version),
|
|
29
|
+
});
|
|
30
|
+
await res.body?.cancel().catch(() => {});
|
|
31
|
+
if (!res.ok) throw new Error(`npm dist-tag ${o.name}@${o.version} ${o.tag} failed: HTTP ${res.status}`);
|
|
32
|
+
ctx.log("npm dist-tag", { package: `${o.name}@${o.version}`, tag: o.tag });
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// sshSession — run commands on a host over SSH from a handler (alpine image: installs openssh-client).
|
|
2
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Ctx } from "../ctx/types.ts";
|
|
5
|
+
|
|
6
|
+
const SAFE = /^[A-Za-z0-9_\/.:=@%+,-]+$/;
|
|
7
|
+
/** POSIX shell quoting (safe words are left bare, so logs stay readable). */
|
|
8
|
+
export function shq(s: string): string {
|
|
9
|
+
return SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SshSession {
|
|
13
|
+
readonly host: string;
|
|
14
|
+
/** Runs a remote command; a non-zero exit throws. */
|
|
15
|
+
run(remote: string): Promise<void>;
|
|
16
|
+
/** Runs a remote command and returns its stdout. */
|
|
17
|
+
text(remote: string): Promise<string>;
|
|
18
|
+
/** `scp -r` a local file or directory to a remote path. */
|
|
19
|
+
copy(local: string, remote: string): Promise<void>;
|
|
20
|
+
/** The ssh invocation as a shell command, for pipes and redirects: `ctx.sh(`${s.shell("cat > f")} < ${file}`)`. */
|
|
21
|
+
shell(remote: string): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Opens an SSH "session" to `host` (`user@host`) with a private key: installs openssh-client (apk),
|
|
26
|
+
* writes the key to `<tmpDir>/ssh/id`. The host key is not verified: hosts are reached over the tailnet.
|
|
27
|
+
*/
|
|
28
|
+
export async function sshSession(ctx: Pick<Ctx, "exec" | "sh" | "tmpDir">, opts: { host: string; key: string }): Promise<SshSession> {
|
|
29
|
+
await ctx.exec(["apk", "add", "--no-cache", "openssh-client"]);
|
|
30
|
+
const dir = join(ctx.tmpDir, "ssh");
|
|
31
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
const keyFile = join(dir, "id");
|
|
33
|
+
// OpenSSH rejects a key without a trailing newline.
|
|
34
|
+
writeFileSync(keyFile, opts.key.endsWith("\n") ? opts.key : opts.key + "\n", { mode: 0o600 });
|
|
35
|
+
chmodSync(keyFile, 0o600);
|
|
36
|
+
const sshOpts = ["-i", keyFile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "BatchMode=yes"];
|
|
37
|
+
const argv = (remote: string) => ["ssh", ...sshOpts, opts.host, remote];
|
|
38
|
+
return {
|
|
39
|
+
host: opts.host,
|
|
40
|
+
run: (remote) => ctx.exec(argv(remote)),
|
|
41
|
+
text: (remote) => ctx.sh.text(argv(remote).map(shq).join(" ")),
|
|
42
|
+
copy: (local, remote) => ctx.exec(["scp", ...sshOpts, "-r", local, `${opts.host}:${remote}`]),
|
|
43
|
+
shell: (remote) => argv(remote).map(shq).join(" "),
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// @
|
|
1
|
+
// @olivervea/pipelines/testing — run a handler under `bun test` through the real harness path
|
|
2
2
|
// (validate input → handler → strict output validation), with a real ctx you can override.
|
|
3
3
|
import { mkdtempSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { isImplementation, type Implementation } from "../core/handlers.ts";
|
|
7
7
|
import type { FileRef } from "../core/models.ts";
|
|
8
|
-
import { isStep, type AnyStep, type LibraryStep, type OutputValue, type SecretsOf, type StepInput } from "../core/steps.ts";
|
|
8
|
+
import { isStep, type AnyStep, type ConfigOf, type LibraryStep, type OutputValue, type SecretsOf, type StepInput } from "../core/steps.ts";
|
|
9
9
|
import { createCtx } from "../ctx/index.ts";
|
|
10
10
|
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
11
11
|
import { executeStep, type FailureKind, type StepFunction } from "../harness/execute.ts";
|
|
@@ -13,7 +13,9 @@ import { sha256Hex } from "../util/hash.ts";
|
|
|
13
13
|
|
|
14
14
|
export interface TestHandlerOptions<S extends AnyStep> {
|
|
15
15
|
/** Replace parts of ctx (e.g. `sh`, `http`, `files`). The rest is the real implementation. */
|
|
16
|
-
ctx?: Partial<Ctx<SecretsOf<S>>>;
|
|
16
|
+
ctx?: Partial<Ctx<SecretsOf<S>, ConfigOf<S>>>;
|
|
17
|
+
/** The step's config entries (z.input view: defaults are applied), like `$config` in input.json. */
|
|
18
|
+
config?: Record<string, unknown>;
|
|
17
19
|
/** Secret values (instead of reading the environment). */
|
|
18
20
|
secrets?: Partial<Record<SecretsOf<S>, string>>;
|
|
19
21
|
run?: Partial<Omit<RunInfo, "step">>;
|
|
@@ -69,7 +71,7 @@ export async function testHandler<S extends AnyStep>(
|
|
|
69
71
|
});
|
|
70
72
|
const ctx = { ...real, ...opts.ctx } as Ctx;
|
|
71
73
|
// Round-trip through JSON like input.json does (drops undefined, rejects non-JSON values early).
|
|
72
|
-
const raw = JSON.parse(JSON.stringify(input
|
|
74
|
+
const raw = JSON.parse(JSON.stringify({ ...input, ...(opts.config ? { $config: opts.config } : {}) }));
|
|
73
75
|
const exec = await executeStep({ step, fn, rawInput: raw, ctx, workFiles: filesDir, log: (l) => logs.push(l) });
|
|
74
76
|
return { dir, filesDir, logs, exitCode: exec.exitCode, ...exec.envelope } as TestResult<S>;
|
|
75
77
|
}
|
package/src/util/handler-hash.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { sha256Hex } from "./hash.ts";
|
|
|
5
5
|
export interface HarnessInfo {
|
|
6
6
|
/** Bun version (runtime compiled into the binary). */
|
|
7
7
|
bun: string;
|
|
8
|
-
/** @
|
|
8
|
+
/** @olivervea/pipelines package version. */
|
|
9
9
|
sdk: string;
|
|
10
10
|
/** `sha256:` hash over the SDK runtime sources and the zod version. */
|
|
11
11
|
sdkHash: string;
|