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

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.
Files changed (65) hide show
  1. package/README.md +165 -78
  2. package/manifest.schema.json +300 -16
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +98 -64
  6. package/src/build/cli.ts +33 -23
  7. package/src/build/elf.ts +16 -7
  8. package/src/build/hashes.ts +40 -51
  9. package/src/build/project.ts +55 -52
  10. package/src/builder/pipeline.ts +348 -89
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +6 -5
  13. package/src/core/config-refs.ts +103 -0
  14. package/src/core/config.ts +224 -0
  15. package/src/core/external.ts +86 -0
  16. package/src/core/handlers.ts +62 -15
  17. package/src/core/matrix.ts +150 -0
  18. package/src/core/models.ts +37 -15
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +113 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +119 -42
  24. package/src/ctx/index.ts +110 -39
  25. package/src/ctx/links.ts +30 -0
  26. package/src/ctx/sh.ts +106 -44
  27. package/src/ctx/types.ts +24 -4
  28. package/src/describe/config-schema.ts +98 -0
  29. package/src/describe/context.ts +187 -0
  30. package/src/describe/describe.ts +63 -355
  31. package/src/describe/failure-handlers.ts +34 -0
  32. package/src/describe/manifest-schema.ts +304 -61
  33. package/src/describe/mapper-source.ts +252 -105
  34. package/src/describe/record.ts +67 -37
  35. package/src/describe/schemas.ts +71 -0
  36. package/src/describe/stages.ts +166 -0
  37. package/src/describe/step.ts +544 -0
  38. package/src/describe/triggers.ts +72 -0
  39. package/src/dev/cli.ts +62 -35
  40. package/src/harness/env.ts +47 -25
  41. package/src/harness/execute.ts +74 -30
  42. package/src/harness/exit.ts +1 -1
  43. package/src/harness/index.ts +19 -8
  44. package/src/harness/main.ts +245 -99
  45. package/src/harness/mask.ts +98 -0
  46. package/src/harness/result.ts +45 -24
  47. package/src/harness/validate.ts +82 -15
  48. package/src/index.ts +139 -10
  49. package/src/library/checks.ts +11 -6
  50. package/src/library/compose-deploy.ts +115 -0
  51. package/src/library/github-source.ts +100 -47
  52. package/src/library/helm-deploy-ssh.ts +426 -112
  53. package/src/library/helm-deploy.ts +529 -0
  54. package/src/library/http-health.ts +7 -5
  55. package/src/library/image-build.ts +510 -162
  56. package/src/library/index.ts +48 -14
  57. package/src/library/models.ts +35 -14
  58. package/src/library/ssh.ts +44 -18
  59. package/src/testing/index.ts +100 -35
  60. package/src/util/canonical.ts +7 -4
  61. package/src/util/code-hash.ts +17 -0
  62. package/src/util/hash.ts +5 -5
  63. package/src/util/tar.ts +83 -38
  64. package/src/library/script.ts +0 -85
  65. package/src/util/handler-hash.ts +0 -22
