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

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.
@@ -8,11 +8,14 @@ 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
+ import type { Configurable } from "../core/config.ts";
12
+ import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
11
13
  import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
12
14
  import type { Simplify } from "../core/types.ts";
13
15
  import { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
14
16
  import { BuiltImage, HelmRelease, PushedImage, Source } from "./models.ts";
15
17
  import { shq, sshSession } from "./ssh.ts";
18
+ import { helmDeployOverSshTarget, type HelmDeploySshTargetDef, type HelmTargetDeployInput } from "./helm-deploy.ts";
16
19
 
17
20
  export { shq } from "./ssh.ts";
18
21
 
@@ -31,30 +34,40 @@ export const DEFAULT_ROLLOUT_TIMEOUT_SECONDS = 120;
31
34
 
32
35
  type DefaultImages = { readonly image: "image" };
33
36
  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> }
37
+ export type HelmDeployOverSshInput<
38
+ Im extends Record<string, string> = DefaultImages,
39
+ R extends boolean = false,
40
+ Rf extends Record<string, string> = {},
41
+ > = Simplify<
42
+ { readonly source: typeof Source } & { readonly [K in keyof Im]: ImageModel<R> } & { readonly [K in keyof Rf]: typeof PushedImage }
36
43
  >;
37
44
 
