@olivervea/pipelines 2.0.0-beta.6 → 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.
@@ -7,6 +7,8 @@ import { configRefPath, configRefTarget, isConfigRef, unionAt } from "../core/co
7
7
  import { isModel, isOptional, MODEL_NAME, type AnyModel } from "../core/models.ts";
8
8
  import { SECRET_NAME, type Secret } from "../core/secrets.ts";
9
9
  import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from "../core/steps.ts";
10
+ import { isImplementation } from "../core/handlers.ts";
11
+ import { recordConfigRefs } from "../core/config-refs.ts";
10
12
  import { pipelineData, type MapperFunction, type StageNode, type StageStepNode } from "../builder/pipeline.ts";
11
13
  import { FailureContext } from "../library/models.ts";
12
14
  import { canonicalize, canonicalJson } from "../util/canonical.ts";
@@ -27,6 +29,8 @@ export interface DescribeOptions {
27
29
  handlerNames: Iterable<string>;
28
30
  /** Step name → handler hash (from the build; includes the harness identity). */
29
31
  handlerHashes: Readonly<Record<string, string>>;
32
+ /** Kind → hash of the code that implements the steps of that kind which carry their implementation (from the build). */
33
+ kindHashes?: Readonly<Record<string, string>>;
30
34
  harness: HarnessInfo;
31
35
  }
32
36
 
@@ -163,8 +167,8 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
163
167
  const prev = placements.get(s.name) ?? [];
164
168
  if (prev.some((p) => !exclusive(p.cases, cases))) problems.push(`duplicate step name '${s.name}'`);
165
169
  else if (prev.length) {
166
- if (prev.some((p) => p.step !== s && p.step.kind === "custom" && s.kind === "custom"))
167
- problems.push(`step '${s.name}': two different custom steps share the name across select cases; handlers pair by name, so use one step or different names`);
170
+ if (prev.some((p) => p.step !== s && !isImplementation(p.step) && !isImplementation(s)))
171
+ problems.push(`step '${s.name}': two different steps with handler files share the name across select cases; handlers pair by name, so use one step or different names`);
168
172
  if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
169
173
  problems.push(`step '${s.name}': its select cases must give it the same output model`);
170
174
  }
@@ -233,14 +237,20 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
233
237
  if (key.startsWith("$")) problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
234
238
  const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
235
239
  const configNames = [...new Set((s.config ?? []).map((c) => useConfig(c, at)).filter((n): n is string => n !== undefined))].sort();