@@ -3,62 +3,84 @@
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";
7
- import { join } from "node:path";
8
- import type { Ctx } from "../ctx/types.ts";
9
- import type { FileRef } from "../core/models.ts";
10
- import type { Secret } from "../core/secrets.ts";
11
- import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.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";
6
+ import { existsSync, readFileSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import type { Ctx } from '../ctx/types.ts';
9
+ import type { FileRef } from '../core/models.ts';
10
+ import type { Secret } from '../core/secrets.ts';
11
+ import type { Configurable } from '../core/config.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';
15
+ import type { Simplify } from '../core/types.ts';
16
+ import { httpHealthCheck, type HttpHealthCheckOptions } from './http-health.ts';
17
+ import { BuiltImage, HelmRelease, PushedImage, Source } from './models.ts';
18
+ import { shq, sshSession } from './ssh.ts';
19
+ import {
20
+ helmDeployOverSshTarget,
21
+ type HelmDeploySshTargetDef,
22
+ type HelmTargetDeployInput,
23
+ } from './helm-deploy.ts';
18
24
 
19
25
  /** Alpine: `apk add openssh-client` at run time gives ssh/scp (the health check runs in the harness). */
20
- export const HELM_DEPLOY_IMAGE = "alpine:3.22";
26
+ export const HELM_DEPLOY_IMAGE = 'alpine:3.22';
21
27
 
22
28
  /**
23
29
  * Footgun: import via the k3s containerd socket, NOT the default one — kubelet only sees images in
24
30
  * the k3s instance's `k8s.io` namespace (otherwise pods fail with ErrImageNeverPull).
25
31
  */
26
- export const K3S_IMAGE_IMPORT = "sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load";
32
+ export const K3S_IMAGE_IMPORT =
33
+ 'sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load';
27
34
  /** 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";
35
+ export const K3S_IMAGE_PULL =
36
+ 'sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io pull -q';
29
37
 
30
38
  export const DEFAULT_ROLLOUT_TIMEOUT_SECONDS = 120;
31
39
 
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> }
40
+ type DefaultImages = { readonly image: 'image' };
41
+ type ImageModel<R extends boolean> = R extends true
42
+ ? typeof PushedImage
43
+ : typeof BuiltImage;
44
+ export type HelmDeployOverSshInput<
45
+ Im extends Record<string, string> = DefaultImages,
46
+ R extends boolean = false,
47
+ Rf extends Record<string, string> = {},
48
+ > = Simplify<
49
+ { readonly source: typeof Source } & { readonly [K in keyof Im]: ImageModel<R> } & {
50
+ readonly [K in keyof Rf]: typeof PushedImage;
51
+ }
36
52
  >;
37
53
 
38
- export interface HelmDeployOverSshDef<N extends string, K extends string, Im extends Record<string, string> = DefaultImages, R extends boolean = false> {
54
+ export interface HelmDeployOverSshDef<
55
+ N extends string,
56
+ K extends string,
57
+ Im extends Record<string, string> = DefaultImages,
58
+ R extends boolean = false,
59
+ Rf extends Record<string, string> = {},
60
+ > {
39
61
  name: LiteralName<N>;
40
62
  /** What the step does, shown in the UI. Required; may be empty. */
41
63
  description: string;
42
64
  /** SSH target, `user@host`. The user needs passwordless sudo for nerdctl, and helm/kubectl with a kubeconfig. */
43
- host: string;
65
+ host: Configurable<string>;
44
66
  /** Private key for `host`. */
45
67
  sshKey: Secret<K>;
46
68
  /** Helm release name. */
47
- release: string;
48
- namespace: string;
69
+ release: Configurable<string>;
70
+ namespace: Configurable<string>;
49
71
  /** Chart directory inside the source tree, e.g. `helm`. */
50
72
  chart: string;
51
73
  /** Values files, relative to the chart directory (`-f`, in order). */
52
- values?: readonly string[];
74
+ values?: Configurable<readonly string[]>;
53
75
  /**
54
76
  * Chart-specific `--set` flags (e.g. `{ "ingress.enabled": "false" }`). The image flags are added by
55
77
  * the step and always win: `<prefix>.repository`, `<prefix>.tag`, `<prefix>.pullPolicy`.
56
78
  */
57
- set?: Readonly<Record<string, string>>;
79
+ set?: Configurable<Readonly<Record<string, string>>>;
58
80
  /**
59
81
  * The images to deploy: step input key → the chart's values prefix for that image. Each input is a
60
82
  * `BuiltImage` (or `PushedImage` with `fromRegistry`). Default `{ image: "image" }`; several images
61
- * need a mapper, e.g. `{ server: "image", web: "web.image" }`.
83
+ * need a mapper, e.g. `{ server: "image", web: "web.image" }`. `{}` deploys the chart alone.
62
84
  */
63
85
  images?: Im & { readonly source?: never };
64
86
  /**
@@ -66,13 +88,40 @@ export interface HelmDeployOverSshDef<N extends string, K extends string, Im ext
66
88
  * first, then `pullPolicy=IfNotPresent`. Default: `BuiltImage` tarballs, imported on the host, then
67
89
  * `pullPolicy=Never`.
68
90
  */
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. */
91
+ fromRegistry?: R &
92
+ (boolean extends R
93
+ ? '❌ fromRegistry must be a literal true or false (use `as const` on a shared config object)'
94
+ : unknown);
95
+ /**
96
+ * Registry images the chart only references (not run by the release's own pods, e.g. an image the
97
+ * app starts Jobs with): step input key → values prefix. Each input is a `PushedImage`; the step sets
98
+ * `<prefix>.repository`, `<prefix>.tag` and `<prefix>.digest` and does not pull it on the host. Works
99
+ * next to tarball `images`.
100
+ */
101
+ imageRefs?: Rf & { readonly source?: never };
102
+ /** 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. */
71
103
  deployments?: readonly string[];
72
- /** Per rollout (`kubectl rollout status --timeout`). Default 120. */
104
+ /** Per rollout (`kubectl rollout status --timeout`), or for helm's own wait. Default 120. */
73
105
  rolloutTimeoutSeconds?: number;
106
+ /**
107
+ * Pull the dependencies `Chart.yaml` lists (`charts/` is usually not committed) into the chart on the
108
+ * host before installing: `helm pull <name> --repo <repository> --version <version>`, or the `oci://`
109
+ * reference. Default false.
110
+ */
111
+ dependencies?: boolean;
112
+ /** `helm upgrade --wait`: helm waits for all of the release's resources to be ready. Default false. */
113
+ helmWait?: boolean;
114
+ /** `helm upgrade --atomic`: wait like `helmWait`, and roll back when the upgrade fails. Default false. */
115
+ atomic?: boolean;
116
+ /** Public URL of what the release serves; the step links to it after a successful deploy. */
117
+ link?: Configurable<string>;
74
118
  /** 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">);
119
+ healthUrl?:
120
+ | Configurable<string>
121
+ | ({ url: Configurable<string> } & Pick<
122
+ HttpHealthCheckOptions,
123
+ 'attempts' | 'delaySeconds'
124
+ >);
76
125
  timeoutSeconds?: number;
77
126
  }
78
127
 
@@ -80,34 +129,218 @@ export interface HelmImageFlags {
80
129
  prefix: string;
81
130
  repository: string;
82
131
  tag: string;
83
- pullPolicy: "Never" | "IfNotPresent";
132
+ pullPolicy: 'Never' | 'IfNotPresent';
133
+ /** Pins the image: `<prefix>.digest` (the chart renders `repository:tag@digest`). */
134
+ digest?: string;
84
135
  }
