@olivervea/pipelines 2.0.0-beta.7 → 2.0.0-beta.9

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.
@@ -7,8 +7,9 @@ import type { Ctx, RunInfo } from "../ctx/types.ts";
7
7
  import type { FileRef } from "../core/models.ts";
8
8
  import type { Secret } from "../core/secrets.ts";
9
9
  import { isConfigRef, type ConfigRef, type Configurable } from "../core/config.ts";
10
- import { isLiteral, recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
11
- import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
10
+ import { isLiteral, resolveConfigRefs } from "../core/config-refs.ts";
11
+ import { implement, type Implementation } from "../core/handlers.ts";
12
+ import { step, type LiteralName, type Step } from "../core/steps.ts";
12
13
  import { sha256File } from "../util/hash.ts";
13
14
  import { dirEntries, writeTar } from "../util/tar.ts";
14
15
  import { BuiltImage, PushedImage, Source } from "./models.ts";
@@ -47,16 +48,8 @@ export interface ImageBuildDef<N extends string, K extends string = never> {
47
48
  * (`registry.ovea.pro/app/api`). The tag is the run's version (see `imageTag`) unless `contentTag`.
48
49
  */
49
50
  repository: Configurable<string>;
50
- /** Build context: a directory of the source tree (default: the root). */
51
+ /** Build context: a directory of the source tree with a `Dockerfile` (default: the root). */
51
52
  context?: string;
52
- /** Dockerfile path, relative to the context (default `Dockerfile`). */
53
- dockerfile?: string;
54
- /**
55
- * Registry repository for kaniko's layer cache (`--cache=true --cache-repo=…`), so unchanged
56
- * layers (restore, apt, npm, …) are reused. kaniko pushes cache layers even with `--no-push`.
57
- * A registry without GC grows with every changed layer — prune it when needed.
58
- */
59
- cacheRepo?: Configurable<string | undefined>;
60
53
  /**
61
54
  * kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
62
55
  * the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
@@ -71,15 +64,6 @@ export interface ImageBuildDef<N extends string, K extends string = never> {
71
64
  * the longest silent build phase (an AOT "Generating native code" runs ~2 min with no output).
72
65
  */
73
66
  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;
83
67
  /**
84
68
  * Automatic retries of a failed attempt (a fresh pod: kaniko unpacks the base image over the
85
69
  * container root and does not clean up when killed, so retrying inside the same pod is unsafe).
@@ -147,32 +131,22 @@ export async function registryUp(http: Ctx["http"], host: string, signal?: Abort
147
131
  }
148
132
  }
149
133
 
150
- /**
151
- * Drops the caches that don't answer, so a cache outage (GC, maintenance) never blocks a build: an
152
- * unreachable mirror is left out of the registry map, an unreachable `cacheRepo` means no layer cache.
153
- */
154
- export async function reachableCaches(
134
+ /** The registry map without the mirrors that don't answer, so a mirror outage (GC, maintenance) never blocks a build. */
135
+ export async function reachableMirrors(
155
136
  ctx: Pick<Ctx, "http" | "log" | "signal">,
156
- caches: { registryMap: Record<string, string>; cacheRepo?: string },
137
+ registryMap: Record<string, string>,
157
138
  insecureHosts: readonly string[] = [],
158
- ): Promise<{ registryMap: Record<string, string>; cacheRepo?: string }> {
159
- const hosts = new Set([...Object.values(caches.registryMap), ...(caches.cacheRepo ? [caches.cacheRepo] : [])].map(registryHost));
139
+ ): Promise<Record<string, string>> {
140
+ const hosts = new Set(Object.values(registryMap).map(registryHost));
160
141
  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)));
161
- const registryMap: Record<string, string> = {};
162
- for (const [from, to] of Object.entries(caches.registryMap)) {
163
- if (up.get(registryHost(to))) registryMap[from] = to;
142
+ const reachable: Record<string, string> = {};
143
+ for (const [from, to] of Object.entries(registryMap)) {
144
+ if (up.get(registryHost(to))) reachable[from] = to;
164
145
  else ctx.log(`${registryHost(to)} unreachable; pulling from ${from} directly`);
165
146
  }
166
- if (caches.cacheRepo && !up.get(registryHost(caches.cacheRepo))) {
167
- ctx.log(`${registryHost(caches.cacheRepo)} unreachable; building without the layer cache`);
168
- return { registryMap };
169
- }
170
- return { registryMap, ...(caches.cacheRepo ? { cacheRepo: caches.cacheRepo } : {}) };
147
+ return reachable;
171
148
  }
172
149
 
173
- /** Default kaniko `--snapshot-mode`. */
174
- export const DEFAULT_SNAPSHOT_MODE = "redo";
175
-
176
150
  /** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
177
151
  export const DEFAULT_RETRIES = 2;
178
152
 
@@ -314,124 +288,115 @@ export function writeDockerConfig(dir: string, host: string, username: string, p
314
288
  }
315
289
 
316
290
  /**
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 }`.
291
+ * Builds an image with kaniko from `<context>/Dockerfile` in the source tree (default: the root). Default: into an image tarball, output `BuiltImage { repository, tag, digest, tar }`.
319
292
  * With `push: true`: pushed to `repository`, output `PushedImage { repository, tag, digest }`.
320
293
  */
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;
294
+ export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K>): Implementation<Step<N, ImageBuildInput, typeof BuiltImage, K>>;
295
+ export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildPushDef<N, K>): Implementation<Step<N, ImageBuildInput, typeof PushedImage, K>>;
323
296
  export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K> | ImageBuildPushDef<N, K>): AnyImageBuild {
324
297
  const push = def.push === true;
325
298
  const contentTag = push && def.contentTag === true;
326
299
  const context = checkRelative("context", def.context ?? "");
327
- const dockerfile = checkRelative("dockerfile", def.dockerfile ?? "Dockerfile");
328
300
  const authSecret = def.registryAuth && !isConfigRef(def.registryAuth) ? def.registryAuth : undefined;
329
301
  if (isLiteral([def.repository, def.registryAuth, def.insecureRegistry])) checkRegistryHost(def.name, def.repository as string, push, def.registryAuth !== undefined, def.insecureRegistry === true);
330
302
  const secrets = [...(authSecret ? [authSecret.password] : []), ...(def.secrets ?? [])].filter((s, i, all) => all.findIndex((x) => x.name === s.name) === i);
331
- return makeStep(
332
- "imageBuild",
333
- { name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: push ? PushedImage : BuiltImage, secrets, timeoutSeconds: def.timeoutSeconds, retries: def.retries ?? DEFAULT_RETRIES },
334
- {
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
- },
355
- async run({ source }: { source: { tree: FileRef } }, ctx: Ctx) {
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
-
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] : []);
386
- await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
387
- await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
388
- const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
389
-
390
- // Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
391
- // kaniko images have no /tmp), then into the step's files.
392
- mkdirSync(ctx.tmpDir, { recursive: true });
393
- const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
394
- const tarPath = join(out, "image.tar");
395
- const digestPath = join(out, "digest");
396
- const output = push ? [] : ["--no-push", `--tar-path=${tarPath}`];
397
- // No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
398
- // the build context intact. --skip-push-permission-check: with --no-push kaniko otherwise checks
399
- // the cache repo at startup and exits if it is down; later cache lookup and push errors are
400
- // only warnings.
401
- await ctx.exec([
402
- "/kaniko/executor",
403
- `--context=${kanikoContext}`,
404
- `--dockerfile=${kanikoContext}/${dockerfile}`,
405
- ...output,
406
- `--digest-file=${digestPath}`,
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}`,
412
- ...(caches.cacheRepo ? ["--cache=true", `--cache-repo=${caches.cacheRepo}`, "--skip-push-permission-check"] : []),
413
- ...Object.entries(caches.registryMap).map(([from, to]) => `--registry-map=${from}=${to}`),
414
- ], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
415
-
416
- let digest: string;
417
- try {
418
- digest = readFileSync(digestPath, "utf8").trim();
419
- } catch {
420
- throw new Error(`kaniko did not write the image digest to ${digestPath}`);
421
- }
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
- }
427
- const tar = await ctx.output.file(tarPath, { name: "image.tar" });
428
- rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
429
- ctx.log("image built", { image: `${repository}:${tag}`, digest });
430
- return { repository: qualifyRepository(repository), tag, digest, tar };
431
- },
303
+ const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
304
+ const mapped = isConfigRef(registryMap) || Object.keys(registryMap).length > 0;
305
+ const contract = step({
306
+ name: def.name,
307
+ description: def.description,
308
+ kind: "imageBuild",
309
+ image: KANIKO_IMAGE,
310
+ input,
311
+ output: push ? PushedImage : BuiltImage,
312
+ secrets,
313
+ timeoutSeconds: def.timeoutSeconds,
314
+ retries: def.retries ?? DEFAULT_RETRIES,
315
+ settings: {
316
+ repository: def.repository,
317
+ ...(context ? { context } : {}),
318
+ ...(push ? { push: true } : {}),
319
+ ...(contentTag ? { contentTag: true } : {}),
320
+ ...(mapped ? { registryMap } : {}),
321
+ ...(authSecret ? { registryAuth: { username: authSecret.username, password: authSecret.password.name } } : {}),
322
+ ...(def.registryAuth && isConfigRef(def.registryAuth) ? { registryAuth: def.registryAuth } : {}),
323
+ ...(def.insecureRegistry !== undefined && def.insecureRegistry !== false ? { insecureRegistry: def.insecureRegistry } : {}),
324
+ ...(def.enabled !== undefined ? { enabled: def.enabled } : {}),
432
325
  },
433
- def,
434
- ) as never;
326
+ });
327
+ return implement(contract, async ({ source }, ctx: Ctx) => {
328
+ const d = resolveConfigRefs({
329
+ repository: def.repository, registryMap: def.registryMap, insecureRegistry: def.insecureRegistry,
330
+ registryAuth: def.registryAuth, enabled: def.enabled,
331
+ }, ctx.config ?? {});
332
+ const repository = d.repository as string;
333
+ if (d.enabled === false) return ctx.skip(`publishing ${repository} is turned off`);
334
+ const insecure = d.insecureRegistry === true;
335
+ const auth = registryLogin(d.registryAuth as RegistryAuth<string> | RegistryAuthConfig | undefined, ctx, secrets.map((s) => s.name));
336
+ checkRegistryHost(def.name, repository, push, auth !== undefined, insecure);
337
+ const host = registryHost(repository);
338
+
339
+ const tree = await ctx.files.fetch(source.tree);
340
+ const contextDir = context ? join(tree, context) : tree;
341
+ if (!existsSync(contextDir) || !statSync(contextDir).isDirectory()) throw new Error(`build context '${context}' not found in the source tree`);
342
+ if (!existsSync(join(contextDir, "Dockerfile"))) throw new Error(`Dockerfile not found in the build context${context ? ` '${context}'` : ""}`);
343
+
344
+ const access: RegistryAccess = { insecure, ...(auth ?? {}) };
345
+ if (auth) writeDockerConfig(process.env.DOCKER_CONFIG || KANIKO_DOCKER_CONFIG, host, auth.username, auth.password);
346
+
347
+ const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
348
+ if (contentTag) {
349
+ const digest = await registryDigest(ctx.http, repository, tag, access);
350
+ if (digest) {
351
+ ctx.log("image already in the registry", { image: `${repository}:${tag}`, digest });
352
+ return { repository, tag, digest };
353
+ }
354
+ }
355
+
356
+ const mirrors = await reachableMirrors(ctx, (d.registryMap as Record<string, string> | undefined) ?? DEFAULT_REGISTRY_MAP, insecure ? [host] : []);
357
+ await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
358
+ await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
359
+ const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
360
+
361
+ // Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
362
+ // kaniko images have no /tmp), then into the step's files.
363
+ mkdirSync(ctx.tmpDir, { recursive: true });
364
+ const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
365
+ const tarPath = join(out, "image.tar");
366
+ const digestPath = join(out, "digest");
367
+ const output = push ? [] : ["--no-push", `--tar-path=${tarPath}`];
368
+ // No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
369
+ // the build context intact.
370
+ await ctx.exec([
371
+ "/kaniko/executor",
372
+ `--context=${kanikoContext}`,
373
+ `--dockerfile=${kanikoContext}/Dockerfile`,
374
+ ...output,
375
+ `--digest-file=${digestPath}`,
376
+ `--destination=${repository}:${tag}`,
377
+ ...(insecure ? ["--insecure", `--insecure-registry=${host}`] : []),
378
+ "--snapshot-mode=redo",
379
+ "--use-new-run",
380
+ "--compressed-caching=false",
381
+ ...Object.entries(mirrors).map(([from, to]) => `--registry-map=${from}=${to}`),
382
+ ], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
383
+
384
+ let digest: string;
385
+ try {
386
+ digest = readFileSync(digestPath, "utf8").trim();
387
+ } catch {
388
+ throw new Error(`kaniko did not write the image digest to ${digestPath}`);
389
+ }
390
+ if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
391
+ if (push) {
392
+ ctx.log("image pushed", { image: `${repository}:${tag}`, digest });
393
+ return { repository, tag, digest };
394
+ }
395
+ const tar = await ctx.output.file(tarPath, { name: "image.tar" });
396
+ rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
397
+ ctx.log("image built", { image: `${repository}:${tag}`, digest });
398
+ return { repository: qualifyRepository(repository), tag, digest, tar };
399
+ }) as never;
435
400
  }
436
401
 
437
402
  function checkRegistryHost(name: string, repository: string, push: boolean, auth: boolean, insecure: boolean): void {
@@ -450,25 +415,4 @@ function registryLogin(auth: RegistryAuth<string> | RegistryAuthConfig | undefin
450
415
  return { username: auth.username, password };
451
416
  }
452
417
 
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;
418
+ type AnyImageBuild = Implementation<Step<string, ImageBuildInput, typeof BuiltImage | typeof PushedImage, any>>;
@@ -3,19 +3,16 @@ import { composeDeploy } from "./compose-deploy.ts";
3
3
  import { helmDeployInCluster } from "./helm-deploy.ts";
4
4
  import { helmDeployOverSsh } from "./helm-deploy-ssh.ts";
5
5
  import { imageBuild } from "./image-build.ts";
6
- import { script } from "./script.ts";
7
6
 
8
7
  export { Source, BuiltImage, PushedImage, HelmRelease, FailureContext } from "./models.ts";
9
- export { inputEnvName, type RunValue, type ScriptDef } from "./script.ts";
10
8
  export type { GitHubSourceDef } from "./github-source.ts";
11
- export { registryTag, registryDigest, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type RegistryAuthConfig, type RegistryAccess } from "./image-build.ts";
9
+ export type { ImageBuildDef, ImageBuildTarballDef, ImageBuildPushDef, RegistryAuth, RegistryAuthConfig } from "./image-build.ts";
12
10
  export type { HelmDeployOverSshDef } from "./helm-deploy-ssh.ts";
13
11
  export { type ComposeDeployDef, type ComposeTargetValue, type ComposeDeployResult } from "./compose-deploy.ts";
14
12
  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";
13
+ export { sshSession, type SshSession } from "./ssh.ts";
16
14
  export { serviceUrl, checkSpa } from "./checks.ts";
17
- export { httpHealthCheck, HTTP_HEALTH_DEFAULTS, type HttpHealthCheckOptions } from "./http-health.ts";
18
- export { npmDistTag, npmHasVersion, NPM_REGISTRY } from "./npm.ts";
15
+ export { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
19
16
 
20
- /** SDK-provided steps (step + handler pairs with typed config). */
21
- export const steps = { script, gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy };
17
+ /** SDK-provided steps: each is `implement(step(…), …)` with typed settings, staged directly. */
18
+ export const steps = { gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy };
@@ -29,7 +29,8 @@ export const HelmRelease = model(
29
29
  z.object({
30
30
  release: z.string(),
31
31
  namespace: z.string(),
32
- image: z.string(),
32
+ /** The release's main image; absent for a chart deployed without images. */
33
+ image: z.string().optional(),
33
34
  /** Where step pods reach the release (the target's `url`); absent = its in-cluster Service URL (`serviceUrl`). */
34
35
  url: z.string().optional(),
35
36
  }),
@@ -5,7 +5,7 @@ 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 ConfigOf, type LibraryStep, type OutputValue, type SecretsOf, type StepInput } from "../core/steps.ts";
8
+ import type { AnyStep, ConfigOf, OutputValue, SecretsOf, 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";
@@ -38,24 +38,18 @@ export type TestResult<S extends AnyStep> = {
38
38
  );
39
39
 
40
40
  /**
41
- * Runs a handler (the default export of `handlers/<step>.ts`) or a library step with `input`.
41
+ * Runs an implementation (a handler module's default export, or a step that carries its own) with `input`.
42
42
  * Input is validated like `input.json`, the result like a real run: an invalid output gives
43
43
  * `{ status: "failed", error: { kind: "output" } }`, a throwing handler `kind: "handler"`.
44
44
  */
45
45
  export async function testHandler<S extends AnyStep>(
46
- target: Implementation<S> | (S & LibraryStep),
46
+ target: Implementation<S>,
47
47
  input: StepInput<S["input"]>,
48
48
  opts: TestHandlerOptions<S> = {},
49
49
  ): Promise<TestResult<S>> {
50
- let step: AnyStep;
51
- let fn: StepFunction;
52
- if (isImplementation(target)) {
53
- step = target.step;
54
- fn = target.handler as unknown as StepFunction;
55
- } else if (isStep(target) && target.builtin) {
56
- step = target;
57
- fn = target.builtin.run as StepFunction;
58
- } else throw new TypeError("testHandler expects implement(...) (a handler module's default export) or a library step");
50
+ if (!isImplementation(target)) throw new TypeError("testHandler expects implement(…): a handler module's default export or a step that carries its implementation");
51
+ const step: AnyStep = target.step;
52
+ const fn = target.handler as unknown as StepFunction;
59
53
 
60
54
  const dir = opts.dir ?? mkdtempSync(join(tmpdir(), `olve-test-${step.name}-`));
61
55
  const filesDir = join(dir, "work", "files");
@@ -16,7 +16,7 @@ export function composeHandlerHash(bundleHash: string, harness: HarnessInfo): st
16
16
  return "sha256:" + sha256Hex(canonicalJson({ bundle: bundleHash, harness }));
17
17
  }
18
18
 
19
- /** Library step: kind + canonical config + harness. */
20
- export function libraryHandlerHash(kind: string, config: unknown, harness: HarnessInfo): string {
21
- return "sha256:" + sha256Hex(canonicalJson({ library: kind, config, harness }));
19
+ /** A step that carries its implementation: kind + canonical settings + harness, and the hash of the code that implements the kind. */
20
+ export function libraryHandlerHash(kind: string, config: unknown, harness: HarnessInfo, code?: string): string {
21
+ return "sha256:" + sha256Hex(canonicalJson({ library: kind, config, harness, ...(code ? { code } : {}) }));
22
22
  }
@@ -1,33 +0,0 @@
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
- }
@@ -1,85 +0,0 @@
1
- import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import type { Ctx, RunInfo } from "../ctx/types.ts";
4
- import type { AnyModel } from "../core/models.ts";
5
- import type { Secret } from "../core/secrets.ts";
6
- import { makeStep, type InputSpec, type LibraryStep, type Step, type StepDef, type StepInput } from "../core/steps.ts";
7
- import { sha256Hex } from "../util/hash.ts";
8
-
9
- /** A config value that may depend on the run: a constant or a function of the step's inputs and run info. */
10
- export type RunValue<I, T = string> = T | ((input: StepInput<I>, run: RunInfo) => T);
11
-
12
- export interface ScriptDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[]>
13
- extends StepDef<N, I, O, Sec> {
14
- /** An embedded script: `import s from "./scripts/x.sh" with { type: "file" }`. */
15
- script: string;
16
- /** Extra environment; values may be functions of the inputs (evaluated in the step, not at describe). */
17
- env?: Record<string, RunValue<I>>;
18
- }
19
-
20
- /** `deployTo` → `INPUT_DEPLOY_TO`. */
21
- export function inputEnvName(key: string): string {
22
- return "INPUT_" + key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
23
- }
24
-
25
- /**
26
- * Runs a shell script (the zero-TypeScript path). Contract for the script:
27
- * - `$OLVE_INPUT`: path of the validated input as JSON; each input key is also `INPUT_<KEY>`
28
- * (strings raw, everything else JSON).
29
- * - `$OLVE_OUTPUT`: if the step has an output model, write the output JSON there. Objects of the
30
- * form `{"$file": "<path>"}` / `{"$dir": "<path>"}` are turned into file references.
31
- * - Declared secrets are in the environment under their names.
32
- */
33
- export function script<
34
- const N extends string,
35
- const I extends InputSpec = {},
36
- O extends AnyModel | null = null,
37
- const Sec extends readonly Secret[] = [],
38
- >(def: ScriptDef<N, I, O, Sec>): Step<N, I, O, Sec[number]["name"]> & LibraryStep {
39
- const env = def.env ?? {};
40
- return makeStep("script", def as never, {
41
- manifestConfig: () => ({
42
- scriptSha256: "sha256:" + sha256Hex(readFileSync(def.script)),
43
- env: Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v === "string")),
44
- computedEnv: Object.keys(env).filter((k) => typeof env[k] === "function").sort(),
45
- }),
46
- async run(input: Record<string, unknown>, ctx: Ctx) {
47
- mkdirSync(ctx.tmpDir, { recursive: true });
48
- const dir = mkdtempSync(join(ctx.tmpDir, "script-"));
49
- const inputPath = join(dir, "input.json");
50
- const outputPath = join(dir, "output.json");
51
- writeFileSync(inputPath, JSON.stringify(input));
52
- const vars: Record<string, string> = { OLVE_INPUT: inputPath };
53
- for (const [k, v] of Object.entries(input)) {
54
- if (v !== undefined) vars[inputEnvName(k)] = typeof v === "string" ? v : JSON.stringify(v);
55
- }
56
- for (const [k, v] of Object.entries(env)) {
57
- vars[k] = typeof v === "function" ? (v as (i: unknown, r: RunInfo) => string)(input, ctx.run) : v;
58
- }
59
- if (def.output) vars.OLVE_OUTPUT = outputPath;
60
- await ctx.sh({ script: def.script }, { env: vars });
61
- if (!def.output) return undefined;
62
- let raw: unknown;
63
- try {
64
- raw = JSON.parse(readFileSync(outputPath, "utf8"));
65
- } catch (e) {
66
- throw new Error(`script did not write valid JSON to $OLVE_OUTPUT: ${(e as Error).message}`);
67
- }
68
- return resolveFilePlaceholders(raw, ctx);
69
- },
70
- }) as never;
71
- }
72
-
73
- async function resolveFilePlaceholders(v: unknown, ctx: Ctx): Promise<unknown> {
74
- if (Array.isArray(v)) return Promise.all(v.map((x) => resolveFilePlaceholders(x, ctx)));
75
- if (typeof v === "object" && v !== null) {
76
- const o = v as Record<string, unknown>;
77
- const keys = Object.keys(o);
78
- if (keys.length === 1 && typeof o.$file === "string") return ctx.output.file(o.$file);
79
- if (keys.length === 1 && typeof o.$dir === "string") return ctx.output.dir(o.$dir);
80
- const out: Record<string, unknown> = {};
81
- for (const k of keys) out[k] = await resolveFilePlaceholders(o[k], ctx);
82
- return out;
83
- }
84
- return v;
85
- }