236
- // A library step that references parts of an entry needs the secrets those parts name; any other
237
- // step receiving an entry needs every secret the entry names.
238
- let refs: { ref: string; case?: string }[] = [];
239
- try {
240
- refs = s.builtin ? recordedConfigRefs(s.builtin.manifestConfig()) : [];
241
- } catch {
242
- // reported with the library configuration below
240
+ const carried = isImplementation(s);
241
+ let settings: Record<string, unknown> | undefined;
242
+ let settingsProblem: string | undefined;
243
+ if (s.settings !== undefined || carried) {
244
+ try {
245
+ settings = canonicalize(recordConfigRefs(s.settings ?? {})) as Record<string, unknown>;
246
+ } catch (e) {
247
+ settingsProblem = (e as Error).message;
248
+ settings = {};
249
+ }
243
250
  }
251
+ const refs = settings ? recordedConfigRefs(settings) : [];
252
+ // A step whose settings reference parts of an entry needs the secrets those parts name; any other
253
+ // step receiving an entry needs every secret the entry names.
244
254
  for (const name of configNames) {
245
255
  const into = refs.filter((r) => r.ref.split(".")[0] === name);
246
256
  const needed = into.length === 0
@@ -250,29 +260,24 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
250
260
  if (!secretNames.includes(ref)) problems.push(`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`);
251
261
  }
252
262
  let handlerHash: string;
253
- let config: Record<string, unknown> | undefined;
254
- if (s.kind === "custom") {
263
+ if (settingsProblem !== undefined) problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
264
+ if (typeof s.kind !== "string" || s.kind === "") problems.push(`${at}: kind must be a non-empty string`);
265
+ if (carried && s.kind === "custom") problems.push(`${at} carries its implementation, so it needs a kind (step({ kind: "…", … })); a step with kind custom has its handler in handlers/`);
266
+ for (const { ref, case: kase } of refs) {
267
+ const [entry, ...path] = ref.split(".") as [string, ...string[]];
268
+ if (kase !== undefined && !cases.some((c) => c.cond === kase))
269
+ problems.push(`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`);
270
+ if (!configNames.includes(entry)) problems.push(`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`);
271
+ else if (!resolveField(configs.get(entry)?.schema, path)) problems.push(`${at}: config reference '${ref}': config '${entry}' has no field '${path.join(".")}'`);
272
+ }
273
+ if (carried) {
274
+ const code = opts.kindHashes?.[s.kind];
275
+ if (opts.kindHashes && !code) problems.push(`${at}: no hash was computed for the code of kind '${s.kind}'`);
276
+ handlerHash = libraryHandlerHash(s.kind, settings, opts.harness, code);
277
+ } else {
255
278
  if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`);
256
279
  handlerHash = opts.handlerHashes[s.name] ?? "";
257
280
  if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
258
- } else if (s.builtin) {
259
- try {
260
- config = canonicalize(s.builtin.manifestConfig()) as Record<string, unknown>;
261
- } catch (e) {
262
- problems.push(`${at}: invalid ${s.kind} configuration (${(e as Error).message})`);
263
- config = {};
264
- }
265
- for (const { ref, case: kase } of recordedConfigRefs(config)) {
266
- const [entry, ...path] = ref.split(".") as [string, ...string[]];
267
- if (kase !== undefined && !cases.some((c) => c.cond === kase))
268
- problems.push(`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`);
269
- if (!configNames.includes(entry)) problems.push(`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`);
270
- else if (!resolveField(configs.get(entry)?.schema, path)) problems.push(`${at}: config reference '${ref}': config '${entry}' has no field '${path.join(".")}'`);
271
- }
272
- handlerHash = libraryHandlerHash(s.kind, config, opts.harness);
273
- } else {
274
- problems.push(`${at}: library step of kind '${s.kind}' has no implementation`);
275
- handlerHash = "";
276
281
  }
277
282
  if (typeof s.description !== "string") problems.push(`${at}: description must be a string (it may be empty)`);
278
283
  if (s.timeoutSeconds !== undefined && !(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)) problems.push(`${at}: timeoutSeconds must be a positive integer`);
@@ -290,7 +295,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
290
295
  handlerHash: handlerHash || "sha256:" + "0".repeat(64),
291
296
  ...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
292
297
  ...(s.retries !== undefined ? { retries: s.retries } : {}),
293
- ...(config !== undefined ? { config: config as Record<string, z.core.util.JSONType> } : {}),
298
+ ...(settings !== undefined ? { config: settings as Record<string, z.core.util.JSONType> } : {}),
294
299
  ...(configNames.length ? { configEntries: configNames } : {}),
295
300
  ...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
296
301
  };
@@ -432,7 +437,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
432
437
  for (const h of handlerNames) {
433
438
  const s = steps.get(h);
434
439
  if (!s) problems.push(`a handler implements step '${h}', which is not in the pipeline`);
435
- else if (s.kind !== "custom") problems.push(`a handler implements '${h}', a library step (${s.kind}) that carries its own implementation`);
440
+ else if (isImplementation(s)) problems.push(`a handler implements '${h}', a ${s.kind} step that carries its own implementation`);
436
441
  }
437
442
 
438
443
  // ── triggers ──
@@ -534,7 +539,7 @@ export function secretRefsOf(schema: unknown): string[] {
534
539
  return [...out].sort();
535
540
  }
536
541
 
537
- /** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a library step's recorded configuration. */
542
+ /** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
538
543
  export function recordedConfigRefs(v: unknown, out: { ref: string; case?: string }[] = []): { ref: string; case?: string }[] {
539
544
  if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
540
545
  else if (v !== null && typeof v === "object") {
@@ -51,19 +51,19 @@ export const StepDescriptor = z
51
51
  .strictObject({
52
52
  name: stepName,
53
53
  description: z.string().describe("What the step does, shown in the UI (may be empty)."),
54
- kind: z.string().min(1).describe("'custom' = handlers/<name>.ts; others are SDK library steps. A label: the service accepts any kind."),
54
+ kind: z.string().min(1).describe("'custom' = the handler is a handlers/*.ts file; any other kind is a step that carries its implementation (an SDK or package step). A label: the service accepts any kind."),
55
55
  image: z.string().min(1).describe("Container image the step runs in."),
56
56
  inputTypes: z.record(z.string(), InputType).describe("Declared input entries, keyed like input.json."),
57
57
  input: z.record(z.string(), Mapping).describe("How the service assembles input.json: one mapping per input key (same keys as inputTypes)."),
58
58
  output: modelName.nullable().describe("Output model, or null if the step produces no output."),
59
59
  secrets: z.array(secretName).describe("Declared secrets (sorted). Mount exactly these, as env vars of the same name."),
60
- handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for library steps kind + config + harness."),
60
+ handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for a step that carries its implementation kind + config + harness + the hash of the code of its kind."),
61
61
  timeoutSeconds: z.number().int().positive().optional(),
62
62
  retries: z.number().int().nonnegative().optional(),
63
63
  config: z
64
64
  .record(z.string(), Json)
65
65
  .optional()
66
- .describe("Library-step configuration (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries."),
66
+ .describe("The step's settings (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries."),
67
67
  configEntries: z
68
68
  .array(configName)
69
69
  .optional()
package/src/dev/cli.ts CHANGED
@@ -9,8 +9,8 @@ import { join, resolve } from "node:path";
9
9
  import { parseArgs } from "node:util";
10
10
  import { runHarness, type HarnessConfig } from "../harness/main.ts";
11
11
  import { EXIT } from "../harness/exit.ts";
12
- import { handlerHashes } from "../build/hashes.ts";
13
- import { harnessInfo, loadProject, ProjectError } from "../build/project.ts";
12
+ import { handlerHashes, kindHashes } from "../build/hashes.ts";
13
+ import { harnessInfo, loadPipeline, loadProject, ProjectError } from "../build/project.ts";
14
14
 
15
15
  const USAGE = `usage: olve-dev describe <dir> [--sentinels]
16
16
  olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
@@ -18,14 +18,14 @@ const USAGE = `usage: olve-dev describe <dir> [--sentinels]
18
18
 
19
19
  export async function devConfig(dir: string, withHashes: boolean): Promise<HarnessConfig> {
20
20
  const project = await loadProject(dir);
21
- const mod = (await import(project.pipelineFile)) as { default?: unknown };
22
- if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
21
+ const pipeline = await loadPipeline(project);
23
22
  const harness = harnessInfo();
24
23
  const hashes = withHashes ? await handlerHashes(project, harness) : {};
25
24
  return {
26
- pipeline: mod.default,
25
+ pipeline,
27
26
  handlers: Object.fromEntries(project.handlers.map(([name, file]) => [name, () => import(file) as Promise<{ default: unknown }>])),
28
27
  handlerHashes: Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])),
28
+ ...(withHashes ? { kindHashes: await kindHashes(project, pipeline) } : {}),
29
29
  harness,
30
30
  };
31
31
  }
@@ -26,6 +26,8 @@ export interface HarnessConfig {
26
26
  pipeline: unknown;
27
27
  handlers: HandlerRegistry;
28
28
  handlerHashes: Readonly<Record<string, string>>;
29
+ /** Kind → hash of the code of the steps of that kind that carry their implementation. */
30
+ kindHashes?: Readonly<Record<string, string>>;
29
31
  harness: HarnessInfo;
30
32
  }
31
33
 
@@ -100,12 +102,9 @@ export function cancellation(): { signal: AbortSignal; dispose(): void } {
100
102
  };
101
103
  }
102
104
 
103
- /** Resolves the function that implements `step` (library or handler module). */
105
+ /** Resolves the function that implements `step` (carried by the step, or a handler module). */
104
106
  export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
105
- if (step.kind !== "custom") {
106
- if (!step.builtin) return `library step '${step.name}' (${step.kind}) has no implementation`;
107
- return step.builtin.run as StepFunction;
108
- }
107
+ if (isImplementation(step)) return step.handler as unknown as StepFunction;
109
108
  const load = handlers[step.name];
110
109
  if (!load) return `step '${step.name}' has no handler`;
111
110
  let mod: { default: unknown };
@@ -151,7 +150,7 @@ function readJson(path: string): { ok: true; value: unknown } | { ok: false; mes
151
150
  async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
152
151
  const sentinels = args.includes("--sentinels");
153
152
  try {
154
- const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, harness: cfg.harness }));
153
+ const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, kindHashes: cfg.kindHashes, harness: cfg.harness }));
155
154
  await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
156
155
  return EXIT.ok;
157
156
  } catch (e) {
package/src/index.ts CHANGED
@@ -3,11 +3,13 @@
3
3
  export { z } from "zod";
4
4
  export { model, fileRef, optional, type Model, type AnyModel, type Infer, type InferInput, type Plain, type FileRef, type Optional } from "./core/models.ts";
5
5
  export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
6
- export { config, secretRef, type ConfigEntry, type AnyConfigEntry, type ConfigValues, type ConfigRef, type ConfigNode, type Configurable, type DiscriminatorOf, type VariantOf } from "./core/config.ts";
6
+ export { config, secretRef, isConfigRef, type ConfigEntry, type AnyConfigEntry, type ConfigValues, type ConfigRef, type ConfigNode, type Configurable, type DiscriminatorOf, type VariantOf } from "./core/config.ts";
7
7
  export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
8
- export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type ConfigOf, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
8
+ export { resolveConfigRefs, isLiteral, type Resolved } from "./core/config-refs.ts";
9
+ export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type ConfigOf, type InputEntry, type InputSpec, type LiteralName, type OutputValue } from "./core/steps.ts";
10
+ export type { Simplify } from "./core/types.ts";
9
11
  export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
10
12
  export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
11
13
  export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
12
14
  export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
13
- export { steps, Source, BuiltImage, PushedImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type HelmDeployOverSshDef, helmTarget, deployTarget, type ComposeTargetValue, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue, registryTag, registryDigest, type RegistryAuthConfig, type RegistryAccess, npmDistTag, npmHasVersion, httpHealthCheck, type HttpHealthCheckOptions, sshSession, shq, type SshSession, serviceUrl, checkSpa } from "./library/index.ts";
15
+ export { steps, Source, BuiltImage, PushedImage, HelmRelease, FailureContext, type GitHubSourceDef, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type RegistryAuthConfig, type HelmDeployOverSshDef, helmTarget, deployTarget, type DeployTargetOptions, type ComposeDeployDef, type ComposeTargetValue, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetDeployDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue, httpHealthCheck, type HttpHealthCheckOptions, sshSession, type SshSession, serviceUrl, checkSpa } from "./library/index.ts";
@@ -3,9 +3,10 @@
3
3
  // and is fixed there, behind a versioned HTTP API, without editing pipelines.
4
4
  import type { Ctx } from "../ctx/types.ts";
5
5
  import type { Configurable } from "../core/config.ts";
6
- import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
6
+ import { resolveConfigRefs } from "../core/config-refs.ts";
7
+ import { implement, type Implementation } from "../core/handlers.ts";
7
8
  import type { Secret } from "../core/secrets.ts";
8
- import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
9
+ import { step, type LiteralName, type Step } from "../core/steps.ts";
9
10
  import { httpHealthCheck } from "./http-health.ts";
10
11
  import { HelmRelease, PushedImage } from "./models.ts";
11
12
 
@@ -53,41 +54,43 @@ const input = { image: PushedImage };
53
54
  */
54
55
  export function composeDeploy<const N extends string, const K extends string>(
55
56
  def: ComposeDeployDef<N, K>,
56
- ): Step<N, typeof input, typeof HelmRelease, K> & LibraryStep {
57
- const configurable = { target: def.target };
58
- return makeStep(
59
- "composeDeploy",
60
- { name: def.name, description: def.description, image: COMPOSE_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.token], timeoutSeconds: def.timeoutSeconds },
61
- {
62
- manifestConfig: () => ({ target: recordConfigRefs(def.target), token: def.token.name }),
63
- async run({ image }: { image: { repository: string; digest: string } }, ctx: Ctx) {
64
- const { target } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: ComposeTargetValue };
65
- if (target?.kind !== "compose") throw new Error(`composeDeploy '${def.name}': the target is kind '${String(target?.kind)}', not 'compose'`);
66
- if (target.token.secret !== def.token.name) throw new Error(`composeDeploy '${def.name}': the target's token names secret ${target.token.secret}, but the step declares ${def.token.name}`);
67
- const token = (ctx.secrets as Record<string, string>)[def.token.name];
68
- const ref = `${image.repository}@${image.digest}`;
69
- const url = `${target.deployerUrl.replace(/\/+$/, "")}/v1/deploy`;
70
- ctx.log("deploying", { service: target.service, image: ref, deployer: target.deployerUrl });
71
- const res = await ctx.http.fetch(url, {
72
- method: "POST",
73
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
74
- body: JSON.stringify({ service: target.service, image: ref }),
75
- });
76
- const text = await res.text();
77
- let result: ComposeDeployResult | undefined;
78
- try {
79
- result = JSON.parse(text) as ComposeDeployResult;
80
- } catch {
81
- // reported below
82
- }
83
- for (const line of result?.log ?? []) ctx.log(`deployer: ${line}`);
84
- if (!res.ok || result?.status !== "deployed")
85
- throw new Error(`the deployer answered HTTP ${res.status}: ${result ? `${result.status}${result.message ? `: ${result.message}` : ""}` : text.slice(0, 300)}`);
86
- if (target.healthUrl) await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
87
- ctx.log("deployed", { service: target.service, image: ref, previous: result.previousImage ?? "-" });
88
- return { release: target.service, namespace: "compose", image: ref };
89
- },
90
- },
91
- configurable,
92
- ) as never;
57
+ ): Implementation<Step<N, typeof input, typeof HelmRelease, K>> {
58
+ const contract = step({
59
+ name: def.name,
60
+ description: def.description,
61
+ kind: "composeDeploy",
62
+ image: COMPOSE_DEPLOY_IMAGE,
63
+ input,
64
+ output: HelmRelease,
65
+ secrets: [def.token],
66
+ timeoutSeconds: def.timeoutSeconds,
67
+ settings: { target: def.target, token: def.token.name },
68
+ });
69
+ return implement(contract, async ({ image }, ctx: Ctx) => {
70
+ const target = resolveConfigRefs(def.target, ctx.config ?? {}) as ComposeTargetValue;
71
+ if (target?.kind !== "compose") throw new Error(`composeDeploy '${def.name}': the target is kind '${String(target?.kind)}', not 'compose'`);
72
+ if (target.token.secret !== def.token.name) throw new Error(`composeDeploy '${def.name}': the target's token names secret ${target.token.secret}, but the step declares ${def.token.name}`);
73
+ const token = (ctx.secrets as Record<string, string>)[def.token.name];
74
+ const ref = `${image.repository}@${image.digest}`;
75
+ const url = `${target.deployerUrl.replace(/\/+$/, "")}/v1/deploy`;
76
+ ctx.log("deploying", { service: target.service, image: ref, deployer: target.deployerUrl });
77
+ const res = await ctx.http.fetch(url, {
78
+ method: "POST",
79
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
80
+ body: JSON.stringify({ service: target.service, image: ref }),
81
+ });
82
+ const text = await res.text();
83
+ let result: ComposeDeployResult | undefined;
84
+ try {
85
+ result = JSON.parse(text) as ComposeDeployResult;
86
+ } catch {
87
+ // reported below
88
+ }
89
+ for (const line of result?.log ?? []) ctx.log(`deployer: ${line}`);
90
+ if (!res.ok || result?.status !== "deployed")
91
+ throw new Error(`the deployer answered HTTP ${res.status}: ${result ? `${result.status}${result.message ? `: ${result.message}` : ""}` : text.slice(0, 300)}`);
92
+ if (target.healthUrl) await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
93
+ ctx.log("deployed", { service: target.service, image: ref, previous: result.previousImage ?? "-" });
94
+ return { release: target.service, namespace: "compose", image: ref };
95
+ }) as never;
93
96
  }