85
136
 
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;
137
+ /** A registry image the chart only references (`imageRefs`). */
138
+ export interface HelmImageRefFlags {
139
+ prefix: string;
140
+ repository: string;
141
+ tag: string;
142
+ digest: string;
143
+ }
144
+
145
+ export interface HelmFlagOptions {
91
146
  values: readonly string[];
92
147
  set: Readonly<Record<string, string>>;
93
148
  images: readonly HelmImageFlags[];
94
- }): string {
149
+ imageRefs?: readonly HelmImageRefFlags[];
150
+ }
151
+
152
+ /** `helm upgrade` flags: values files, `--set`, then the image flags (which win). */
153
+ export function helmFlags(o: HelmFlagOptions): string[] {
95
154
  // Chart-relative values paths (`-f values-beta.yaml`) resolve because helm runs from inside the
96
155
  // copied chart dir. Extra flags come before the image flags, so the image overrides win.
156
+ // --set-string: a tag like `2026.10` or `1e5` would otherwise be parsed as a number.
97
157
  const flags = [
98
- ...o.values.flatMap((v) => ["-f", v]),
99
- ...Object.entries(o.set).flatMap(([k, v]) => ["--set", `${k}=${v}`]),
158
+ ...o.values.flatMap((v) => ['-f', v]),
159
+ ...Object.entries(o.set).flatMap(([k, v]) => ['--set', `${k}=${v}`]),
100
160
  ...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}`,
161
+ '--set',
162
+ `${i.prefix}.repository=${i.repository}`,
163
+ '--set',
164
+ `${i.prefix}.tag=${i.tag}`,
165
+ '--set',
166
+ `${i.prefix}.pullPolicy=${i.pullPolicy}`,
167
+ ...(i.digest ? ['--set-string', `${i.prefix}.digest=${i.digest}`] : []),
168
+ ]),
169
+ ...(o.imageRefs ?? []).flatMap((i) => [
170
+ '--set-string',
171
+ `${i.prefix}.repository=${i.repository}`,
172
+ '--set-string',
173
+ `${i.prefix}.tag=${i.tag}`,
174
+ '--set-string',
175
+ `${i.prefix}.digest=${i.digest}`,
104
176
  ]),
105
177
  ];
178
+ return flags;
179
+ }
180
+
181
+ /** Remote `helm upgrade --install`, run from inside the copied chart directory. */
182
+ export function helmUpgradeCommand(
183
+ o: HelmFlagOptions & {
184
+ remoteChart: string;
185
+ release: string;
186
+ namespace: string;
187
+ wait?: HelmWait;
188
+ },
189
+ ): string {
190
+ const flags = helmFlags(o);
191
+ const wait = o.wait
192
+ ? [o.wait.atomic ? '--atomic' : '--wait', `--timeout=${o.wait.timeoutSeconds}s`]
193
+ : [];
106
194
  return [
107
195
  `cd ${shq(o.remoteChart)}`,
108
- ["helm", "upgrade", "--install", o.release, ".", "-n", o.namespace, ...flags].map(shq).join(" "),
196
+ ['helm', 'upgrade', '--install', o.release, '.', '-n', o.namespace, ...flags, ...wait]
197
+ .map(shq)
198
+ .join(' '),
109
199
  `cd / && rm -rf ${shq(o.remoteChart)}`,
110
- ].join(" && ");
200
+ ].join(' && ');
201
+ }
202
+
203
+ /** helm's own wait (`--wait`, or `--atomic` to roll back on failure). */
204
+ export interface HelmWait {
205
+ atomic: boolean;
206
+ timeoutSeconds: number;
207
+ }
208
+
209
+ /** A dependency from `Chart.yaml`. */
210
+ export interface ChartDependency {
211
+ name: string;
212
+ version: string;
213
+ repository: string;
214
+ }
215
+
216
+ /** The dependencies `<chartDir>/Chart.yaml` lists, checked to be pullable (an http(s) or oci repository). */
217
+ export function chartDependencies(chartDir: string): ChartDependency[] {
218
+ const chart = Bun.YAML.parse(readFileSync(join(chartDir, 'Chart.yaml'), 'utf8')) as {
219
+ dependencies?: Partial<ChartDependency>[];
220
+ } | null;
221
+ return (chart?.dependencies ?? []).map((d) => {
222
+ if (
223
+ !d.name ||
224
+ !d.version ||
225
+ !d.repository ||
226
+ !/^(https?|oci):\/\//.test(d.repository)
227
+ )
228
+ throw new Error(
229
+ `chart dependency ${JSON.stringify(d)}: needs a name, a version and an http(s) or oci repository`,
230
+ );
231
+ return { name: d.name, version: d.version, repository: d.repository };
232
+ });
233
+ }
234
+
235
+ /** The remote `helm pull` of a dependency into `<remoteChart>/charts`. */
236
+ export function helmPullCommand(dep: ChartDependency, remoteChart: string): string {
237
+ const ref = dep.repository.startsWith('oci://')
238
+ ? [`${dep.repository.replace(/\/+$/, '')}/${dep.name}`]
239
+ : [dep.name, '--repo', dep.repository];
240
+ return [
241
+ 'helm',
242
+ 'pull',
243
+ ...ref,
244
+ '--version',
245
+ dep.version,
246
+ '--destination',
247
+ `${remoteChart}/charts`,
248
+ ]
249
+ .map(shq)
250
+ .join(' ');
251
+ }
252
+
253
+ /** One image to get onto the host before the upgrade. */
254
+ export interface HostImage {
255
+ repository: string;
256
+ tag: string;
257
+ digest?: string;
258
+ /** An image tarball to stream in; without one the image is pulled from its registry. */
259
+ tar?: string;
260
+ }
261
+
262
+ /**
263
+ * Over SSH: get each image into the host's k3s containerd, copy the chart (with any generated values
264
+ * files in it), `helm upgrade --install`, then wait for each rollout.
265
+ */
266
+ export async function deployOverSsh(
267
+ ctx: Ctx,
268
+ o: {
269
+ host: string;
270
+ key: string;
271
+ chartDir: string;
272
+ release: string;
273
+ namespace: string;
274
+ hostImages: readonly HostImage[];
275
+ flags: HelmFlagOptions;
276
+ deployments: readonly string[];
277
+ rolloutTimeoutSeconds: number;
278
+ dependencies?: readonly ChartDependency[];
279
+ wait?: HelmWait;
280
+ /** Files in the chart holding secret values: deleted from the host after the upgrade, also when it fails (a successful upgrade removes the whole chart). */
281
+ secretFiles?: readonly string[];
282
+ },
283
+ ): Promise<void> {
284
+ const ssh = await sshSession(ctx, { host: o.host, key: o.key });
285
+ for (const image of o.hostImages) {
286
+ if (image.tar) {
287
+ ctx.log('importing image', {
288
+ image: `${image.repository}:${image.tag}`,
289
+ host: o.host,
290
+ });
291
+ // Streamed over ssh's stdin (ctx.exec has none, hence the shell redirect).
292
+ await ctx.sh(`${ssh.shell(K3S_IMAGE_IMPORT)} < ${shq(image.tar)}`);
293
+ } else {
294
+ const ref = image.digest
295
+ ? `${image.repository}@${image.digest}`
296
+ : `${image.repository}:${image.tag}`;
297
+ ctx.log('pulling image', { image: ref, host: o.host });
298
+ await ssh.run(`${K3S_IMAGE_PULL} ${shq(ref)}`);
299
+ }
300
+ }
301
+ const remoteChart = `/tmp/${o.release}-helm-${o.namespace}`;
302
+ ctx.log('helm upgrade', { release: o.release, namespace: o.namespace, host: o.host });
303
+ await ssh.run(`rm -rf ${shq(remoteChart)}`);
304
+ await ssh.copy(o.chartDir, remoteChart);
305
+ if (o.dependencies?.length) await ssh.run(`mkdir -p ${shq(`${remoteChart}/charts`)}`);
306
+ for (const dep of o.dependencies ?? []) {
307
+ ctx.log('pulling chart dependency', {
308
+ chart: `${dep.name}@${dep.version}`,
309
+ repository: dep.repository,
310
+ });
311
+ await ssh.run(helmPullCommand(dep, remoteChart));
312
+ }
313
+ try {
314
+ await ssh.run(
315
+ helmUpgradeCommand({
316
+ ...o.flags,
317
+ remoteChart,
318
+ release: o.release,
319
+ namespace: o.namespace,
320
+ ...(o.wait ? { wait: o.wait } : {}),
321
+ }),
322
+ );
323
+ } finally {
324
+ if (o.secretFiles?.length)
325
+ await ssh.run(
326
+ `rm -f ${o.secretFiles.map((f) => shq(`${remoteChart}/${f}`)).join(' ')}`,
327
+ );
328
+ }
329
+ for (const d of o.deployments) {
330
+ const target = d.includes('/') ? d : `deploy/${d}`;
331
+ ctx.log('waiting for rollout', { deployment: target });
332
+ await ssh.run(
333
+ `kubectl -n ${shq(o.namespace)} rollout status ${shq(target)} --timeout=${o.rolloutTimeoutSeconds}s`,
334
+ );
335
+ }
336
+ }
337
+
338
+ /** The chart directory inside a fetched source tree, or an error naming it. */
339
+ export function chartDirectory(tree: string, chart: string): string {
340
+ const dir = join(tree, chart);
341
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
342
+ throw new Error(`chart directory '${chart}' not found in the source tree`);
343
+ return dir;
111
344
  }
112
345
 
113
346
  /**
@@ -115,82 +348,163 @@ export function helmUpgradeCommand(o: {
115
348
  * into the host's k3s containerd (stream a tarball in, or pull from the registry), copy the chart,
116
349
  * `helm upgrade --install`, wait for each rollout, then (with `healthUrl`) poll the health endpoint.
117
350
  */
351
+ export function helmDeployOverSsh<
352
+ const N extends string,
353
+ const K extends string,
354
+ const Im extends Record<string, string> = DefaultImages,
355
+ const Rf extends Record<string, string> = {},
356
+ >(
357
+ def: HelmDeploySshTargetDef<N, K, Im, Rf>,
358
+ ): Implementation<Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K>>;
118
359
  export function helmDeployOverSsh<
119
360
  const N extends string,
120
361
  const K extends string,
121
362
  const Im extends Record<string, string> = DefaultImages,
122
363
  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 ?? {};
126
- const images: Record<string, string> = def.images ?? { image: "image" };
364
+ const Rf extends Record<string, string> = {},
365
+ >(
366
+ def: HelmDeployOverSshDef<N, K, Im, R, Rf>,
367
+ ): Implementation<Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K>>;
368
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
369
+ export function helmDeployOverSsh(def: any): any {
370
+ return 'target' in def ? helmDeployOverSshTarget(def) : helmDeployOverSshLiteral(def);
371
+ }
372
+
373
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
374
+ function helmDeployOverSshLiteral(
375
+ def: HelmDeployOverSshDef<string, string, any, boolean, any>,
376
+ ): Implementation {
377
+ const images: Record<string, string> = def.images ?? { image: 'image' };
127
378
  const imageEntries = Object.entries(images);
128
379
  const fromRegistry = def.fromRegistry === true;
129
- const deployments = def.deployments ?? [def.release];
130
380
  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`);
