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

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.
@@ -6,12 +6,13 @@ import { join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import type { Ctx } from "../ctx/types.ts";
8
8
  import type { Configurable } from "../core/config.ts";
9
- import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
9
+ import { resolveConfigRefs } from "../core/config-refs.ts";
10
+ import { implement, type Implementation } from "../core/handlers.ts";
10
11
  import type { FileRef } from "../core/models.ts";
11
12
  import { optional } from "../core/models.ts";
12
13
  import type { Secret } from "../core/secrets.ts";
13
14
  import { secretRef } from "../core/config.ts";
14
- import { makeStep, type LibraryStep, type LiteralName, type Step, type StepKind } from "../core/steps.ts";
15
+ import { step, type AnyStep, type LiteralName, type Step } from "../core/steps.ts";
15
16
  import type { Simplify } from "../core/types.ts";
16
17
  import { serviceUrl } from "./checks.ts";
17
18
  import { chartDirectory, deployOverSsh, DEFAULT_ROLLOUT_TIMEOUT_SECONDS, HELM_DEPLOY_IMAGE, helmFlags, type HelmImageFlags, type HelmImageRefFlags } from "./helm-deploy-ssh.ts";
@@ -117,7 +118,8 @@ export interface HelmTargetDeployDef<N extends string, Im extends Record<string,
117
118
  inlineValues?: readonly Readonly<Record<string, unknown>>[];
118
119
  /**
119
120
  * The images to deploy: step input key → the chart's values prefix. Each input is a `PushedImage`;
120
- * the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`.
121
+ * the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`. `{}` deploys
122
+ * the chart alone.
121
123
  */
122
124
  images?: Im & { readonly source?: never };
123
125
  /**
@@ -144,7 +146,7 @@ export interface HelmDeploySshTargetDef<N extends string, K extends string, Im e
144
146
  target: Configurable<SshTargetValue>;
145
147
  /** Private key for the target's host. */
146
148
  sshKey: Secret<K>;
147
- /** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name. */
149
+ /** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name, or none without images. */
148
150
  deployments?: readonly string[];
149
151
  }
150
152
 
@@ -157,12 +159,12 @@ export function helmDeployInCluster<
157
159
  const N extends string,
158
160
  const Im extends Record<string, string> = DefaultImages,
159
161
  const Rf extends Record<string, string> = {},