38
- export interface HelmDeployOverSshDef<N extends string, K extends string, Im extends Record<string, string> = DefaultImages, R extends boolean = false> {
45
+ export interface HelmDeployOverSshDef<
46
+ N extends string,
47
+ K extends string,
48
+ Im extends Record<string, string> = DefaultImages,
49
+ R extends boolean = false,
50
+ Rf extends Record<string, string> = {},
51
+ > {
39
52
  name: LiteralName<N>;
40
53
  /** What the step does, shown in the UI. Required; may be empty. */
41
54
  description: string;
42
55
  /** SSH target, `user@host`. The user needs passwordless sudo for nerdctl, and helm/kubectl with a kubeconfig. */
43
- host: string;
56
+ host: Configurable<string>;
44
57
  /** Private key for `host`. */
45
58
  sshKey: Secret<K>;
46
59
  /** Helm release name. */
47
- release: string;
48
- namespace: string;
60
+ release: Configurable<string>;
61
+ namespace: Configurable<string>;
49
62
  /** Chart directory inside the source tree, e.g. `helm`. */
50
63
  chart: string;
51
64
  /** Values files, relative to the chart directory (`-f`, in order). */
52
- values?: readonly string[];
65
+ values?: Configurable<readonly string[]>;
53
66
  /**
54
67
  * Chart-specific `--set` flags (e.g. `{ "ingress.enabled": "false" }`). The image flags are added by
55
68
  * the step and always win: `<prefix>.repository`, `<prefix>.tag`, `<prefix>.pullPolicy`.
56
69
  */
57
- set?: Readonly<Record<string, string>>;
70
+ set?: Configurable<Readonly<Record<string, string>>>;
58
71
  /**
59
72
  * The images to deploy: step input key → the chart's values prefix for that image. Each input is a
60
73
  * `BuiltImage` (or `PushedImage` with `fromRegistry`). Default `{ image: "image" }`; several images
@@ -67,12 +80,19 @@ export interface HelmDeployOverSshDef<N extends string, K extends string, Im ext
67
80
  * `pullPolicy=Never`.
68
81
  */
69
82
  fromRegistry?: R & (boolean extends R ? "❌ fromRegistry must be a literal true or false (use `as const` on a shared config object)" : unknown);
83
+ /**
84
+ * Registry images the chart only references (not run by the release's own pods, e.g. an image the
85
+ * app starts Jobs with): step input key → values prefix. Each input is a `PushedImage`; the step sets
86
+ * `<prefix>.repository`, `<prefix>.tag` and `<prefix>.digest` and does not pull it on the host. Works
87
+ * next to tarball `images`.
88
+ */
89
+ imageRefs?: Rf & { readonly source?: never };
70
90
  /** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name. */
71
91
  deployments?: readonly string[];
72
92
  /** Per rollout (`kubectl rollout status --timeout`). Default 120. */
73
93
  rolloutTimeoutSeconds?: number;
74
94
  /** 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">);
95
+ healthUrl?: Configurable<string> | ({ url: Configurable<string> } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
76
96
  timeoutSeconds?: number;
77
97
  }
78
98
 
@@ -81,19 +101,30 @@ export interface HelmImageFlags {
81
101
  repository: string;
82
102
  tag: string;
83
103
  pullPolicy: "Never" | "IfNotPresent";
104
+ /** Pins the image: `<prefix>.digest` (the chart renders `repository:tag@digest`). */
105
+ digest?: string;
84
106
  }
85
107
 
86
- /** Remote `helm upgrade --install`, run from inside the copied chart directory. */
87
- export function helmUpgradeCommand(o: {
88
- remoteChart: string;
89
- release: string;
90
- namespace: string;
108
+ /** A registry image the chart only references (`imageRefs`). */
109
+ export interface HelmImageRefFlags {
110
+ prefix: string;
111
+ repository: string;
112
+ tag: string;
113
+ digest: string;
114
+ }
115
+
116
+ export interface HelmFlagOptions {
91
117
  values: readonly string[];
92
118
  set: Readonly<Record<string, string>>;
93
119
  images: readonly HelmImageFlags[];
94
- }): string {
120
+ imageRefs?: readonly HelmImageRefFlags[];
121
+ }
122
+
123
+ /** `helm upgrade` flags: values files, `--set`, then the image flags (which win). */
124
+ export function helmFlags(o: HelmFlagOptions): string[] {
95
125
  // Chart-relative values paths (`-f values-beta.yaml`) resolve because helm runs from inside the
96
126
  // copied chart dir. Extra flags come before the image flags, so the image overrides win.
127
+ // --set-string: a tag like `2026.10` or `1e5` would otherwise be parsed as a number.
97
128
  const flags = [
98
129
  ...o.values.flatMap((v) => ["-f", v]),
99
130
  ...Object.entries(o.set).flatMap(([k, v]) => ["--set", `${k}=${v}`]),
@@ -101,8 +132,20 @@ export function helmUpgradeCommand(o: {
101
132
  "--set", `${i.prefix}.repository=${i.repository}`,
102
133
  "--set", `${i.prefix}.tag=${i.tag}`,
103
134
  "--set", `${i.prefix}.pullPolicy=${i.pullPolicy}`,
135
+ ...(i.digest ? ["--set-string", `${i.prefix}.digest=${i.digest}`] : []),
136
+ ]),
137
+ ...(o.imageRefs ?? []).flatMap((i) => [
138
+ "--set-string", `${i.prefix}.repository=${i.repository}`,
139
+ "--set-string", `${i.prefix}.tag=${i.tag}`,
140
+ "--set-string", `${i.prefix}.digest=${i.digest}`,
104
141
  ]),
105
142
  ];
143
+ return flags;
144
+ }
145
+
146
+ /** Remote `helm upgrade --install`, run from inside the copied chart directory. */
147
+ export function helmUpgradeCommand(o: HelmFlagOptions & { remoteChart: string; release: string; namespace: string }): string {
148
+ const flags = helmFlags(o);
106
149
  return [
107
150
  `cd ${shq(o.remoteChart)}`,
108
151
  ["helm", "upgrade", "--install", o.release, ".", "-n", o.namespace, ...flags].map(shq).join(" "),
@@ -110,87 +153,152 @@ export function helmUpgradeCommand(o: {
110
153
  ].join(" && ");
111
154
  }
112
155
 
156
+ /** One image to get onto the host before the upgrade. */
157
+ export interface HostImage {
158
+ repository: string;
159
+ tag: string;
160
+ digest?: string;
161
+ /** An image tarball to stream in; without one the image is pulled from its registry. */
162
+ tar?: string;
163
+ }
164
+
165
+ /**
166
+ * Over SSH: get each image into the host's k3s containerd, copy the chart (with any generated values
167
+ * files in it), `helm upgrade --install`, then wait for each rollout.
168
+ */
169
+ export async function deployOverSsh(
170
+ ctx: Ctx,
171
+ o: {
172
+ host: string;
173
+ key: string;
174
+ chartDir: string;
175
+ release: string;
176
+ namespace: string;
177
+ hostImages: readonly HostImage[];
178
+ flags: HelmFlagOptions;
179
+ deployments: readonly string[];
180
+ rolloutTimeoutSeconds: number;
181
+ },
182
+ ): Promise<void> {
183
+ const ssh = await sshSession(ctx, { host: o.host, key: o.key });
184
+ for (const image of o.hostImages) {
185
+ if (image.tar) {
186
+ ctx.log("importing image", { image: `${image.repository}:${image.tag}`, host: o.host });
187
+ // Streamed over ssh's stdin (ctx.exec has none, hence the shell redirect).
188
+ await ctx.sh(`${ssh.shell(K3S_IMAGE_IMPORT)} < ${shq(image.tar)}`);
189
+ } else {
190
+ const ref = image.digest ? `${image.repository}@${image.digest}` : `${image.repository}:${image.tag}`;
191
+ ctx.log("pulling image", { image: ref, host: o.host });
192
+ await ssh.run(`${K3S_IMAGE_PULL} ${shq(ref)}`);
193
+ }
194
+ }
195
+ const remoteChart = `/tmp/${o.release}-helm-${o.namespace}`;
196
+ ctx.log("helm upgrade", { release: o.release, namespace: o.namespace, host: o.host });
197
+ await ssh.run(`rm -rf ${shq(remoteChart)}`);
198
+ await ssh.copy(o.chartDir, remoteChart);
199
+ await ssh.run(helmUpgradeCommand({ ...o.flags, remoteChart, release: o.release, namespace: o.namespace }));
200
+ for (const d of o.deployments) {
201
+ const target = d.includes("/") ? d : `deploy/${d}`;
202
+ ctx.log("waiting for rollout", { deployment: target });
203
+ await ssh.run(`kubectl -n ${shq(o.namespace)} rollout status ${shq(target)} --timeout=${o.rolloutTimeoutSeconds}s`);
204
+ }
205
+ }
206
+
207
+ /** The chart directory inside a fetched source tree, or an error naming it. */
208
+ export function chartDirectory(tree: string, chart: string): string {
209
+ const dir = join(tree, chart);
210
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`chart directory '${chart}' not found in the source tree`);
211
+ return dir;
212
+ }
213
+
113
214
  /**
114
215
  * Deploys images with the Helm chart at `<source>/<chart>` on a k3s host over SSH: get each image
115
216
  * into the host's k3s containerd (stream a tarball in, or pull from the registry), copy the chart,
116
217
  * `helm upgrade --install`, wait for each rollout, then (with `healthUrl`) poll the health endpoint.
117
218
  */
219
+ export function helmDeployOverSsh<
220
+ const N extends string,
221
+ const K extends string,
222
+ const Im extends Record<string, string> = DefaultImages,
223
+ const Rf extends Record<string, string> = {},
224
+ >(def: HelmDeploySshTargetDef<N, K, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K> & LibraryStep;
118
225
  export function helmDeployOverSsh<
119
226
  const N extends string,
120
227
  const K extends string,
121
228
  const Im extends Record<string, string> = DefaultImages,
122
229
  const R extends boolean = false,
123
- >(def: HelmDeployOverSshDef<N, K, Im, R>): Step<N, HelmDeployOverSshInput<Im, R>, typeof HelmRelease, K> & LibraryStep {
124
- const values = def.values ?? [];
125
- const set = def.set ?? {};
230
+ const Rf extends Record<string, string> = {},
231
+ >(def: HelmDeployOverSshDef<N, K, Im, R, Rf>): Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K> & LibraryStep;
232
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
233
+ export function helmDeployOverSsh(def: any): Step & LibraryStep {
234
+ return "target" in def ? helmDeployOverSshTarget(def) : helmDeployOverSshLiteral(def);
235
+ }
236
+
237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
+ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any, boolean, any>): Step & LibraryStep {
126
239
  const images: Record<string, string> = def.images ?? { image: "image" };
127
240
  const imageEntries = Object.entries(images);
128
241
  const fromRegistry = def.fromRegistry === true;
129
- const deployments = def.deployments ?? [def.release];
130
242
  const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
131
- const health = typeof def.healthUrl === "string" ? { url: def.healthUrl } : def.healthUrl;
132
243
  if (imageEntries.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': images must not be empty`);
133
244
  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`);
245
+ if (def.deployments?.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
246
+ const imageRefs: Record<string, string> = def.imageRefs ?? {};
247
+ const refEntries = Object.entries(imageRefs);
248
+ if ("source" in imageRefs) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
249
+ for (const [key] of refEntries) if (key in images) throw new Error(`helmDeployOverSsh '${def.name}': '${key}' is both in images and imageRefs`);
135
250
  const imageModel = fromRegistry ? PushedImage : BuiltImage;
136
- const input = { source: Source, ...Object.fromEntries(imageEntries.map(([key]) => [key, imageModel])) };
251
+ const input = {
252
+ source: Source,
253
+ ...Object.fromEntries(imageEntries.map(([key]) => [key, imageModel])),
254
+ ...Object.fromEntries(refEntries.map(([key]) => [key, PushedImage])),
255
+ };
137
256
  const isDefaultImages = imageEntries.length === 1 && images.image === "image";
257
+ const configurable = { host: def.host, release: def.release, namespace: def.namespace, values: def.values ?? [], set: def.set ?? {}, healthUrl: def.healthUrl };
138
258
 
139
259
  return makeStep(
140
260
  "helmDeployOverSsh",
141
261
  { name: def.name, description: def.description, image: HELM_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.sshKey], timeoutSeconds: def.timeoutSeconds },
142
262
  {
143
263
  manifestConfig: () => ({
144
- host: def.host,
264
+ host: recordConfigRefs(def.host),
145
265
  sshKey: def.sshKey.name,
146
- release: def.release,
147
- namespace: def.namespace,
266
+ release: recordConfigRefs(def.release),
267
+ namespace: recordConfigRefs(def.namespace),
148
268
  chart: def.chart,
149
- values: [...values],
150
- set: { ...set },
269
+ values: recordConfigRefs(configurable.values),
270
+ set: recordConfigRefs(configurable.set),
151
271
  ...(isDefaultImages ? {} : { images: { ...images } }),
152
272
  ...(fromRegistry ? { fromRegistry: true } : {}),
273
+ ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
153
274
  ...(def.deployments ? { deployments: [...def.deployments] } : {}),
154
275
  ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
155
- ...(def.healthUrl !== undefined ? { healthUrl: typeof def.healthUrl === "string" ? def.healthUrl : { ...def.healthUrl } } : {}),
276
+ ...(def.healthUrl !== undefined ? { healthUrl: recordConfigRefs(def.healthUrl) } : {}),
156
277
  }),
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);
159
- const chartDir = join(tree, def.chart);
160
- if (!existsSync(chartDir) || !statSync(chartDir).isDirectory()) throw new Error(`chart directory '${def.chart}' not found in the source tree`);
161
-
162
- const ssh = await sshSession(ctx, { host: def.host, key: ctx.secrets[def.sshKey.name]! });
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[] = [];
163
285
  const flags: HelmImageFlags[] = [];
164
286
  for (const [key, prefix] of imageEntries) {
165
287
  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
- }
288
+ hostImages.push({ repository: image.repository, tag: image.tag, ...(fromRegistry ? {} : { tar: await ctx.files.fetch(image.tar!) }) });
176
289
  flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
177
290
  }
178
-
179
- const remoteChart = `/tmp/${def.release}-helm-${def.namespace}`;
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 }));
184
-
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
- }
190
-
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;
191
298
  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}` };
299
+ return { release: d.release, namespace: d.namespace, image: `${flags[0]!.repository}:${flags[0]!.tag}` };
193
300
  },
194
301
  },
302
+ configurable,
195
303
  ) as never;
196
304
  }
@@ -0,0 +1,268 @@
1
+ // Helm deploys to a target from config (`helmTarget()`): steps.helmDeployInCluster runs helm in the step
2
+ // pod against its own cluster; steps.helmDeployOverSsh({ target, … }) runs it on a k3s host. Pick one
3
+ // per target kind with `select`. Images come from a registry, pinned by digest.
4
+ import { existsSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { z } from "zod";
7
+ import type { Ctx } from "../ctx/types.ts";
8
+ import type { Configurable } from "../core/config.ts";
9
+ import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
10
+ import type { FileRef } from "../core/models.ts";
11
+ import { optional } from "../core/models.ts";
12
+ import type { Secret } from "../core/secrets.ts";
13
+ import { secretRef } from "../core/config.ts";
14
+ import { makeStep, type LibraryStep, type LiteralName, type Step, type StepKind } from "../core/steps.ts";
15
+ import type { Simplify } from "../core/types.ts";
16
+ import { serviceUrl } from "./checks.ts";
17
+ import { chartDirectory, deployOverSsh, DEFAULT_ROLLOUT_TIMEOUT_SECONDS, HELM_DEPLOY_IMAGE, helmFlags, type HelmImageFlags, type HelmImageRefFlags } from "./helm-deploy-ssh.ts";
18
+ import { httpHealthCheck } from "./http-health.ts";
19
+ import { HelmRelease, PushedImage, Source } from "./models.ts";
20
+
21
+ /** helm 3 on Alpine, for in-cluster deploys (the health check runs in the harness). */
22
+ export const HELM_IMAGE = "alpine/helm:3.16.2";
23
+
24
+ /** Where the step pod finds its ServiceAccount token (mounted only with the instance's step ServiceAccount). */
25
+ export const SERVICE_ACCOUNT_TOKEN = "/var/run/secrets/kubernetes.io/serviceaccount/token";
26
+
27
+ function targetVariants<const E extends z.ZodRawShape, const K extends string>(o: Omit<DeployTargetOptions<E, K>, "composeToken"> & { composeToken?: Secret<K> }) {
28
+ const name = (d: string | undefined) => (d === undefined ? z.string() : z.string().default(d));
29
+ const common = {
30
+ namespace: name(o.namespace).describe("Namespace the release is installed into."),
31
+ release: name(o.release).describe("Helm release name."),
32
+ values: z.array(z.string()).default([]).describe("Values files, relative to the chart directory (-f, in order)."),
33
+ set: z.record(z.string(), z.string()).default({}).describe("Extra --set flags."),
34
+ url: z.string().optional().describe("Base URL step pods reach the release at (health checks, smoke tests); default http://<release>.<namespace>.svc.cluster.local."),
35
+ ...(o.extend ?? ({} as E)),
36
+ };
37
+ const inCluster = z.object({ kind: z.literal("inCluster").describe("helm runs in the step pod against its own cluster (needs the instance's step ServiceAccount)."), ...common });
38
+ const ssh = z.object({ kind: z.literal("ssh").describe("helm runs on a k3s host over SSH."), host: z.string().describe("user@host"), ...common });
39
+ const compose = z.object({
40
+ kind: z.literal("compose").describe("A Docker Compose deployer recreates a service with the image."),
41
+ deployerUrl: z.string().describe("The deployer's base URL (POST <deployerUrl>/v1/deploy)."),
42
+ service: z.string().describe("The compose service to recreate."),
43
+ token: secretRef(o.composeToken ?? ({ kind: "secret", name: "DEPLOYER_TOKEN", description: "" } as Secret<K>)).describe("The deployer's bearer token."),
44
+ healthUrl: z.string().optional().describe("Checked by the step after the deployer's own health check, when the step can reach it."),
45
+ ...(o.extend ?? ({} as E)),
46
+ });
47
+ const none = z.object({ kind: z.literal("none").describe("Not deployed: the target's steps are left out.") });
48
+ return { inCluster, ssh, compose, none };
49
+ }
50
+
51
+ export interface DeployTargetOptions<E extends z.ZodRawShape, K extends string> {
52
+ /** Default namespace (helm targets). */
53
+ namespace?: string;
54
+ /** Default release name (helm targets). */
55
+ release?: string;
56
+ /** Fields added to every kind but `none` (e.g. per-target auth overrides). */
57
+ extend?: E;
58
+ /** The secret holding the compose deployer's token (`kind: compose`'s `token: { secret: … }`). */
59
+ composeToken: Secret<K>;
60
+ /** Adds `kind: none`, a target that isn't deployed (e.g. an optional beta). */
61
+ optional?: boolean;
62
+ }
63
+
64
+ /**
65
+ * The config schema of a deploy target, a union on `kind` to `select` on: `inCluster` and `ssh` (helm),
66
+ * `compose` (a Docker Compose deployer), and with `optional: true` also `none` (not deployed).
67
+ * `config("target", "…", z.object({ beta: deployTarget({ optional: true, composeToken: T }), prod: deployTarget({ composeToken: T }) }))`
68
+ */
69
+ export function deployTarget<const K extends string, const E extends z.ZodRawShape = {}>(o: DeployTargetOptions<E, K> & { optional: true }): z.ZodDiscriminatedUnion<[
70
+ ReturnType<typeof targetVariants<E, K>>["inCluster"], ReturnType<typeof targetVariants<E, K>>["ssh"], ReturnType<typeof targetVariants<E, K>>["compose"], ReturnType<typeof targetVariants<E, K>>["none"],
71
+ ]>;
72
+ export function deployTarget<const K extends string, const E extends z.ZodRawShape = {}>(o: DeployTargetOptions<E, K>): z.ZodDiscriminatedUnion<[
73
+ ReturnType<typeof targetVariants<E, K>>["inCluster"], ReturnType<typeof targetVariants<E, K>>["ssh"], ReturnType<typeof targetVariants<E, K>>["compose"],
74
+ ]>;
75
+ export function deployTarget(o: DeployTargetOptions<z.ZodRawShape, string>): unknown {
76
+ const v = targetVariants(o);
77
+ return z.discriminatedUnion("kind", o.optional ? [v.inCluster, v.ssh, v.compose, v.none] : [v.inCluster, v.ssh, v.compose]);
78
+ }
79
+
80
+ /** A helm-only deploy target (`inCluster | ssh`). */
81
+ export function helmTarget<const E extends z.ZodRawShape = {}>(defaults: { namespace?: string; release?: string } = {}, extend?: E) {
82
+ const v = targetVariants({ ...defaults, extend });
83
+ return z.discriminatedUnion("kind", [v.inCluster, v.ssh]);
84
+ }
85
+
86
+ interface TargetCommon {
87
+ namespace: string;
88
+ release: string;
89
+ values: readonly string[];
90
+ set: Readonly<Record<string, string>>;
91
+ url?: string | undefined;
92
+ }
93
+ /** A resolved `kind: inCluster` target. */
94
+ export type InClusterTargetValue = TargetCommon & { kind: "inCluster" };
95
+ /** A resolved `kind: ssh` target. */
96
+ export type SshTargetValue = TargetCommon & { kind: "ssh"; host: string };
97
+ /** A resolved deploy target (the output of `helmTarget()`). */
98
+ export type HelmTargetValue = InClusterTargetValue | SshTargetValue;
99
+
100
+ type DefaultImages = { readonly image: "image" };
101
+ export type HelmTargetDeployInput<Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}> = Simplify<
102
+ { readonly source: typeof Source } & { readonly [K in keyof Im]: typeof PushedImage } & { readonly [K in keyof Rf]: ReturnType<typeof optional<typeof PushedImage>> }
103
+ >;
104
+
105
+ /** What both target deploys take (besides `target`). */
106
+ export interface HelmTargetDeployDef<N extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}> {
107
+ name: LiteralName<N>;
108
+ /** What the step does, shown in the UI. Required; may be empty. */
109
+ description: string;
110
+ /** Chart directory inside the source tree, e.g. `helm`. */
111
+ chart: string;
112
+ /**
113
+ * Values written as files and passed after the target's values files (`-f`, in order). Config
114
+ * references are resolved first; undefined members are dropped, so `{ auth: target.auth }`
115
+ * overrides only when set.
116
+ */
117
+ inlineValues?: readonly Readonly<Record<string, unknown>>[];
118
+ /**
119
+ * 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
+ */
122
+ images?: Im & { readonly source?: never };
123
+ /**
124
+ * Registry images the chart only references: input key → values prefix. Each input is an
125
+ * `optional(PushedImage)`; when its producer was skipped the values are left alone.
126
+ */
127
+ imageRefs?: Rf & { readonly source?: never };
128
+ /** Rollout wait: per rollout over SSH, `helm --wait --timeout` in-cluster. Default 120. */
129
+ rolloutTimeoutSeconds?: number;
130
+ /** After the rollout, GET `http://<release>.<namespace>.svc.cluster.local<healthPath>` until it returns 2xx. */
131
+ healthPath?: string;
132
+ timeoutSeconds?: number;
133
+ }
134
+
135
+ export interface HelmDeployInClusterDef<N extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}>
136
+ extends HelmTargetDeployDef<N, Im, Rf> {
137
+ /** An in-cluster target, usually a `select` case's reference (`(p, prod) => … target: prod`). */
138
+ target: Configurable<InClusterTargetValue>;
139
+ }
140
+
141
+ export interface HelmDeploySshTargetDef<N extends string, K extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}>
142
+ extends HelmTargetDeployDef<N, Im, Rf> {
143
+ /** An ssh target, usually a `select` case's reference. */
144
+ target: Configurable<SshTargetValue>;
145
+ /** Private key for the target's host. */
146
+ sshKey: Secret<K>;
147
+ /** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name. */
148
+ deployments?: readonly string[];
149
+ }
150
+
151
+ /**
152
+ * Deploys registry images, pinned by digest, with the Helm chart at `<source>/<chart>` into the
153
+ * cluster the step runs in: `helm upgrade --install --wait` in the step pod, with the instance's step
154
+ * ServiceAccount token (`clusterAccess`). Then (with `healthPath`) polls the release's Service.
155
+ */
156
+ export function helmDeployInCluster<
157
+ const N extends string,
158
+ const Im extends Record<string, string> = DefaultImages,
159
+ const Rf extends Record<string, string> = {},
160
+ >(def: HelmDeployInClusterDef<N, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never> & LibraryStep {
161
+ return targetDeploy("helmDeployInCluster", def as unknown as AnyTargetDef, undefined) as never;
162
+ }
163
+
164
+ /** 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 {
166
+ return targetDeploy("helmDeployOverSsh", def as unknown as AnyTargetDef, def.sshKey, def.deployments) as never;
167
+ }
168
+
169
+ interface AnyTargetDef {
170
+ name: string;
171
+ description: string;
172
+ chart: string;
173
+ target: unknown;
174
+ inlineValues?: readonly Readonly<Record<string, unknown>>[];
175
+ images?: Readonly<Record<string, string>>;
176
+ imageRefs?: Readonly<Record<string, string>>;
177
+ rolloutTimeoutSeconds?: number;
178
+ healthPath?: string;
179
+ timeoutSeconds?: number;
180
+ }
181
+
182
+ function targetDeploy(kind: StepKind, def: AnyTargetDef, sshKey: Secret | undefined, deployments?: readonly string[]): Step & LibraryStep {
183
+ const images: Record<string, string> = { ...(def.images ?? { image: "image" }) };
184
+ const imageEntries = Object.entries(images);
185
+ const imageRefs: Record<string, string> = { ...(def.imageRefs ?? {}) };
186
+ const refEntries = Object.entries(imageRefs);
187
+ 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
+ if ("source" in images || "source" in imageRefs) throw new Error(`${kind} '${def.name}': 'source' can't be an image input`);
190
+ for (const [key] of refEntries) if (key in images) throw new Error(`${kind} '${def.name}': '${key}' is both in images and imageRefs`);
191
+ if (deployments?.length === 0) throw new Error(`${kind} '${def.name}': deployments must not be empty`);
192
+ if (def.healthPath !== undefined && !def.healthPath.startsWith("/")) throw new Error(`${kind} '${def.name}': healthPath must start with '/'`);
193
+ const inCluster = kind === "helmDeployInCluster";
194
+ const input = {
195
+ source: Source,
196
+ ...Object.fromEntries(imageEntries.map(([key]) => [key, PushedImage])),
197
+ ...Object.fromEntries(refEntries.map(([key]) => [key, optional(PushedImage)])),
198
+ };
199
+ const configurable = { target: def.target, inlineValues: def.inlineValues ?? [] };
200
+
201
+ return makeStep(
202
+ 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
+ },
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);
224
+
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;
240
+
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
+ }
259
+
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;
268
+ }