@@ -8,8 +8,9 @@ import { z } from "zod";
8
8
  import type { Ctx } from "../ctx/types.ts";
9
9
  import type { Secret } from "../core/secrets.ts";
10
10
  import type { Configurable } from "../core/config.ts";
11
- import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
12
- import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
11
+ import { resolveConfigRefs } from "../core/config-refs.ts";
12
+ import { implement, type Implementation } from "../core/handlers.ts";
13
+ import { step, type LiteralName, type Step } from "../core/steps.ts";
13
14
  import { extractTar } from "../util/tar.ts";
14
15
  import { Source } from "./models.ts";
15
16
 
@@ -52,45 +53,48 @@ export const gitHubTarballUrl = (repo: string, sha: string, apiUrl = GITHUB_API_
52
53
  */
53
54
  export function gitHubSource<const N extends string, const T extends string = never>(
54
55
  def: GitHubSourceDef<N, T>,
55
- ): Step<N, GitHubSourceInput, typeof Source, T> & LibraryStep {
56
- return makeStep(
57
- "gitHubSource",
58
- { name: def.name, description: def.description, image: GITHUB_SOURCE_IMAGE, input, output: Source, secrets: def.token ? [def.token] : [], timeoutSeconds: def.timeoutSeconds },
59
- {
60
- manifestConfig: () => ({ ...(def.token ? { token: def.token.name } : {}), ...(def.apiUrl !== undefined ? { apiUrl: recordConfigRefs(def.apiUrl) } : {}) }),
61
- async run({ repo, sha }: { repo: string; sha: string }, ctx: Ctx) {
62
- const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "olve-pipelines" };
63
- const token = def.token ? optionalSecret(ctx, def.token.name) : "";
64
- if (token) headers.authorization = `Bearer ${token}`;
65
- else if (def.token) ctx.log(`${def.token.name} is not set: fetching anonymously (public repositories only)`);
66
- const url = gitHubTarballUrl(repo, sha, resolveConfigRefs(def.apiUrl, ctx.config ?? {}) ?? GITHUB_API_URL);
67
- mkdirSync(ctx.tmpDir, { recursive: true });
68
- const dir = mkdtempSync(join(ctx.tmpDir, "github-source-"));
69
- const archive = join(dir, "repo.tar.gz");
56
+ ): Implementation<Step<N, GitHubSourceInput, typeof Source, T>> {
57
+ const contract = step({
58
+ name: def.name,
59
+ description: def.description,
60
+ kind: "gitHubSource",
61
+ image: GITHUB_SOURCE_IMAGE,
62
+ input,
63
+ output: Source,
64
+ secrets: def.token ? [def.token] : [],
65
+ timeoutSeconds: def.timeoutSeconds,
66
+ settings: { ...(def.token ? { token: def.token.name } : {}), ...(def.apiUrl !== undefined ? { apiUrl: def.apiUrl } : {}) },
67
+ });
68
+ return implement(contract, async ({ repo, sha }, ctx: Ctx) => {
69
+ const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "olve-pipelines" };
70
+ const token = def.token ? optionalSecret(ctx, def.token.name) : "";
71
+ if (token) headers.authorization = `Bearer ${token}`;
72
+ else if (def.token) ctx.log(`${def.token.name} is not set: fetching anonymously (public repositories only)`);
73
+ const url = gitHubTarballUrl(repo, sha, resolveConfigRefs(def.apiUrl, ctx.config ?? {}) ?? GITHUB_API_URL);
74
+ mkdirSync(ctx.tmpDir, { recursive: true });
75
+ const dir = mkdtempSync(join(ctx.tmpDir, "github-source-"));
76
+ const archive = join(dir, "repo.tar.gz");
70
77
 
71
- ctx.log("fetching source", { repo, sha });
72
- // Retries network errors and 5xx; a 4xx (bad sha, no access) fails at once.
73
- const status = await ctx.retry({ attempts: 4, delaySeconds: 2, backoff: 2 }, async () => {
74
- const res = await ctx.http.fetch(url, { headers });
75
- if (res.status >= 500) throw new Error(`HTTP ${res.status} from ${url}`);
76
- if (res.ok) await Bun.write(archive, res);
77
- return res.status;
78
- });
79
- if (status < 200 || status >= 300) {
80
- throw new Error(`cannot fetch ${repo}@${sha}: HTTP ${status} from ${url}${def.token ? "" : " (no token configured: a private repository needs one)"}`);
81
- }
78
+ ctx.log("fetching source", { repo, sha });
79
+ // Retries network errors and 5xx; a 4xx (bad sha, no access) fails at once.
80
+ const status = await ctx.retry({ attempts: 4, delaySeconds: 2, backoff: 2 }, async () => {
81
+ const res = await ctx.http.fetch(url, { headers });
82
+ if (res.status >= 500) throw new Error(`HTTP ${res.status} from ${url}`);
83
+ if (res.ok) await Bun.write(archive, res);
84
+ return res.status;
85
+ });
86
+ if (status < 200 || status >= 300) {
87
+ throw new Error(`cannot fetch ${repo}@${sha}: HTTP ${status} from ${url}${def.token ? "" : " (no token configured: a private repository needs one)"}`);
88
+ }
82
89
 
83
- const extracted = join(dir, "x");
84
- mkdirSync(extracted);
85
- await extractTar(archive, extracted);
86
- // GitHub tarballs hold one top-level directory, plus a pax_global_header entry (the commit id)
87
- // that some extractors materialise as a file.
88
- const roots = readdirSync(extracted).filter((e) => e !== "pax_global_header" && statSync(join(extracted, e)).isDirectory());
89
- if (roots.length !== 1) throw new Error(`unexpected tarball layout for ${repo}@${sha}: top-level entries ${JSON.stringify(readdirSync(extracted))}`);
90
- const tree = await ctx.output.dir(join(extracted, roots[0]!), { name: "tree" });
91
- return { repo, sha, tree };
92
- },
93
- },
94
- { apiUrl: def.apiUrl },
95
- ) as never;
90
+ const extracted = join(dir, "x");
91
+ mkdirSync(extracted);
92
+ await extractTar(archive, extracted);
93
+ // GitHub tarballs hold one top-level directory, plus a pax_global_header entry (the commit id)
94
+ // that some extractors materialise as a file.
95
+ const roots = readdirSync(extracted).filter((e) => e !== "pax_global_header" && statSync(join(extracted, e)).isDirectory());
96
+ if (roots.length !== 1) throw new Error(`unexpected tarball layout for ${repo}@${sha}: top-level entries ${JSON.stringify(readdirSync(extracted))}`);
97
+ const tree = await ctx.output.dir(join(extracted, roots[0]!), { name: "tree" });
98
+ return { repo, sha, tree };
99
+ }) as never;
96
100
  }
