@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.3

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.
@@ -20,7 +20,7 @@ export class DescribeError extends Error {
20
20
  }
21
21
 
22
22
  export interface DescribeOptions {
23
- /** Step names that have a handler file (`handlers/<name>.ts`). */
23
+ /** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
24
24
  handlerNames: Iterable<string>;
25
25
  /** Step name → handler hash (from the build; includes the harness identity). */
26
26
  handlerHashes: Readonly<Record<string, string>>;
@@ -198,7 +198,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
198
198
  let handlerHash: string;
199
199
  let config: Record<string, unknown> | undefined;
200
200
  if (s.kind === "custom") {
201
- if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (expected handlers/${s.name}.ts default-exporting implement(${camel(s.name)}, …))`);
201
+ if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`);
202
202
  handlerHash = opts.handlerHashes[s.name] ?? "";
203
203
  if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
204
204
  } else if (s.builtin) {
@@ -329,19 +329,26 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
329
329
  return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
330
330
  }).filter((x) => x !== undefined);
331
331
 
332
- // ── handler files without a step ──
332
+ // ── handlers without a step ──
333
333
  for (const h of handlerNames) {
334
334
  const s = steps.get(h);
335
- if (!s) problems.push(`handlers/${h}.ts does not match any step of the pipeline`);
336
- else if (s.kind !== "custom") problems.push(`handlers/${h}.ts: '${h}' is a library step (${s.kind}) and must not have a handler file`);
335
+ if (!s) problems.push(`a handler implements step '${h}', which is not in the pipeline`);
336
+ else if (s.kind !== "custom") problems.push(`a handler implements '${h}', a library step (${s.kind}) that carries its own implementation`);
337
337
  }
338
338
 
339
339
  // ── triggers ──
340
340
  const triggerNames = new Set<string>();
341
341
  const triggers: TriggerT[] = data.triggers.map((t) => {
342
- if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll a distinct name)"}`);
342
+ if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll and onWebhook a distinct name)"}`);
343
343
  triggerNames.add(t.name);
344
344
  if (t.kind === "push") return { kind: "push", name: "push" };
345
+ if (t.kind === "webhook") {
346
+ if (!STEP_NAME.test(t.name)) problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
347
+ const secret = useSecret(t.verify.secret, `webhook '${t.name}'`);
348
+ if (t.verify.scheme === "bearer") return { kind: "webhook", name: t.name, verify: { scheme: "bearer", secret } };
349
+ if (!t.verify.header) problems.push(`webhook '${t.name}': the signature header must not be empty`);
350
+ return { kind: "webhook", name: t.name, verify: { scheme: "hmac-sha256", secret, header: t.verify.header, prefix: t.verify.prefix } };
351
+ }
345
352
  if (!STEP_NAME.test(t.name)) problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
346
353
  if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0)) problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
347
354
  const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
@@ -90,6 +90,21 @@ export const Trigger = z
90
90
  intervalSeconds: z.number().int().positive(),
91
91
  })
92
92
  .describe("Background poller."),
93
+ z
94
+ .strictObject({
95
+ kind: z.literal("webhook"),
96
+ name: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/),
97
+ verify: z.discriminatedUnion("scheme", [
98
+ z.strictObject({
99
+ scheme: z.literal("hmac-sha256"),
100
+ secret: secretName,
101
+ header: z.string().min(1),
102
+ prefix: z.string().describe("Text before the hex digest in the header, e.g. 'sha256='."),
103
+ }),
104
+ z.strictObject({ scheme: z.literal("bearer"), secret: secretName }),
105
+ ]),
106
+ })
107
+ .describe("Inbound HTTP delivery to /api/hooks/<pipeline id>/<name>. Event = {headers, body}; a null mapping ignores it."),
93
108
  ])
94
109
  .meta({ id: "Trigger" });
95
110
 
@@ -102,7 +117,7 @@ export const Manifest = z
102
117
  harness: z
103
118
  .strictObject({
104
119
  bun: z.string().describe("Bun runtime version compiled into the binary."),
105
- sdk: z.string().describe("@olve/pipelines package version."),
120
+ sdk: z.string().describe("@olivervea/pipelines package version."),
106
121
  sdkHash: hash.describe("Hash of the SDK runtime sources + zod version."),
107
122
  })
108
123
  .describe("Harness identity. Already folded into every handlerHash."),
