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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,22 +3,21 @@
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 { existsSync, statSync } from "node:fs";
6
+ import { existsSync, readFileSync, 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 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,10 +86,20 @@ 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 or with helm's wait. */
91
90
  deployments?: readonly string[];
92
- /** Per rollout (`kubectl rollout status --timeout`). Default 120. */
91
+ /** Per rollout (`kubectl rollout status --timeout`), or for helm's own wait. Default 120. */
93
92
  rolloutTimeoutSeconds?: number;
93
+ /**
94
+ * Pull the dependencies `Chart.yaml` lists (`charts/` is usually not committed) into the chart on the
95
+ * host before installing: `helm pull <name> --repo <repository> --version <version>`, or the `oci://`
96
+ * reference. Default false.
97
+ */
98
+ dependencies?: boolean;
99
+ /** `helm upgrade --wait`: helm waits for all of the release's resources to be ready. Default false. */
100
+ helmWait?: boolean;
101
+ /** `helm upgrade --atomic`: wait like `helmWait`, and roll back when the upgrade fails. Default false. */
102
+ atomic?: boolean;
94
103
  /** After the rollouts, GET this URL until it returns 2xx (`httpHealthCheck`: 5 × 5 s unless given), or fail the step. */
95
104
  healthUrl?: Configurable<string> | ({ url: Configurable<string> } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
96
105
  timeoutSeconds?: number;
@@ -144,15 +153,45 @@ export function helmFlags(o: HelmFlagOptions): string[] {
144
153
  }
145
154
 
146
155
  /** Remote `helm upgrade --install`, run from inside the copied chart directory. */
147
- export function helmUpgradeCommand(o: HelmFlagOptions & { remoteChart: string; release: string; namespace: string }): string {
156
+ export function helmUpgradeCommand(o: HelmFlagOptions & { remoteChart: string; release: string; namespace: string; wait?: HelmWait }): string {
148
157
  const flags = helmFlags(o);
158
+ const wait = o.wait ? [o.wait.atomic ? "--atomic" : "--wait", `--timeout=${o.wait.timeoutSeconds}s`] : [];
149
159
  return [
150
160
  `cd ${shq(o.remoteChart)}`,
151
- ["helm", "upgrade", "--install", o.release, ".", "-n", o.namespace, ...flags].map(shq).join(" "),
161
+ ["helm", "upgrade", "--install", o.release, ".", "-n", o.namespace, ...flags, ...wait].map(shq).join(" "),
152
162
  `cd / && rm -rf ${shq(o.remoteChart)}`,
153
163
  ].join(" && ");
154
164
  }
155
165
 
166
+ /** helm's own wait (`--wait`, or `--atomic` to roll back on failure). */
167
+ export interface HelmWait {
168
+ atomic: boolean;
169
+ timeoutSeconds: number;
170
+ }
171
+
172
+ /** A dependency from `Chart.yaml`. */
173
+ export interface ChartDependency {
174
+ name: string;
175
+ version: string;
176
+ repository: string;
177
+ }
178
+
179
+ /** The dependencies `<chartDir>/Chart.yaml` lists, checked to be pullable (an http(s) or oci repository). */
180
+ export function chartDependencies(chartDir: string): ChartDependency[] {
181
+ const chart = Bun.YAML.parse(readFileSync(join(chartDir, "Chart.yaml"), "utf8")) as { dependencies?: Partial<ChartDependency>[] } | null;
182
+ return (chart?.dependencies ?? []).map((d) => {
183
+ if (!d.name || !d.version || !d.repository || !/^(https?|oci):\/\//.test(d.repository))
184
+ throw new Error(`chart dependency ${JSON.stringify(d)}: needs a name, a version and an http(s) or oci repository`);
185
+ return { name: d.name, version: d.version, repository: d.repository };
186
+ });
187
+ }
188
+
189
+ /** The remote `helm pull` of a dependency into `<remoteChart>/charts`. */
190
+ export function helmPullCommand(dep: ChartDependency, remoteChart: string): string {
191
+ const ref = dep.repository.startsWith("oci://") ? [`${dep.repository.replace(/\/+$/, "")}/${dep.name}`] : [dep.name, "--repo", dep.repository];
192
+ return ["helm", "pull", ...ref, "--version", dep.version, "--destination", `${remoteChart}/charts`].map(shq).join(" ");
193
+ }
194
+
156
195
  /** One image to get onto the host before the upgrade. */
157
196
  export interface HostImage {
158
197
  repository: string;
@@ -178,6 +217,8 @@ export async function deployOverSsh(
178
217
  flags: HelmFlagOptions;
179
218
  deployments: readonly string[];
180
219
  rolloutTimeoutSeconds: number;
220
+ dependencies?: readonly ChartDependency[];
221
+ wait?: HelmWait;
181
222
  },
182
223
  ): Promise<void> {
183
224
  const ssh = await sshSession(ctx, { host: o.host, key: o.key });
@@ -196,7 +237,12 @@ export async function deployOverSsh(
196
237
  ctx.log("helm upgrade", { release: o.release, namespace: o.namespace, host: o.host });
197
238
  await ssh.run(`rm -rf ${shq(remoteChart)}`);
198
239
  await ssh.copy(o.chartDir, remoteChart);
199
- await ssh.run(helmUpgradeCommand({ ...o.flags, remoteChart, release: o.release, namespace: o.namespace }));
240
+ if (o.dependencies?.length) await ssh.run(`mkdir -p ${shq(`${remoteChart}/charts`)}`);
241
+ for (const dep of o.dependencies ?? []) {
242
+ ctx.log("pulling chart dependency", { chart: `${dep.name}@${dep.version}`, repository: dep.repository });
243
+ await ssh.run(helmPullCommand(dep, remoteChart));
244
+ }
245
+ await ssh.run(helmUpgradeCommand({ ...o.flags, remoteChart, release: o.release, namespace: o.namespace, ...(o.wait ? { wait: o.wait } : {}) }));
200
246
  for (const d of o.deployments) {
201
247
  const target = d.includes("/") ? d : `deploy/${d}`;
202
248
  ctx.log("waiting for rollout", { deployment: target });
@@ -221,26 +267,26 @@ export function helmDeployOverSsh<
221
267
  const K extends string,
222
268
  const Im extends Record<string, string> = DefaultImages,
223
269
  const Rf extends Record<string, string> = {},
224
- >(def: HelmDeploySshTargetDef<N, K, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K> & LibraryStep;
270
+ >(def: HelmDeploySshTargetDef<N, K, Im, Rf>): Implementation<Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K>>;
225
271
  export function helmDeployOverSsh<
226
272
  const N extends string,
227
273
  const K extends string,
228
274
  const Im extends Record<string, string> = DefaultImages,
229
275
  const R extends boolean = false,
230
276
  const Rf extends Record<string, string> = {},
231
- >(def: HelmDeployOverSshDef<N, K, Im, R, Rf>): Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K> & LibraryStep;
277
+ >(def: HelmDeployOverSshDef<N, K, Im, R, Rf>): Implementation<Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K>>;
232
278
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
233
- export function helmDeployOverSsh(def: any): Step & LibraryStep {
279
+ export function helmDeployOverSsh(def: any): any {
234
280
  return "target" in def ? helmDeployOverSshTarget(def) : helmDeployOverSshLiteral(def);
235
281
  }
236
282
 
237
283
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
- function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any, boolean, any>): Step & LibraryStep {
284
+ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any, boolean, any>): Implementation {
239
285
  const images: Record<string, string> = def.images ?? { image: "image" };
240
286
  const imageEntries = Object.entries(images);
241
287
  const fromRegistry = def.fromRegistry === true;
242
288
  const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
243
- if (imageEntries.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': images must not be empty`);
289
+ const wait: HelmWait | undefined = def.atomic || def.helmWait ? { atomic: def.atomic === true, timeoutSeconds: rolloutTimeout } : undefined;
244
290
  if ("source" in images) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
245
291
  if (def.deployments?.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
246
292
  const imageRefs: Record<string, string> = def.imageRefs ?? {};
@@ -256,49 +302,57 @@ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any,
256
302
  const isDefaultImages = imageEntries.length === 1 && images.image === "image";
257
303
  const configurable = { host: def.host, release: def.release, namespace: def.namespace, values: def.values ?? [], set: def.set ?? {}, healthUrl: def.healthUrl };
258
304
 
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
- },
305
+ const contract = step({
306
+ name: def.name,
307
+ description: def.description,
308
+ kind: "helmDeployOverSsh",
309
+ image: HELM_DEPLOY_IMAGE,
310
+ input,
311
+ output: HelmRelease,
312
+ secrets: [def.sshKey],
313
+ timeoutSeconds: def.timeoutSeconds,
314
+ settings: {
315
+ host: def.host,
316
+ sshKey: def.sshKey.name,
317
+ release: def.release,
318
+ namespace: def.namespace,
319
+ chart: def.chart,
320
+ values: configurable.values,
321
+ set: configurable.set,
322
+ ...(isDefaultImages ? {} : { images: { ...images } }),
323
+ ...(fromRegistry ? { fromRegistry: true } : {}),
324
+ ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
325
+ ...(def.deployments ? { deployments: [...def.deployments] } : {}),
326
+ ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
327
+ ...(def.dependencies ? { dependencies: true } : {}),
328
+ ...(def.atomic ? { atomic: true } : def.helmWait ? { helmWait: true } : {}),
329
+ ...(def.healthUrl !== undefined ? { healthUrl: def.healthUrl } : {}),
301
330
  },
302
- configurable,
303
- ) as never;
331
+ });
332
+ return implement(contract, async (stepInput, ctx: Ctx) => {
333
+ const input = stepInput as unknown as Record<string, { repository: string; tag: string; digest: string; tar?: FileRef }> & { source: { tree: FileRef } };
334
+ const d = resolveConfigRefs(configurable, ctx.config ?? {}) as {
335
+ host: string; release: string; namespace: string; values: string[]; set: Record<string, string>;
336
+ healthUrl?: string | ({ url: string } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
337
+ };
338
+ const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
339
+ const hostImages: HostImage[] = [];
340
+ const flags: HelmImageFlags[] = [];
341
+ for (const [key, prefix] of imageEntries) {
342
+ const image = input[key]!;
343
+ hostImages.push({ repository: image.repository, tag: image.tag, ...(fromRegistry ? {} : { tar: await ctx.files.fetch(image.tar!) }) });
344
+ flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
345
+ }
346
+ const refs: HelmImageRefFlags[] = refEntries.map(([key, prefix]) => ({ prefix, repository: input[key]!.repository, tag: input[key]!.tag, digest: input[key]!.digest }));
347
+ await deployOverSsh(ctx, {
348
+ host: d.host, key: ctx.secrets[def.sshKey.name]!, chartDir, release: d.release, namespace: d.namespace, hostImages,
349
+ flags: { values: d.values, set: d.set, images: flags, imageRefs: refs },
350
+ deployments: def.deployments ?? (imageEntries.length && !wait ? [d.release] : []), rolloutTimeoutSeconds: rolloutTimeout,
351
+ ...(def.dependencies ? { dependencies: chartDependencies(chartDir) } : {}),
352
+ ...(wait ? { wait } : {}),
353
+ });
354
+ const health = typeof d.healthUrl === "string" ? { url: d.healthUrl } : d.healthUrl;
355
+ if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });
356
+ return { release: d.release, namespace: d.namespace, ...(flags[0] ? { image: `${flags[0].repository}:${flags[0].tag}` } : {}) };
357
+ }) as never;
304
358
  }
@@ -6,12 +6,13 @@ import { join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import type { Ctx } from "../ctx/types.ts";
8
8
  import type { Configurable } from "../core/config.ts";
9
- import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
9
+ import { resolveConfigRefs } from "../core/config-refs.ts";
10
+ import { implement, type Implementation } from "../core/handlers.ts";
10
11
  import type { FileRef } from "../core/models.ts";
11
12
  import { optional } from "../core/models.ts";
12
13
  import type { Secret } from "../core/secrets.ts";
13
14
  import { secretRef } from "../core/config.ts";
14
- import { makeStep, type LibraryStep, type LiteralName, type Step, type StepKind } from "../core/steps.ts";
15
+ import { step, type AnyStep, type LiteralName, type Step } from "../core/steps.ts";
15
16
  import type { Simplify } from "../core/types.ts";
16
17
  import { serviceUrl } from "./checks.ts";
17
18
  import { chartDirectory, deployOverSsh, DEFAULT_ROLLOUT_TIMEOUT_SECONDS, HELM_DEPLOY_IMAGE, helmFlags, type HelmImageFlags, type HelmImageRefFlags } from "./helm-deploy-ssh.ts";
@@ -117,7 +118,8 @@ export interface HelmTargetDeployDef<N extends string, Im extends Record<string,
117
118
  inlineValues?: readonly Readonly<Record<string, unknown>>[];
118
119
  /**
119
120
  * The images to deploy: step input key → the chart's values prefix. Each input is a `PushedImage`;
120
- * the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`.
121
+ * the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`. `{}` deploys
122
+ * the chart alone.
121
123
  */
122
124
  images?: Im & { readonly source?: never };
123
125
  /**
@@ -144,7 +146,7 @@ export interface HelmDeploySshTargetDef<N extends string, K extends string, Im e
144
146
  target: Configurable<SshTargetValue>;
145
147
  /** Private key for the target's host. */
146
148
  sshKey: Secret<K>;
147
- /** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name. */
149
+ /** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name, or none without images. */
148
150
  deployments?: readonly string[];
149
151
  }
150
152
 
@@ -157,12 +159,12 @@ export function helmDeployInCluster<
157
159
  const N extends string,
158
160
  const Im extends Record<string, string> = DefaultImages,
159
161
  const Rf extends Record<string, string> = {},
160
- >(def: HelmDeployInClusterDef<N, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never> & LibraryStep {
162
+ >(def: HelmDeployInClusterDef<N, Im, Rf>): Implementation<Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never>> {
161
163
  return targetDeploy("helmDeployInCluster", def as unknown as AnyTargetDef, undefined) as never;
162
164
  }
163
165
 
164
166
  /** The `target` form of `steps.helmDeployOverSsh`: an ssh target from config, registry images by digest. */
165
- export function helmDeployOverSshTarget(def: HelmDeploySshTargetDef<string, string, Record<string, string>, Record<string, string>>): Step & LibraryStep {
167
+ export function helmDeployOverSshTarget(def: HelmDeploySshTargetDef<string, string, Record<string, string>, Record<string, string>>): Implementation {
166
168
  return targetDeploy("helmDeployOverSsh", def as unknown as AnyTargetDef, def.sshKey, def.deployments) as never;
167
169
  }
168
170
 
@@ -179,13 +181,12 @@ interface AnyTargetDef {
179
181
  timeoutSeconds?: number;
180
182
  }
181
183
 
182
- function targetDeploy(kind: StepKind, def: AnyTargetDef, sshKey: Secret | undefined, deployments?: readonly string[]): Step & LibraryStep {
184
+ function targetDeploy(kind: "helmDeployInCluster" | "helmDeployOverSsh", def: AnyTargetDef, sshKey: Secret | undefined, deployments?: readonly string[]): Implementation {
183
185
  const images: Record<string, string> = { ...(def.images ?? { image: "image" }) };
184
186
  const imageEntries = Object.entries(images);
185
187
  const imageRefs: Record<string, string> = { ...(def.imageRefs ?? {}) };
186
188
  const refEntries = Object.entries(imageRefs);
187
189
  const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
188
- if (imageEntries.length === 0) throw new Error(`${kind} '${def.name}': images must not be empty`);
189
190
  if ("source" in images || "source" in imageRefs) throw new Error(`${kind} '${def.name}': 'source' can't be an image input`);
190
191
  for (const [key] of refEntries) if (key in images) throw new Error(`${kind} '${def.name}': '${key}' is both in images and imageRefs`);
191
192
  if (deployments?.length === 0) throw new Error(`${kind} '${def.name}': deployments must not be empty`);
@@ -198,71 +199,73 @@ function targetDeploy(kind: StepKind, def: AnyTargetDef, sshKey: Secret | undefi
198
199
  };
199
200
  const configurable = { target: def.target, inlineValues: def.inlineValues ?? [] };
200
201
 
201
- return makeStep(
202
+ const contract = step({
203
+ name: def.name,
204
+ description: def.description,
202
205
  kind,
203
- {
204
- name: def.name, description: def.description, image: inCluster ? HELM_IMAGE : HELM_DEPLOY_IMAGE, input, output: HelmRelease,
205
- secrets: sshKey ? [sshKey] : [], timeoutSeconds: def.timeoutSeconds, ...(inCluster ? { clusterAccess: true } : {}),
206
+ image: inCluster ? HELM_IMAGE : HELM_DEPLOY_IMAGE,
207
+ input,
208
+ output: HelmRelease,
209
+ secrets: sshKey ? [sshKey] : [],
210
+ timeoutSeconds: def.timeoutSeconds,
211
+ ...(inCluster ? { clusterAccess: true } : {}),
212
+ settings: {
213
+ target: def.target,
214
+ ...(sshKey ? { sshKey: sshKey.name } : {}),
215
+ chart: def.chart,
216
+ ...(def.inlineValues?.length ? { inlineValues: def.inlineValues } : {}),
217
+ images: { ...images },
218
+ ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
219
+ ...(deployments ? { deployments: [...deployments] } : {}),
220
+ ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
221
+ ...(def.healthPath !== undefined ? { healthPath: def.healthPath } : {}),
206
222
  },
207
- {
208
- manifestConfig: () => ({
209
- target: recordConfigRefs(def.target),
210
- ...(sshKey ? { sshKey: sshKey.name } : {}),
211
- chart: def.chart,
212
- ...(def.inlineValues?.length ? { inlineValues: recordConfigRefs(def.inlineValues) } : {}),
213
- images: { ...images },
214
- ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
215
- ...(deployments ? { deployments: [...deployments] } : {}),
216
- ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
217
- ...(def.healthPath !== undefined ? { healthPath: def.healthPath } : {}),
218
- }),
219
- async run(input: Record<string, { repository: string; tag: string; digest: string } | undefined> & { source: { tree: FileRef } }, ctx: Ctx) {
220
- const { target, inlineValues } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: HelmTargetValue; inlineValues: Record<string, unknown>[] };
221
- const want = inCluster ? "inCluster" : "ssh";
222
- if (target?.kind !== want) throw new Error(`${kind} '${def.name}': the target is kind '${String(target?.kind)}', not '${want}'`);
223
- const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
223
+ } as never) as AnyStep;
224
+ return implement(contract, async (stepInput, ctx: Ctx) => {
225
+ const input = stepInput as unknown as Record<string, { repository: string; tag: string; digest: string } | undefined> & { source: { tree: FileRef } };
226
+ const { target, inlineValues } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: HelmTargetValue; inlineValues: Record<string, unknown>[] };
227
+ const want = inCluster ? "inCluster" : "ssh";
228
+ if (target?.kind !== want) throw new Error(`${kind} '${def.name}': the target is kind '${String(target?.kind)}', not '${want}'`);
229
+ const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
224
230
 
225
- const generated = inlineValues.map((values, i) => {
226
- const file = `olve-values-${i}.json`;
227
- writeFileSync(join(chartDir, file), JSON.stringify(values));
228
- return file;
229
- });
230
- const flags: HelmImageFlags[] = imageEntries.map(([key, prefix]) => {
231
- const image = input[key]!;
232
- return { prefix, repository: image.repository, tag: image.tag, digest: image.digest, pullPolicy: "IfNotPresent" };
233
- });
234
- const refs: HelmImageRefFlags[] = refEntries.flatMap(([key, prefix]) => {
235
- const image = input[key];
236
- return image ? [{ prefix, repository: image.repository, tag: image.tag, digest: image.digest }] : [];
237
- });
238
- const helm = { values: [...target.values, ...generated], set: target.set, images: flags, imageRefs: refs };
239
- const { release, namespace } = target;
231
+ const generated = inlineValues.map((values, i) => {
232
+ const file = `olve-values-${i}.json`;
233
+ writeFileSync(join(chartDir, file), JSON.stringify(values));
234
+ return file;
235
+ });
236
+ const flags: HelmImageFlags[] = imageEntries.map(([key, prefix]) => {
237
+ const image = input[key]!;
238
+ return { prefix, repository: image.repository, tag: image.tag, digest: image.digest, pullPolicy: "IfNotPresent" };
239
+ });
240
+ const refs: HelmImageRefFlags[] = refEntries.flatMap(([key, prefix]) => {
241
+ const image = input[key];
242
+ return image ? [{ prefix, repository: image.repository, tag: image.tag, digest: image.digest }] : [];
243
+ });
244
+ const helm = { values: [...target.values, ...generated], set: target.set, images: flags, imageRefs: refs };
245
+ const { release, namespace } = target;
240
246
 
241
- if (target.kind === "ssh") {
242
- const key = ctx.secrets[sshKey!.name];
243
- if (!key) throw new Error(`secret ${sshKey!.name} (sshKey) is not set`);
244
- await deployOverSsh(ctx, {
245
- host: target.host, key, chartDir, release, namespace,
246
- hostImages: flags.map((f) => ({ repository: f.repository, tag: f.tag, digest: f.digest })),
247
- flags: helm, deployments: deployments ?? [release], rolloutTimeoutSeconds: rolloutTimeout,
248
- });
249
- } else {
250
- if (!existsSync(SERVICE_ACCOUNT_TOKEN))
251
- throw new Error(`no ServiceAccount token in the step pod: the instance grants no step ServiceAccount (Kubernetes:StepServiceAccount), so an inCluster target can't be deployed`);
252
- const home = join(ctx.tmpDir, "helm-home");
253
- ctx.log("helm upgrade", { release, namespace, images: flags.map((f) => `${f.repository}@${f.digest}`).join(" ") });
254
- await ctx.exec(["helm", "upgrade", "--install", release, ".", "-n", namespace, ...helmFlags(helm), "--wait", `--timeout=${rolloutTimeout}s`], {
255
- cwd: chartDir,
256
- env: { HOME: home, HELM_CACHE_HOME: join(home, "cache"), HELM_CONFIG_HOME: join(home, "config"), HELM_DATA_HOME: join(home, "data") },
257
- });
258
- }
247
+ if (target.kind === "ssh") {
248
+ const key = ctx.secrets[sshKey!.name];
249
+ if (!key) throw new Error(`secret ${sshKey!.name} (sshKey) is not set`);
250
+ await deployOverSsh(ctx, {
251
+ host: target.host, key, chartDir, release, namespace,
252
+ hostImages: flags.map((f) => ({ repository: f.repository, tag: f.tag, digest: f.digest })),
253
+ flags: helm, deployments: deployments ?? (imageEntries.length ? [release] : []), rolloutTimeoutSeconds: rolloutTimeout,
254
+ });
255
+ } else {
256
+ if (!existsSync(SERVICE_ACCOUNT_TOKEN))
257
+ throw new Error(`no ServiceAccount token in the step pod: the instance grants no step ServiceAccount (Kubernetes:StepServiceAccount), so an inCluster target can't be deployed`);
258
+ const home = join(ctx.tmpDir, "helm-home");
259
+ ctx.log("helm upgrade", { release, namespace, images: flags.map((f) => `${f.repository}@${f.digest}`).join(" ") });
260
+ await ctx.exec(["helm", "upgrade", "--install", release, ".", "-n", namespace, ...helmFlags(helm), "--wait", `--timeout=${rolloutTimeout}s`], {
261
+ cwd: chartDir,
262
+ env: { HOME: home, HELM_CACHE_HOME: join(home, "cache"), HELM_CONFIG_HOME: join(home, "config"), HELM_DATA_HOME: join(home, "data") },
263
+ });
264
+ }
259
265
 
260
- const url = (target.url ?? serviceUrl({ release, namespace })).replace(/\/+$/, "");
261
- if (def.healthPath !== undefined) await httpHealthCheck(ctx, `${url}${def.healthPath}`, { attempts: 12, delaySeconds: 5 });
262
- const main = flags[0]!;
263
- return { release, namespace, image: `${main.repository}:${main.tag}@${main.digest}`, ...(target.url ? { url } : {}) };
264
- },
265
- },
266
- configurable,
267
- ) as never;
266
+ const url = (target.url ?? serviceUrl({ release, namespace })).replace(/\/+$/, "");
267
+ if (def.healthPath !== undefined) await httpHealthCheck(ctx, `${url}${def.healthPath}`, { attempts: 12, delaySeconds: 5 });
268
+ const main = flags[0];
269
+ return { release, namespace, ...(main ? { image: `${main.repository}:${main.tag}@${main.digest}` } : {}), ...(target.url ? { url } : {}) } as never;
270
+ }) as never;
268
271
  }