@@ -9,16 +9,15 @@ import type { Ctx } from "../ctx/types.ts";
9
9
  import type { FileRef } from "../core/models.ts";
10
10
  import type { Secret } from "../core/secrets.ts";
11
11
  import type { Configurable } from "../core/config.ts";
12
- import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
13
- import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
12
+ import { resolveConfigRefs } from "../core/config-refs.ts";
13
+ import { implement, type Implementation } from "../core/handlers.ts";
14
+ import { step, type LiteralName, type Step } from "../core/steps.ts";
14
15
  import type { Simplify } from "../core/types.ts";
15
16
  import { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
16
17
  import { BuiltImage, HelmRelease, PushedImage, Source } from "./models.ts";
17
18
  import { shq, sshSession } from "./ssh.ts";
18
19
  import { helmDeployOverSshTarget, type HelmDeploySshTargetDef, type HelmTargetDeployInput } from "./helm-deploy.ts";
19
20
 
20
- export { shq } from "./ssh.ts";
21
-
22
21
  /** Alpine: `apk add openssh-client` at run time gives ssh/scp (the health check runs in the harness). */
23
22
  export const HELM_DEPLOY_IMAGE = "alpine:3.22";
24
23
 
@@ -71,7 +70,7 @@ export interface HelmDeployOverSshDef<
71
70
  /**
72
71
  * The images to deploy: step input key → the chart's values prefix for that image. Each input is a
73
72
  * `BuiltImage` (or `PushedImage` with `fromRegistry`). Default `{ image: "image" }`; several images
74
- * need a mapper, e.g. `{ server: "image", web: "web.image" }`.
73
+ * need a mapper, e.g. `{ server: "image", web: "web.image" }`. `{}` deploys the chart alone.
75
74
  */
76
75
  images?: Im & { readonly source?: never };
77
76
  /**
@@ -87,7 +86,7 @@ export interface HelmDeployOverSshDef<
87
86
  * next to tarball `images`.
88
87
  */
89
88
  imageRefs?: Rf & { readonly source?: never };
90
- /** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name. */
89
+ /** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name, or none without images. */
91
90
  deployments?: readonly string[];
92
91
  /** Per rollout (`kubectl rollout status --timeout`). Default 120. */
93
92
  rolloutTimeoutSeconds?: number;
@@ -221,26 +220,25 @@ export function helmDeployOverSsh<
221
220
  const K extends string,
222
221
  const Im extends Record<string, string> = DefaultImages,
223
222
  const Rf extends Record<string, string> = {},
224
- >(def: HelmDeploySshTargetDef<N, K, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K> & LibraryStep;
223
+ >(def: HelmDeploySshTargetDef<N, K, Im, Rf>): Implementation<Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K>>;
225
224
  export function helmDeployOverSsh<
226
225
  const N extends string,
227
226
  const K extends string,
228
227
  const Im extends Record<string, string> = DefaultImages,
229
228
  const R extends boolean = false,
230
229
  const Rf extends Record<string, string> = {},
231
- >(def: HelmDeployOverSshDef<N, K, Im, R, Rf>): Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K> & LibraryStep;
230
+ >(def: HelmDeployOverSshDef<N, K, Im, R, Rf>): Implementation<Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K>>;
232
231
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
233
- export function helmDeployOverSsh(def: any): Step & LibraryStep {
232
+ export function helmDeployOverSsh(def: any): any {
234
233
  return "target" in def ? helmDeployOverSshTarget(def) : helmDeployOverSshLiteral(def);
235
234
  }
236
235
 
237
236
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
- function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any, boolean, any>): Step & LibraryStep {
237
+ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any, boolean, any>): Implementation {
239
238
  const images: Record<string, string> = def.images ?? { image: "image" };
240
239
  const imageEntries = Object.entries(images);
241
240
  const fromRegistry = def.fromRegistry === true;
242
241
  const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
243
- if (imageEntries.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': images must not be empty`);
244
242
  if ("source" in images) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
245
243
  if (def.deployments?.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
246
244
  const imageRefs: Record<string, string> = def.imageRefs ?? {};
@@ -256,49 +254,53 @@ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any,
256
254
  const isDefaultImages = imageEntries.length === 1 && images.image === "image";
257
255
  const configurable = { host: def.host, release: def.release, namespace: def.namespace, values: def.values ?? [], set: def.set ?? {}, healthUrl: def.healthUrl };
258
256
 
259
- return makeStep(
260
- "helmDeployOverSsh",
261
- { name: def.name, description: def.description, image: HELM_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.sshKey], timeoutSeconds: def.timeoutSeconds },
262
- {
263
- manifestConfig: () => ({
264
- host: recordConfigRefs(def.host),
265
- sshKey: def.sshKey.name,
266
- release: recordConfigRefs(def.release),
267
- namespace: recordConfigRefs(def.namespace),
268
- chart: def.chart,
269
- values: recordConfigRefs(configurable.values),
270
- set: recordConfigRefs(configurable.set),
271
- ...(isDefaultImages ? {} : { images: { ...images } }),
272
- ...(fromRegistry ? { fromRegistry: true } : {}),
273
- ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
274
- ...(def.deployments ? { deployments: [...def.deployments] } : {}),
275
- ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
276
- ...(def.healthUrl !== undefined ? { healthUrl: recordConfigRefs(def.healthUrl) } : {}),
277
- }),
278
- async run(input: Record<string, { repository: string; tag: string; digest: string; tar?: FileRef }> & { source: { tree: FileRef } }, ctx: Ctx) {
279
- const d = resolveConfigRefs(configurable, ctx.config ?? {}) as {
280
- host: string; release: string; namespace: string; values: string[]; set: Record<string, string>;
281
- healthUrl?: string | ({ url: string } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
282
- };
283
- const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
284
- const hostImages: HostImage[] = [];
285
- const flags: HelmImageFlags[] = [];
286
- for (const [key, prefix] of imageEntries) {
287
- const image = input[key]!;
288
- hostImages.push({ repository: image.repository, tag: image.tag, ...(fromRegistry ? {} : { tar: await ctx.files.fetch(image.tar!) }) });
289
- flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
290
- }
291
- const refs: HelmImageRefFlags[] = refEntries.map(([key, prefix]) => ({ prefix, repository: input[key]!.repository, tag: input[key]!.tag, digest: input[key]!.digest }));
292
- await deployOverSsh(ctx, {
293
- host: d.host, key: ctx.secrets[def.sshKey.name]!, chartDir, release: d.release, namespace: d.namespace, hostImages,
294
- flags: { values: d.values, set: d.set, images: flags, imageRefs: refs },
295
- deployments: def.deployments ?? [d.release], rolloutTimeoutSeconds: rolloutTimeout,
296
- });
297
- const health = typeof d.healthUrl === "string" ? { url: d.healthUrl } : d.healthUrl;
298
- if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });
299
- return { release: d.release, namespace: d.namespace, image: `${flags[0]!.repository}:${flags[0]!.tag}` };
300
- },
257
+ const contract = step({
258
+ name: def.name,
259
+ description: def.description,
260
+ kind: "helmDeployOverSsh",
261
+ image: HELM_DEPLOY_IMAGE,
262
+ input,
263
+ output: HelmRelease,
264
+ secrets: [def.sshKey],
265
+ timeoutSeconds: def.timeoutSeconds,
266
+ settings: {
267
+ host: def.host,
268
+ sshKey: def.sshKey.name,
269
+ release: def.release,
270
+ namespace: def.namespace,
271
+ chart: def.chart,
272
+ values: configurable.values,
273
+ set: configurable.set,
274
+ ...(isDefaultImages ? {} : { images: { ...images } }),
275
+ ...(fromRegistry ? { fromRegistry: true } : {}),
276
+ ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
277
+ ...(def.deployments ? { deployments: [...def.deployments] } : {}),
278
+ ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
279
+ ...(def.healthUrl !== undefined ? { healthUrl: def.healthUrl } : {}),
301
280
  },
302
- configurable,
303
- ) as never;
281
+ });
282
+ return implement(contract, async (stepInput, ctx: Ctx) => {
283
+ const input = stepInput as unknown as Record<string, { repository: string; tag: string; digest: string; tar?: FileRef }> & { source: { tree: FileRef } };
284
+ const d = resolveConfigRefs(configurable, ctx.config ?? {}) as {
285
+ host: string; release: string; namespace: string; values: string[]; set: Record<string, string>;
286
+ healthUrl?: string | ({ url: string } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
287
+ };
288
+ const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
289
+ const hostImages: HostImage[] = [];
290
+ const flags: HelmImageFlags[] = [];
291
+ for (const [key, prefix] of imageEntries) {
292
+ const image = input[key]!;
293
+ hostImages.push({ repository: image.repository, tag: image.tag, ...(fromRegistry ? {} : { tar: await ctx.files.fetch(image.tar!) }) });
294
+ flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
295
+ }
296
+ const refs: HelmImageRefFlags[] = refEntries.map(([key, prefix]) => ({ prefix, repository: input[key]!.repository, tag: input[key]!.tag, digest: input[key]!.digest }));
297
+ await deployOverSsh(ctx, {
298
+ host: d.host, key: ctx.secrets[def.sshKey.name]!, chartDir, release: d.release, namespace: d.namespace, hostImages,
299
+ flags: { values: d.values, set: d.set, images: flags, imageRefs: refs },
300
+ deployments: def.deployments ?? (imageEntries.length ? [d.release] : []), rolloutTimeoutSeconds: rolloutTimeout,
301
+ });
302
+ const health = typeof d.healthUrl === "string" ? { url: d.healthUrl } : d.healthUrl;
303
+ if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });
304
+ return { release: d.release, namespace: d.namespace, ...(flags[0] ? { image: `${flags[0].repository}:${flags[0].tag}` } : {}) };
305
+ }) as never;
304
306
  }