package/src/dev/cli.ts CHANGED
@@ -17,7 +17,7 @@ const USAGE = `usage: olve-dev describe <dir> [--sentinels]
17
17
  olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]`;
18
18
 
19
19
  export async function devConfig(dir: string, withHashes: boolean): Promise<HarnessConfig> {
20
- const project = loadProject(dir);
20
+ const project = await loadProject(dir);
21
21
  const mod = (await import(project.pipelineFile)) as { default?: unknown };
22
22
  if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
23
23
  const harness = harnessInfo();
@@ -1,4 +1,4 @@
1
- // @olve/pipelines/harness — the runtime the build tool compiles into the step binary.
1
+ // @olivervea/pipelines/harness — the runtime the build tool compiles into the step binary.
2
2
  export { runHarness, defaultIO, allSteps, resolveStepFunction, type HarnessConfig, type HandlerRegistry, type HarnessIO } from "./main.ts";
3
3
  export { EXIT } from "./exit.ts";
4
4
  export { ENV, paths, runInfoFromEnv } from "./env.ts";
@@ -6,6 +6,7 @@ import { mkdirSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { isImplementation } from "../core/handlers.ts";
8
8
  import type { AnyStep } from "../core/steps.ts";
9
+ import type { WebhookEvent } from "../core/webhooks.ts";
9
10
  import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
10
11
  import { createCtx, formatLog } from "../ctx/index.ts";
11
12
  import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
@@ -76,17 +77,17 @@ export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegist
76
77
  return step.builtin.run as StepFunction;
77
78
  }
78
79
  const load = handlers[step.name];
79
- if (!load) return `step '${step.name}' has no handler (expected handlers/${step.name}.ts)`;
80
+ if (!load) return `step '${step.name}' has no handler`;
80
81
  let mod: { default: unknown };
81
82
  try {
82
83
  mod = await load();
83
84
  } catch (e) {
84
- return `handlers/${step.name}.ts failed to load: ${(e as Error)?.stack ?? String(e)}`;
85
+ return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
85
86
  }
86
87
  const impl = mod.default;
87
- if (!isImplementation(impl)) return `handlers/${step.name}.ts must default-export implement(<step '${step.name}'>, …)`;
88
+ if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
88
89
  if (impl.step !== step)
89
- return `handlers/${step.name}.ts implements step '${(impl.step as AnyStep)?.name}', not '${step.name}' (the default export must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
90
+ return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
90
91
  return impl.handler as unknown as StepFunction;
91
92
  }
92
93
 
@@ -162,6 +163,12 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
162
163
  }
163
164
  }
164
165
 
166
+ function isWebhookEvent(v: unknown): boolean {
167
+ if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
168
+ const headers = (v as { headers?: unknown }).headers;
169
+ return typeof headers === "object" && headers !== null && !Array.isArray(headers);
170
+ }
171
+
165
172
  async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
166
173
  if (!name) return io.stderr(USAGE), EXIT.usage;
167
174
  const data = pipelineData(cfg.pipeline);
@@ -181,15 +188,21 @@ async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io:
181
188
  exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
182
189
  else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
183
190
  exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
191
+ else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
192
+ exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
184
193
  else {
185
194
  let mapped: unknown;
186
195
  let threw: unknown = undefined;
187
196
  try {
188
- mapped = trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value) : await trigger.map(raw.value);
197
+ mapped =
198
+ trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
199
+ : trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
200
+ : await trigger.map(raw.value);
189
201
  } catch (e) {
190
202
  threw = e ?? new Error("undefined");
191
203
  }
192
204
  if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
205
+ else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
193
206
  else {
194
207
  const r = parseStrict(data.input.schema, mapped, "input");
195
208
  exec = r.ok
package/src/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /// <reference path="./assets.d.ts" />
2
- // @olve/pipelines — authoring API (contracts, handlers, pipeline builder).
2
+ // @olivervea/pipelines — authoring API (contracts, handlers, pipeline builder).
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 { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
6
7
  export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
7
8
  export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
8
9
  export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
9
10
  export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
10
11
  export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
11
- export { steps, Source, BuiltImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type HelmDeployOverSshDef, httpHealthCheck, type HttpHealthCheckOptions } from "./library/index.ts";
12
+ export { steps, Source, BuiltImage, PushedImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type HelmDeployOverSshDef, httpHealthCheck, type HttpHealthCheckOptions, sshSession, shq, type SshSession, serviceUrl, checkSpa } from "./library/index.ts";
@@ -0,0 +1,23 @@
1
+ // Checks for deployed apps, used from custom test handlers.
2
+ import type { Ctx } from "../ctx/types.ts";
3
+
4
+ /** The in-cluster URL of a release's Service, assuming the chart names it after the release. */
5
+ export function serviceUrl(release: { release: string; namespace: string }): string {
6
+ return `http://${release.release}.${release.namespace}.svc.cluster.local`;
7
+ }
8
+
9
+ /**
10
+ * Checks `url` serves a single-page app: `GET <url>/` references a `/assets/*.js` bundle (Vite's
11
+ * layout), and the bundle is served as JavaScript. Returns the page, for app-specific checks.
12
+ */
13
+ export async function checkSpa(ctx: Pick<Ctx, "http" | "log">, url: string): Promise<{ html: string; bundle: string }> {
14
+ const base = url.replace(/\/$/, "");
15
+ const html = await (await ctx.http.expectOk(`${base}/`)).text();
16
+ const bundle = html.match(/src="(\/assets\/[^"]+\.js)"/)?.[1];
17
+ if (bundle === undefined) throw new Error(`${base}/ does not reference an /assets/*.js bundle`);
18
+ const js = await ctx.http.expectOk(`${base}${bundle}`);
19
+ await js.body?.cancel().catch(() => {});
20
+ if (!(js.headers.get("content-type")?.includes("javascript") ?? false)) throw new Error(`${base}${bundle} is not served as JavaScript`);
21
+ ctx.log("SPA served", { url: base, bundle });
22
+ return { html, bundle };
23
+ }
@@ -1,16 +1,20 @@
1
- // steps.helmDeployOverSsh — import a built image into a k3s host's containerd and helm-upgrade a
2
- // release there, over SSH; optionally verify an HTTP health endpoint afterwards.
1
+ // steps.helmDeployOverSsh — get built images onto a k3s host and helm-upgrade a release there, over
2
+ // SSH; optionally verify an HTTP health endpoint afterwards.
3
3
  //