381
+ const wait: HelmWait | undefined =
382
+ def.atomic || def.helmWait
383
+ ? { atomic: def.atomic === true, timeoutSeconds: rolloutTimeout }
384
+ : undefined;
385
+ if ('source' in images)
386
+ throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
387
+ if (def.deployments?.length === 0)
388
+ throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
389
+ const imageRefs: Record<string, string> = def.imageRefs ?? {};
390
+ const refEntries = Object.entries(imageRefs);
391
+ if ('source' in imageRefs)
392
+ throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
393
+ for (const [key] of refEntries)
394
+ if (key in images)
395
+ throw new Error(
396
+ `helmDeployOverSsh '${def.name}': '${key}' is both in images and imageRefs`,
397
+ );
135
398
  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
-
139
- return makeStep(
140
- "helmDeployOverSsh",
141
- { name: def.name, description: def.description, image: HELM_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.sshKey], timeoutSeconds: def.timeoutSeconds },
142
- {
143
- manifestConfig: () => ({
144
- host: def.host,
145
- sshKey: def.sshKey.name,
146
- release: def.release,
147
- namespace: def.namespace,
148
- chart: def.chart,
149
- values: [...values],
150
- set: { ...set },
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 } } : {}),
156
- }),
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]! });
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
- }
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
-
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}` };
193
- },
399
+ const input = {
400
+ source: Source,
401
+ ...Object.fromEntries(imageEntries.map(([key]) => [key, imageModel])),
402
+ ...Object.fromEntries(refEntries.map(([key]) => [key, PushedImage])),
403
+ };
404
+ const isDefaultImages = imageEntries.length === 1 && images.image === 'image';
405
+ const configurable = {
406
+ host: def.host,
407
+ release: def.release,
408
+ namespace: def.namespace,
409
+ values: def.values ?? [],
410
+ set: def.set ?? {},
411
+ healthUrl: def.healthUrl,
412
+ link: def.link,
413
+ };
414
+
415
+ const contract = step({
416
+ name: def.name,
417
+ description: def.description,
418
+ kind: 'helmDeployOverSsh',
419
+ image: HELM_DEPLOY_IMAGE,
420
+ input,
421
+ output: HelmRelease,
422
+ secrets: [def.sshKey],
423
+ timeoutSeconds: def.timeoutSeconds,
424
+ settings: {
425
+ host: def.host,
426
+ sshKey: def.sshKey.name,
427
+ release: def.release,
428
+ namespace: def.namespace,
429
+ chart: def.chart,
430
+ values: configurable.values,
431
+ set: configurable.set,
432
+ ...(isDefaultImages ? {} : { images: { ...images } }),
433
+ ...(fromRegistry ? { fromRegistry: true } : {}),
434
+ ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
435
+ ...(def.deployments ? { deployments: [...def.deployments] } : {}),
436
+ ...(def.rolloutTimeoutSeconds !== undefined
437
+ ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds }
438
+ : {}),
439
+ ...(def.dependencies ? { dependencies: true } : {}),
440
+ ...(def.atomic ? { atomic: true } : def.helmWait ? { helmWait: true } : {}),
441
+ ...(def.healthUrl !== undefined ? { healthUrl: def.healthUrl } : {}),
442
+ ...(def.link !== undefined ? { link: def.link } : {}),
194
443
  },
195
- ) as never;
444
+ });
445
+ return implement(contract, async (stepInput, ctx: Ctx) => {
446
+ const input = stepInput as unknown as Record<
447
+ string,
448
+ { repository: string; tag: string; digest: string; tar?: FileRef }
449
+ > & { source: { tree: FileRef } };
450
+ const d = resolveConfigRefs(configurable, ctx.config ?? {}) as {
451
+ host: string;
452
+ release: string;
453
+ namespace: string;
454
+ values: string[];
455
+ set: Record<string, string>;
456
+ link?: string;
457
+ healthUrl?:
458
+ | string
459
+ | ({ url: string } & Pick<HttpHealthCheckOptions, 'attempts' | 'delaySeconds'>);
460
+ };
461
+ const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
462
+ const hostImages: HostImage[] = [];
463
+ const flags: HelmImageFlags[] = [];
464
+ for (const [key, prefix] of imageEntries) {
465
+ const image = input[key]!;
466
+ hostImages.push({
467
+ repository: image.repository,
468
+ tag: image.tag,
469
+ ...(fromRegistry ? {} : { tar: await ctx.files.fetch(image.tar!) }),
470
+ });
471
+ flags.push({
472
+ prefix,
473
+ repository: image.repository,
474
+ tag: image.tag,
475
+ pullPolicy: fromRegistry ? 'IfNotPresent' : 'Never',
476
+ });
477
+ }
478
+ const refs: HelmImageRefFlags[] = refEntries.map(([key, prefix]) => ({
479
+ prefix,
480
+ repository: input[key]!.repository,
481
+ tag: input[key]!.tag,
482
+ digest: input[key]!.digest,
483
+ }));
484
+ await deployOverSsh(ctx, {
485
+ host: d.host,
486
+ key: ctx.secrets[def.sshKey.name]!,
487
+ chartDir,
488
+ release: d.release,
489
+ namespace: d.namespace,
490
+ hostImages,
491
+ flags: { values: d.values, set: d.set, images: flags, imageRefs: refs },
492
+ deployments: def.deployments ?? (imageEntries.length && !wait ? [d.release] : []),
493
+ rolloutTimeoutSeconds: rolloutTimeout,
494
+ ...(def.dependencies ? { dependencies: chartDependencies(chartDir) } : {}),
495
+ ...(wait ? { wait } : {}),
496
+ });
497
+ const health = typeof d.healthUrl === 'string' ? { url: d.healthUrl } : d.healthUrl;
498
+ if (health)
499
+ await httpHealthCheck(ctx, health.url, {
500
+ attempts: health.attempts,
501
+ delaySeconds: health.delaySeconds,
502
+ });
503
+ if (d.link) ctx.link(d.link);
504
+ return {
505
+ release: d.release,
506
+ namespace: d.namespace,
507
+ ...(flags[0] ? { image: `${flags[0].repository}:${flags[0].tag}` } : {}),
508
+ };
509
+ }) as never;
196
510
  }