160
- >(def: HelmDeployInClusterDef<N, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never> & LibraryStep {
162
+ >(def: HelmDeployInClusterDef<N, Im, Rf>): Implementation<Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never>> {
161
163
  return targetDeploy("helmDeployInCluster", def as unknown as AnyTargetDef, undefined) as never;
162
164
  }
163
165
 
164
166
  /** The `target` form of `steps.helmDeployOverSsh`: an ssh target from config, registry images by digest. */
165
- export function helmDeployOverSshTarget(def: HelmDeploySshTargetDef<string, string, Record<string, string>, Record<string, string>>): Step & LibraryStep {
167
+ export function helmDeployOverSshTarget(def: HelmDeploySshTargetDef<string, string, Record<string, string>, Record<string, string>>): Implementation {
166
168
  return targetDeploy("helmDeployOverSsh", def as unknown as AnyTargetDef, def.sshKey, def.deployments) as never;
167
169
  }
168
170
 
@@ -179,13 +181,12 @@ interface AnyTargetDef {
179
181
  timeoutSeconds?: number;
180
182
  }
181
183
 
182
- function targetDeploy(kind: StepKind, def: AnyTargetDef, sshKey: Secret | undefined, deployments?: readonly string[]): Step & LibraryStep {
184
+ function targetDeploy(kind: "helmDeployInCluster" | "helmDeployOverSsh", def: AnyTargetDef, sshKey: Secret | undefined, deployments?: readonly string[]): Implementation {
183
185
  const images: Record<string, string> = { ...(def.images ?? { image: "image" }) };
184
186
  const imageEntries = Object.entries(images);
185
187
  const imageRefs: Record<string, string> = { ...(def.imageRefs ?? {}) };
186
188
  const refEntries = Object.entries(imageRefs);
187
189
  const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
188
- if (imageEntries.length === 0) throw new Error(`${kind} '${def.name}': images must not be empty`);
189
190
  if ("source" in images || "source" in imageRefs) throw new Error(`${kind} '${def.name}': 'source' can't be an image input`);
190
191
  for (const [key] of refEntries) if (key in images) throw new Error(`${kind} '${def.name}': '${key}' is both in images and imageRefs`);
191
192
  if (deployments?.length === 0) throw new Error(`${kind} '${def.name}': deployments must not be empty`);
@@ -198,71 +199,73 @@ function targetDeploy(kind: StepKind, def: AnyTargetDef, sshKey: Secret | undefi
198
199
  };
199
200
  const configurable = { target: def.target, inlineValues: def.inlineValues ?? [] };
200
201
 
201
- return makeStep(
202
+ const contract = step({
203
+ name: def.name,
204
+ description: def.description,
202
205
  kind,
203
- {
204
- name: def.name, description: def.description, image: inCluster ? HELM_IMAGE : HELM_DEPLOY_IMAGE, input, output: HelmRelease,
205
- secrets: sshKey ? [sshKey] : [], timeoutSeconds: def.timeoutSeconds, ...(inCluster ? { clusterAccess: true } : {}),
206
+ image: inCluster ? HELM_IMAGE : HELM_DEPLOY_IMAGE,
207
+ input,
208
+ output: HelmRelease,
209
+ secrets: sshKey ? [sshKey] : [],
210
+ timeoutSeconds: def.timeoutSeconds,
211
+ ...(inCluster ? { clusterAccess: true } : {}),
212
+ settings: {
213
+ target: def.target,
214
+ ...(sshKey ? { sshKey: sshKey.name } : {}),
215
+ chart: def.chart,
216
+ ...(def.inlineValues?.length ? { inlineValues: def.inlineValues } : {}),
217
+ images: { ...images },
218
+ ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
219
+ ...(deployments ? { deployments: [...deployments] } : {}),
220
+ ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
221
+ ...(def.healthPath !== undefined ? { healthPath: def.healthPath } : {}),
206
222
  },
207
- {
208
- manifestConfig: () => ({
209
- target: recordConfigRefs(def.target),
210
- ...(sshKey ? { sshKey: sshKey.name } : {}),
211
- chart: def.chart,
212
- ...(def.inlineValues?.length ? { inlineValues: recordConfigRefs(def.inlineValues) } : {}),
213
- images: { ...images },
214
- ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
215
- ...(deployments ? { deployments: [...deployments] } : {}),
216
- ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
217
- ...(def.healthPath !== undefined ? { healthPath: def.healthPath } : {}),
218
- }),
219
- async run(input: Record<string, { repository: string; tag: string; digest: string } | undefined> & { source: { tree: FileRef } }, ctx: Ctx) {
220
- const { target, inlineValues } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: HelmTargetValue; inlineValues: Record<string, unknown>[] };
221
- const want = inCluster ? "inCluster" : "ssh";
222
- if (target?.kind !== want) throw new Error(`${kind} '${def.name}': the target is kind '${String(target?.kind)}', not '${want}'`);
223
- const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
223
+ } as never) as AnyStep;
224
+ return implement(contract, async (stepInput, ctx: Ctx) => {
225
+ const input = stepInput as unknown as Record<string, { repository: string; tag: string; digest: string } | undefined> & { source: { tree: FileRef } };
226
+ const { target, inlineValues } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: HelmTargetValue; inlineValues: Record<string, unknown>[] };
227
+ const want = inCluster ? "inCluster" : "ssh";
228
+ if (target?.kind !== want) throw new Error(`${kind} '${def.name}': the target is kind '${String(target?.kind)}', not '${want}'`);
229
+ const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
224
230
 
225
- const generated = inlineValues.map((values, i) => {
226
- const file = `olve-values-${i}.json`;
227
- writeFileSync(join(chartDir, file), JSON.stringify(values));
228
- return file;
229
- });
230
- const flags: HelmImageFlags[] = imageEntries.map(([key, prefix]) => {
231
- const image = input[key]!;
232
- return { prefix, repository: image.repository, tag: image.tag, digest: image.digest, pullPolicy: "IfNotPresent" };
233
- });
234
- const refs: HelmImageRefFlags[] = refEntries.flatMap(([key, prefix]) => {
235
- const image = input[key];
236
- return image ? [{ prefix, repository: image.repository, tag: image.tag, digest: image.digest }] : [];
237
- });
238
- const helm = { values: [...target.values, ...generated], set: target.set, images: flags, imageRefs: refs };
239
- const { release, namespace } = target;
231
+ const generated = inlineValues.map((values, i) => {
232
+ const file = `olve-values-${i}.json`;
233
+ writeFileSync(join(chartDir, file), JSON.stringify(values));
234
+ return file;
235
+ });
236
+ const flags: HelmImageFlags[] = imageEntries.map(([key, prefix]) => {
237
+ const image = input[key]!;
238
+ return { prefix, repository: image.repository, tag: image.tag, digest: image.digest, pullPolicy: "IfNotPresent" };
239
+ });
240
+ const refs: HelmImageRefFlags[] = refEntries.flatMap(([key, prefix]) => {
241
+ const image = input[key];
242
+ return image ? [{ prefix, repository: image.repository, tag: image.tag, digest: image.digest }] : [];
243
+ });
244
+ const helm = { values: [...target.values, ...generated], set: target.set, images: flags, imageRefs: refs };
245
+ const { release, namespace } = target;
240
246
 
241
- if (target.kind === "ssh") {
242
- const key = ctx.secrets[sshKey!.name];
243
- if (!key) throw new Error(`secret ${sshKey!.name} (sshKey) is not set`);
244
- await deployOverSsh(ctx, {
245
- host: target.host, key, chartDir, release, namespace,
246
- hostImages: flags.map((f) => ({ repository: f.repository, tag: f.tag, digest: f.digest })),
247
- flags: helm, deployments: deployments ?? [release], rolloutTimeoutSeconds: rolloutTimeout,
248
- });
249
- } else {
250
- if (!existsSync(SERVICE_ACCOUNT_TOKEN))
251
- throw new Error(`no ServiceAccount token in the step pod: the instance grants no step ServiceAccount (Kubernetes:StepServiceAccount), so an inCluster target can't be deployed`);
252
- const home = join(ctx.tmpDir, "helm-home");
253
- ctx.log("helm upgrade", { release, namespace, images: flags.map((f) => `${f.repository}@${f.digest}`).join(" ") });
254
- await ctx.exec(["helm", "upgrade", "--install", release, ".", "-n", namespace, ...helmFlags(helm), "--wait", `--timeout=${rolloutTimeout}s`], {
255
- cwd: chartDir,
256
- env: { HOME: home, HELM_CACHE_HOME: join(home, "cache"), HELM_CONFIG_HOME: join(home, "config"), HELM_DATA_HOME: join(home, "data") },
257
- });
258
- }
247
+ if (target.kind === "ssh") {
248
+ const key = ctx.secrets[sshKey!.name];
249
+ if (!key) throw new Error(`secret ${sshKey!.name} (sshKey) is not set`);
250
+ await deployOverSsh(ctx, {
251
+ host: target.host, key, chartDir, release, namespace,
252
+ hostImages: flags.map((f) => ({ repository: f.repository, tag: f.tag, digest: f.digest })),
253
+ flags: helm, deployments: deployments ?? (imageEntries.length ? [release] : []), rolloutTimeoutSeconds: rolloutTimeout,
254
+ });
255
+ } else {
256
+ if (!existsSync(SERVICE_ACCOUNT_TOKEN))
257
+ throw new Error(`no ServiceAccount token in the step pod: the instance grants no step ServiceAccount (Kubernetes:StepServiceAccount), so an inCluster target can't be deployed`);
258
+ const home = join(ctx.tmpDir, "helm-home");
259
+ ctx.log("helm upgrade", { release, namespace, images: flags.map((f) => `${f.repository}@${f.digest}`).join(" ") });
260
+ await ctx.exec(["helm", "upgrade", "--install", release, ".", "-n", namespace, ...helmFlags(helm), "--wait", `--timeout=${rolloutTimeout}s`], {
261
+ cwd: chartDir,
262
+ env: { HOME: home, HELM_CACHE_HOME: join(home, "cache"), HELM_CONFIG_HOME: join(home, "config"), HELM_DATA_HOME: join(home, "data") },
263
+ });
264
+ }
259
265
 
260
- const url = (target.url ?? serviceUrl({ release, namespace })).replace(/\/+$/, "");
261
- if (def.healthPath !== undefined) await httpHealthCheck(ctx, `${url}${def.healthPath}`, { attempts: 12, delaySeconds: 5 });
262
- const main = flags[0]!;
263
- return { release, namespace, image: `${main.repository}:${main.tag}@${main.digest}`, ...(target.url ? { url } : {}) };
264
- },
265
- },
266
- configurable,
267
- ) as never;
266
+ const url = (target.url ?? serviceUrl({ release, namespace })).replace(/\/+$/, "");
267
+ if (def.healthPath !== undefined) await httpHealthCheck(ctx, `${url}${def.healthPath}`, { attempts: 12, delaySeconds: 5 });
268
+ const main = flags[0];
269
+ return { release, namespace, ...(main ? { image: `${main.repository}:${main.tag}@${main.digest}` } : {}), ...(target.url ? { url } : {}) } as never;
270
+ }) as never;
268
271
  }
@@ -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
  }