4
4
  // Ported from v1's olve-lib.sh (olve_ssh_host, olve_image_import, olve_helm_deploy) and
5
5
  // deploy-beta.sh (rollout wait + health loop); the footguns are kept as comments below.
6
- import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
6
+ import { existsSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  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 { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
12
- import { httpHealthCheck } from "./http-health.ts";
13
- import { BuiltImage, HelmRelease, Source } from "./models.ts";
12
+ import type { Simplify } from "../core/types.ts";
13
+ import { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
14
+ import { BuiltImage, HelmRelease, PushedImage, Source } from "./models.ts";
15
+ import { shq, sshSession } from "./ssh.ts";
16
+
17
+ export { shq } from "./ssh.ts";
14
18
 
15
19
  /** Alpine: `apk add openssh-client` at run time gives ssh/scp (the health check runs in the harness). */
16
20
  export const HELM_DEPLOY_IMAGE = "alpine:3.22";
@@ -20,13 +24,18 @@ export const HELM_DEPLOY_IMAGE = "alpine:3.22";
20
24
  * the k3s instance's `k8s.io` namespace (otherwise pods fail with ErrImageNeverPull).
21
25
  */
22
26
  export const K3S_IMAGE_IMPORT = "sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load";
27
+ /** Registry images are pulled the same way before the rollout, so the rollout doesn't wait on the pull. */
28
+ export const K3S_IMAGE_PULL = "sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io pull -q";
23
29
 
24
- const ROLLOUT_TIMEOUT = "120s";
30
+ export const DEFAULT_ROLLOUT_TIMEOUT_SECONDS = 120;
25
31
 
26
- const input = { source: Source, image: BuiltImage };
27
- export type HelmDeployOverSshInput = typeof input;
32
+ type DefaultImages = { readonly image: "image" };
33
+ type ImageModel<R extends boolean> = R extends true ? typeof PushedImage : typeof BuiltImage;
34
+ export type HelmDeployOverSshInput<Im extends Record<string, string> = DefaultImages, R extends boolean = false> = Simplify<
35
+ { readonly source: typeof Source } & { readonly [K in keyof Im]: ImageModel<R> }
36
+ >;
28
37
 
29
- export interface HelmDeployOverSshDef<N extends string, K extends string> {
38
+ export interface HelmDeployOverSshDef<N extends string, K extends string, Im extends Record<string, string> = DefaultImages, R extends boolean = false> {
30
39
  name: LiteralName<N>;
31
40
  /** What the step does, shown in the UI. Required; may be empty. */
32
41
  description: string;
@@ -34,7 +43,7 @@ export interface HelmDeployOverSshDef<N extends string, K extends string> {
34
43
  host: string;
35
44
  /** Private key for `host`. */
36
45
  sshKey: Secret<K>;
37
- /** Helm release name; the chart's Deployment is expected to be named the same (rollout wait). */
46
+ /** Helm release name. */
38
47
  release: string;
39
48
  namespace: string;
40
49
  /** Chart directory inside the source tree, e.g. `helm`. */
@@ -42,19 +51,36 @@ export interface HelmDeployOverSshDef<N extends string, K extends string> {
42
51
  /** Values files, relative to the chart directory (`-f`, in order). */
43
52
  values?: readonly string[];
44
53
  /**
45
- * Chart-specific `--set` flags (e.g. `{ "slo.enabled": "false" }`). The image flags are added by
46
- * the step and always win: `image.repository`, `image.tag`, `image.pullPolicy=Never`.
54
+ * Chart-specific `--set` flags (e.g. `{ "ingress.enabled": "false" }`). The image flags are added by
55
+ * the step and always win: `<prefix>.repository`, `<prefix>.tag`, `<prefix>.pullPolicy`.
47
56
  */
48
57
  set?: Readonly<Record<string, string>>;
49
- /** After the rollout, GET this URL until it returns 2xx, 5 × 5 s (`httpHealthCheck`), or fail the step. */
50
- healthUrl?: string;
58
+ /**
59
+ * The images to deploy: step input key → the chart's values prefix for that image. Each input is a
60
+ * `BuiltImage` (or `PushedImage` with `fromRegistry`). Default `{ image: "image" }`; several images
61
+ * need a mapper, e.g. `{ server: "image", web: "web.image" }`.
62
+ */
63
+ images?: Im & { readonly source?: never };
64
+ /**
65
+ * The images are `PushedImage`s from a registry (`imageBuild` with `push`): pulled on the host
66
+ * first, then `pullPolicy=IfNotPresent`. Default: `BuiltImage` tarballs, imported on the host, then
67
+ * `pullPolicy=Never`.
68
+ */
69
+ fromRegistry?: R & (boolean extends R ? "❌ fromRegistry must be a literal true or false (use `as const` on a shared config object)" : unknown);
70
+ /** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name. */
71
+ deployments?: readonly string[];
72
+ /** Per rollout (`kubectl rollout status --timeout`). Default 120. */
73
+ rolloutTimeoutSeconds?: number;
74
+ /** After the rollouts, GET this URL until it returns 2xx (`httpHealthCheck`: 5 × 5 s unless given), or fail the step. */
75
+ healthUrl?: string | ({ url: string } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
51
76
  timeoutSeconds?: number;
52
77
  }
53
78
 
54
- const SAFE = /^[A-Za-z0-9_\/.:=@%+,-]+$/;
55
- /** POSIX shell quoting (safe words are left bare, so logs stay readable). */
56
- export function shq(s: string): string {
57
- return SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
79
+ export interface HelmImageFlags {
80
+ prefix: string;
81
+ repository: string;
82
+ tag: string;
83
+ pullPolicy: "Never" | "IfNotPresent";
58
84
  }
59
85
 
60
86
  /** Remote `helm upgrade --install`, run from inside the copied chart directory. */
@@ -64,18 +90,18 @@ export function helmUpgradeCommand(o: {
64
90
  namespace: string;
65
91
  values: readonly string[];
66
92
  set: Readonly<Record<string, string>>;
67
- repository: string;
68
- tag: string;
93
+ images: readonly HelmImageFlags[];
69
94
  }): string {
70
95
  // Chart-relative values paths (`-f values-beta.yaml`) resolve because helm runs from inside the
71
- // copied chart dir. Extra flags come before the image flags, so the image overrides win. The image
72
- // is local to the node (imported above), never pulled: pullPolicy=Never.
96
+ // copied chart dir. Extra flags come before the image flags, so the image overrides win.
73
97
  const flags = [
74
98
  ...o.values.flatMap((v) => ["-f", v]),
75
99
  ...Object.entries(o.set).flatMap(([k, v]) => ["--set", `${k}=${v}`]),
76
- "--set", `image.repository=${o.repository}`,
77
- "--set", `image.tag=${o.tag}`,
78
- "--set", "image.pullPolicy=Never",
100
+ ...o.images.flatMap((i) => [
101
+ "--set", `${i.prefix}.repository=${i.repository}`,
102
+ "--set", `${i.prefix}.tag=${i.tag}`,
103
+ "--set", `${i.prefix}.pullPolicy=${i.pullPolicy}`,
104
+ ]),
79
105
  ];
80
106
  return [
81
107
  `cd ${shq(o.remoteChart)}`,
@@ -85,15 +111,31 @@ export function helmUpgradeCommand(o: {
85
111
  }
86
112
 
87
113
  /**
88
- * Deploys `image` with the Helm chart at `<source>/<chart>` on a k3s host over SSH:
89
- * stream the image tar into the host's k3s containerd, copy the chart, `helm upgrade --install`,
90
- * wait for `deploy/<release>` to roll out, then (with `healthUrl`) poll the health endpoint.
114
+ * Deploys images with the Helm chart at `<source>/<chart>` on a k3s host over SSH: get each image
115
+ * into the host's k3s containerd (stream a tarball in, or pull from the registry), copy the chart,
116
+ * `helm upgrade --install`, wait for each rollout, then (with `healthUrl`) poll the health endpoint.
91
117
  */
92
- export function helmDeployOverSsh<const N extends string, const K extends string>(
93
- def: HelmDeployOverSshDef<N, K>,
94
- ): Step<N, HelmDeployOverSshInput, typeof HelmRelease, K> & LibraryStep {
118
+ export function helmDeployOverSsh<
119
+ const N extends string,
120
+ const K extends string,
121
+ const Im extends Record<string, string> = DefaultImages,
122
+ const R extends boolean = false,
123
+ >(def: HelmDeployOverSshDef<N, K, Im, R>): Step<N, HelmDeployOverSshInput<Im, R>, typeof HelmRelease, K> & LibraryStep {
95
124
  const values = def.values ?? [];
96
125
  const set = def.set ?? {};
126
+ const images: Record<string, string> = def.images ?? { image: "image" };
127
+ const imageEntries = Object.entries(images);
128
+ const fromRegistry = def.fromRegistry === true;
129
+ const deployments = def.deployments ?? [def.release];
130
+ const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
131
+ const health = typeof def.healthUrl === "string" ? { url: def.healthUrl } : def.healthUrl;
132
+ if (imageEntries.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': images must not be empty`);
133
+ if ("source" in images) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
134
+ if (deployments.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
135
+ const imageModel = fromRegistry ? PushedImage : BuiltImage;
136
+ const input = { source: Source, ...Object.fromEntries(imageEntries.map(([key]) => [key, imageModel])) };
137
+ const isDefaultImages = imageEntries.length === 1 && images.image === "image";
138
+
97
139
  return makeStep(
98
140
  "helmDeployOverSsh",
99
141
  { name: def.name, description: def.description, image: HELM_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.sshKey], timeoutSeconds: def.timeoutSeconds },
@@ -106,45 +148,48 @@ export function helmDeployOverSsh<const N extends string, const K extends string
106
148
  chart: def.chart,
107
149
  values: [...values],
108
150
  set: { ...set },
109
- ...(def.healthUrl ? { healthUrl: def.healthUrl } : {}),
151
+ ...(isDefaultImages ? {} : { images: { ...images } }),
152
+ ...(fromRegistry ? { fromRegistry: true } : {}),
153
+ ...(def.deployments ? { deployments: [...def.deployments] } : {}),
154
+ ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
155
+ ...(def.healthUrl !== undefined ? { healthUrl: typeof def.healthUrl === "string" ? def.healthUrl : { ...def.healthUrl } } : {}),
110
156
  }),
111
- async run(
112
- { source, image }: { source: { tree: FileRef }; image: { repository: string; tag: string; tar: FileRef } },
113
- ctx: Ctx,
114
- ) {
115
- const tree = await ctx.files.fetch(source.tree);
157
+ async run(input: Record<string, { repository: string; tag: string; tar?: FileRef }> & { source: { tree: FileRef } }, ctx: Ctx) {
158
+ const tree = await ctx.files.fetch(input.source.tree);
116
159
  const chartDir = join(tree, def.chart);
117
160
  if (!existsSync(chartDir) || !statSync(chartDir).isDirectory()) throw new Error(`chart directory '${def.chart}' not found in the source tree`);
118
- const imageTar = await ctx.files.fetch(image.tar);
119
-
120
- // SSH setup. The key file needs a trailing newline (OpenSSH rejects the key otherwise) and
121
- // mode 600. The host key is not verified (as in v1): the host is reached over the tailnet.
122
- await ctx.exec(["apk", "add", "--no-cache", "openssh-client"]);
123
- const sshDir = join(ctx.tmpDir, "ssh");
124
- mkdirSync(sshDir, { recursive: true, mode: 0o700 });
125
- const keyFile = join(sshDir, "id");
126
- const key = ctx.secrets[def.sshKey.name]!;
127
- writeFileSync(keyFile, key.endsWith("\n") ? key : key + "\n", { mode: 0o600 });
128
- chmodSync(keyFile, 0o600);
129
- const opts = ["-i", keyFile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "BatchMode=yes"];
130
- const ssh = (remote: string) => ctx.exec(["ssh", ...opts, def.host, remote]);
131
161
 
132
- const ref = `${image.repository}:${image.tag}`;
133
- ctx.log("importing image", { image: ref, host: def.host });
134
- // Streamed over ssh's stdin (ctx.exec has none, hence the shell redirect).
135
- await ctx.sh(["ssh", ...opts, def.host, K3S_IMAGE_IMPORT].map(shq).join(" ") + ` < ${shq(imageTar)}`);
162
+ const ssh = await sshSession(ctx, { host: def.host, key: ctx.secrets[def.sshKey.name]! });
163
+ const flags: HelmImageFlags[] = [];
164
+ for (const [key, prefix] of imageEntries) {
165
+ const image = input[key]!;
166
+ const ref = `${image.repository}:${image.tag}`;
167
+ if (fromRegistry) {
168
+ ctx.log("pulling image", { image: ref, host: def.host });
169
+ await ssh.run(`${K3S_IMAGE_PULL} ${shq(ref)}`);
170
+ } else {
171
+ const tar = await ctx.files.fetch(image.tar!);
172
+ ctx.log("importing image", { image: ref, host: def.host });
173
+ // Streamed over ssh's stdin (ctx.exec has none, hence the shell redirect).
174
+ await ctx.sh(`${ssh.shell(K3S_IMAGE_IMPORT)} < ${shq(tar)}`);
175
+ }
176
+ flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
177
+ }
136
178
 
137
179
  const remoteChart = `/tmp/${def.release}-helm-${def.namespace}`;
138
- ctx.log("helm upgrade", { release: def.release, namespace: def.namespace, image: ref });
139
- await ssh(`rm -rf ${shq(remoteChart)}`);
140
- await ctx.exec(["scp", ...opts, "-r", chartDir, `${def.host}:${remoteChart}`]);
141
- await ssh(helmUpgradeCommand({ remoteChart, release: def.release, namespace: def.namespace, values, set, repository: image.repository, tag: image.tag }));
180
+ ctx.log("helm upgrade", { release: def.release, namespace: def.namespace, images: flags.map((f) => `${f.repository}:${f.tag}`).join(" ") });
181
+ await ssh.run(`rm -rf ${shq(remoteChart)}`);
182
+ await ssh.copy(chartDir, remoteChart);
183
+ await ssh.run(helmUpgradeCommand({ remoteChart, release: def.release, namespace: def.namespace, values, set, images: flags }));
142
184
 
143
- ctx.log("waiting for rollout", { deployment: def.release });
144
- await ssh(`kubectl -n ${shq(def.namespace)} rollout status ${shq(`deploy/${def.release}`)} --timeout=${ROLLOUT_TIMEOUT}`);
185
+ for (const d of deployments) {
186
+ const target = d.includes("/") ? d : `deploy/${d}`;
187
+ ctx.log("waiting for rollout", { deployment: target });
188
+ await ssh.run(`kubectl -n ${shq(def.namespace)} rollout status ${shq(target)} --timeout=${rolloutTimeout}s`);
189
+ }
145
190
 
146
- if (def.healthUrl) await httpHealthCheck(ctx, def.healthUrl);
147
- return { release: def.release, namespace: def.namespace, image: ref };
191
+ if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });
192
+ return { release: def.release, namespace: def.namespace, image: `${flags[0]!.repository}:${flags[0]!.tag}` };
148
193
  },
149
194
  },
150
195
  ) as never;