@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.6

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.
@@ -0,0 +1,23 @@
1
+ // Checks for deployed apps, used from custom test handlers.
2
+ import type { Ctx } from "../ctx/types.ts";
3
+
4
+ /** The in-cluster URL of a release's Service, assuming the chart names it after the release. */
5
+ export function serviceUrl(release: { release: string; namespace: string }): string {
6
+ return `http://${release.release}.${release.namespace}.svc.cluster.local`;
7
+ }
8
+
9
+ /**
10
+ * Checks `url` serves a single-page app: `GET <url>/` references a `/assets/*.js` bundle (Vite's
11
+ * layout), and the bundle is served as JavaScript. Returns the page, for app-specific checks.
12
+ */
13
+ export async function checkSpa(ctx: Pick<Ctx, "http" | "log">, url: string): Promise<{ html: string; bundle: string }> {
14
+ const base = url.replace(/\/$/, "");
15
+ const html = await (await ctx.http.expectOk(`${base}/`)).text();
16
+ const bundle = html.match(/src="(\/assets\/[^"]+\.js)"/)?.[1];
17
+ if (bundle === undefined) throw new Error(`${base}/ does not reference an /assets/*.js bundle`);
18
+ const js = await ctx.http.expectOk(`${base}${bundle}`);
19
+ await js.body?.cancel().catch(() => {});
20
+ if (!(js.headers.get("content-type")?.includes("javascript") ?? false)) throw new Error(`${base}${bundle} is not served as JavaScript`);
21
+ ctx.log("SPA served", { url: base, bundle });
22
+ return { html, bundle };
23
+ }
@@ -0,0 +1,93 @@
1
+ // steps.composeDeploy — ask a Docker Compose deployer to recreate a service with an image. The step is
2
+ // thin on purpose: the deployer (pull, recreate, health, rollback) lives beside the compose project
3
+ // and is fixed there, behind a versioned HTTP API, without editing pipelines.
4
+ import type { Ctx } from "../ctx/types.ts";
5
+ import type { Configurable } from "../core/config.ts";
6
+ import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
7
+ import type { Secret } from "../core/secrets.ts";
8
+ import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
9
+ import { httpHealthCheck } from "./http-health.ts";
10
+ import { HelmRelease, PushedImage } from "./models.ts";
11
+
12
+ /** The step's own image: the work is one HTTP call from the harness. */
13
+ export const COMPOSE_DEPLOY_IMAGE = "alpine:3.22";
14
+
15
+ /** A resolved `kind: compose` target. */
16
+ export interface ComposeTargetValue {
17
+ kind: "compose";
18
+ deployerUrl: string;
19
+ service: string;
20
+ token: { secret: string };
21
+ healthUrl?: string | undefined;
22
+ }
23
+
24
+ /** What the deployer answers to `POST /v1/deploy`. */
25
+ export interface ComposeDeployResult {
26
+ status: "deployed" | "rolled-back" | "failed";
27
+ service: string;
28
+ image: string;
29
+ previousImage?: string;
30
+ message?: string;
31
+ log?: string[];
32
+ }
33
+
34
+ export interface ComposeDeployDef<N extends string, K extends string> {
35
+ name: LiteralName<N>;
36
+ /** What the step does, shown in the UI. Required; may be empty. */
37
+ description: string;
38
+ /** A compose target, usually a `select` case's reference. */
39
+ target: Configurable<ComposeTargetValue>;
40
+ /** The secret the target's `token` names (declared by the step). */
41
+ token: Secret<K>;
42
+ timeoutSeconds?: number;
43
+ }
44
+
45
+ const input = { image: PushedImage };
46
+
47
+ /**
48
+ * Deploys a registry image (by digest) to a compose service: `POST <deployerUrl>/v1/deploy
49
+ * { service, image: <repository>@<digest> }` with the target's bearer token. The deployer pulls the
50
+ * image, recreates the service, waits for its health and rolls back on failure; the step reports its
51
+ * answer, then (with the target's `healthUrl`) checks health itself. Output: a `HelmRelease` with the
52
+ * service as `release` and `compose` as `namespace`, so it can share a step name with the helm deploys.
53
+ */
54
+ export function composeDeploy<const N extends string, const K extends string>(
55
+ def: ComposeDeployDef<N, K>,
56
+ ): Step<N, typeof input, typeof HelmRelease, K> & LibraryStep {
57
+ const configurable = { target: def.target };
58
+ return makeStep(
59
+ "composeDeploy",
60
+ { name: def.name, description: def.description, image: COMPOSE_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.token], timeoutSeconds: def.timeoutSeconds },
61
+ {
62
+ manifestConfig: () => ({ target: recordConfigRefs(def.target), token: def.token.name }),
63
+ async run({ image }: { image: { repository: string; digest: string } }, ctx: Ctx) {
64
+ const { target } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: ComposeTargetValue };
65
+ if (target?.kind !== "compose") throw new Error(`composeDeploy '${def.name}': the target is kind '${String(target?.kind)}', not 'compose'`);
66
+ if (target.token.secret !== def.token.name) throw new Error(`composeDeploy '${def.name}': the target's token names secret ${target.token.secret}, but the step declares ${def.token.name}`);
67
+ const token = (ctx.secrets as Record<string, string>)[def.token.name];
68
+ const ref = `${image.repository}@${image.digest}`;
69
+ const url = `${target.deployerUrl.replace(/\/+$/, "")}/v1/deploy`;
70
+ ctx.log("deploying", { service: target.service, image: ref, deployer: target.deployerUrl });
71
+ const res = await ctx.http.fetch(url, {
72
+ method: "POST",
73
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
74
+ body: JSON.stringify({ service: target.service, image: ref }),
75
+ });
76
+ const text = await res.text();
77
+ let result: ComposeDeployResult | undefined;
78
+ try {
79
+ result = JSON.parse(text) as ComposeDeployResult;
80
+ } catch {
81
+ // reported below
82
+ }
83
+ for (const line of result?.log ?? []) ctx.log(`deployer: ${line}`);
84
+ if (!res.ok || result?.status !== "deployed")
85
+ throw new Error(`the deployer answered HTTP ${res.status}: ${result ? `${result.status}${result.message ? `: ${result.message}` : ""}` : text.slice(0, 300)}`);
86
+ if (target.healthUrl) await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
87
+ ctx.log("deployed", { service: target.service, image: ref, previous: result.previousImage ?? "-" });
88
+ return { release: target.service, namespace: "compose", image: ref };
89
+ },
90
+ },
91
+ configurable,
92
+ ) as never;
93
+ }
@@ -7,6 +7,8 @@ import { join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import type { Ctx } from "../ctx/types.ts";
9
9
  import type { Secret } from "../core/secrets.ts";
10
+ import type { Configurable } from "../core/config.ts";
11
+ import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
10
12
  import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
11
13
  import { extractTar } from "../util/tar.ts";
12
14
  import { Source } from "./models.ts";
@@ -23,11 +25,25 @@ export interface GitHubSourceDef<N extends string, T extends string> {
23
25
  description: string;
24
26
  /** Token sent to the GitHub API (needed for private repositories; raises the rate limit). */
25
27
  token?: Secret<T>;
28
+ /** GitHub API base (default `https://api.github.com`; GitHub Enterprise: `https://<host>/api/v3`). */
29
+ apiUrl?: Configurable<string | undefined>;
26
30
  timeoutSeconds?: number;
27
31
  }
28
32
 
33
+ export const GITHUB_API_URL = "https://api.github.com";
34
+
35
+ /** A declared secret's value, or "" when it is not set. */
36
+ function optionalSecret(ctx: Pick<Ctx, "secrets">, name: string): string {
37
+ try {
38
+ return ((ctx.secrets as Record<string, string>)[name] ?? "").trim();
39
+ } catch {
40
+ return "";
41
+ }
42
+ }
43
+
29
44
  /** Tarball URL of `repo` at `sha` (the API redirects to codeload.github.com). */
30
- export const gitHubTarballUrl = (repo: string, sha: string): string => `https://api.github.com/repos/${repo}/tarball/${encodeURIComponent(sha)}`;
45
+ export const gitHubTarballUrl = (repo: string, sha: string, apiUrl = GITHUB_API_URL): string =>
46
+ `${apiUrl.replace(/\/+$/, "")}/repos/${repo}/tarball/${encodeURIComponent(sha)}`;
31
47
 
32
48
  /**
33
49
  * Downloads `repo` at `sha` from GitHub as a tarball and outputs it as a `Source` whose `tree` is the
@@ -41,11 +57,13 @@ export function gitHubSource<const N extends string, const T extends string = ne
41
57
  "gitHubSource",
42
58
  { name: def.name, description: def.description, image: GITHUB_SOURCE_IMAGE, input, output: Source, secrets: def.token ? [def.token] : [], timeoutSeconds: def.timeoutSeconds },
43
59
  {
44
- manifestConfig: () => ({ ...(def.token ? { token: def.token.name } : {}) }),
60
+ manifestConfig: () => ({ ...(def.token ? { token: def.token.name } : {}), ...(def.apiUrl !== undefined ? { apiUrl: recordConfigRefs(def.apiUrl) } : {}) }),
45
61
  async run({ repo, sha }: { repo: string; sha: string }, ctx: Ctx) {
46
62
  const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "olve-pipelines" };
47
- if (def.token) headers.authorization = `Bearer ${ctx.secrets[def.token.name]}`;
48
- const url = gitHubTarballUrl(repo, sha);
63
+ const token = def.token ? optionalSecret(ctx, def.token.name) : "";
64
+ if (token) headers.authorization = `Bearer ${token}`;
65
+ else if (def.token) ctx.log(`${def.token.name} is not set: fetching anonymously (public repositories only)`);
66
+ const url = gitHubTarballUrl(repo, sha, resolveConfigRefs(def.apiUrl, ctx.config ?? {}) ?? GITHUB_API_URL);
49
67
  mkdirSync(ctx.tmpDir, { recursive: true });
50
68
  const dir = mkdtempSync(join(ctx.tmpDir, "github-source-"));
51
69
  const archive = join(dir, "repo.tar.gz");
@@ -73,5 +91,6 @@ export function gitHubSource<const N extends string, const T extends string = ne
73
91
  return { repo, sha, tree };
74
92
  },
75
93
  },
94
+ { apiUrl: def.apiUrl },
76
95
  ) as never;
77
96
  }
@@ -1,16 +1,23 @@
1
- // steps.helmDeployOverSsh — import a built image into a k3s host's containerd and helm-upgrade a
2
- // release there, over SSH; optionally verify an HTTP health endpoint afterwards.
1
+ // steps.helmDeployOverSsh — get built images onto a k3s host and helm-upgrade a release there, over
2
+ // SSH; optionally verify an HTTP health endpoint afterwards.
3
3
  //
4
4
  // Ported from v1's olve-lib.sh (olve_ssh_host, olve_image_import, olve_helm_deploy) and
5
5
  // deploy-beta.sh (rollout wait + health loop); the footguns are kept as comments below.
6
- import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
6
+ import { existsSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import type { Ctx } from "../ctx/types.ts";
9
9
  import type { FileRef } from "../core/models.ts";
10
10
  import type { Secret } from "../core/secrets.ts";
11
+ 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
- import { httpHealthCheck } from "./http-health.ts";
13
- import { BuiltImage, HelmRelease, Source } from "./models.ts";
14
+ import type { Simplify } from "../core/types.ts";
15
+ import { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
16
+ import { BuiltImage, HelmRelease, PushedImage, Source } from "./models.ts";
17
+ import { shq, sshSession } from "./ssh.ts";
18
+ import { helmDeployOverSshTarget, type HelmDeploySshTargetDef, type HelmTargetDeployInput } from "./helm-deploy.ts";
19
+
20
+ export { shq } from "./ssh.ts";
14
21
 
15
22
  /** Alpine: `apk add openssh-client` at run time gives ssh/scp (the health check runs in the harness). */
16
23
  export const HELM_DEPLOY_IMAGE = "alpine:3.22";
@@ -20,63 +27,125 @@ export const HELM_DEPLOY_IMAGE = "alpine:3.22";
20
27
  * the k3s instance's `k8s.io` namespace (otherwise pods fail with ErrImageNeverPull).
21
28
  */
22
29
  export const K3S_IMAGE_IMPORT = "sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load";
30
+ /** Registry images are pulled the same way before the rollout, so the rollout doesn't wait on the pull. */
31
+ export const K3S_IMAGE_PULL = "sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io pull -q";
23
32
 
24
- const ROLLOUT_TIMEOUT = "120s";
33
+ export const DEFAULT_ROLLOUT_TIMEOUT_SECONDS = 120;
25
34
 
26
- const input = { source: Source, image: BuiltImage };
27
- export type HelmDeployOverSshInput = typeof input;
35
+ type DefaultImages = { readonly image: "image" };
36
+ type ImageModel<R extends boolean> = R extends true ? typeof PushedImage : typeof BuiltImage;
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 }
43
+ >;
28
44
 
29
- export interface HelmDeployOverSshDef<N extends string, K extends string> {
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
+ > {
30
52
  name: LiteralName<N>;
31
53
  /** What the step does, shown in the UI. Required; may be empty. */
32
54
  description: string;
33
55
  /** SSH target, `user@host`. The user needs passwordless sudo for nerdctl, and helm/kubectl with a kubeconfig. */
34
- host: string;
56
+ host: Configurable<string>;
35
57
  /** Private key for `host`. */
36
58
  sshKey: Secret<K>;
37
- /** Helm release name; the chart's Deployment is expected to be named the same (rollout wait). */
38
- release: string;
39
- namespace: string;
59
+ /** Helm release name. */
60
+ release: Configurable<string>;
61
+ namespace: Configurable<string>;
40
62
  /** Chart directory inside the source tree, e.g. `helm`. */
41
63
  chart: string;
42
64
  /** Values files, relative to the chart directory (`-f`, in order). */
43
- values?: readonly string[];
65
+ values?: Configurable<readonly string[]>;
66
+ /**
67
+ * Chart-specific `--set` flags (e.g. `{ "ingress.enabled": "false" }`). The image flags are added by
68
+ * the step and always win: `<prefix>.repository`, `<prefix>.tag`, `<prefix>.pullPolicy`.
69
+ */
70
+ set?: Configurable<Readonly<Record<string, string>>>;
71
+ /**
72
+ * The images to deploy: step input key → the chart's values prefix for that image. Each input is a
73
+ * `BuiltImage` (or `PushedImage` with `fromRegistry`). Default `{ image: "image" }`; several images
74
+ * need a mapper, e.g. `{ server: "image", web: "web.image" }`.
75
+ */
76
+ images?: Im & { readonly source?: never };
44
77
  /**
45
- * Chart-specific `--set` flags (e.g. `{ "slo.enabled": "false" }`). The image flags are added by
46
- * the step and always win: `image.repository`, `image.tag`, `image.pullPolicy=Never`.
78
+ * The images are `PushedImage`s from a registry (`imageBuild` with `push`): pulled on the host
79
+ * first, then `pullPolicy=IfNotPresent`. Default: `BuiltImage` tarballs, imported on the host, then
80
+ * `pullPolicy=Never`.
47
81
  */
48
- set?: Readonly<Record<string, string>>;
49
- /** After the rollout, GET this URL until it returns 2xx, 5 × 5 s (`httpHealthCheck`), or fail the step. */
50
- healthUrl?: string;
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 };
90
+ /** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name. */
91
+ deployments?: readonly string[];
92
+ /** Per rollout (`kubectl rollout status --timeout`). Default 120. */
93
+ rolloutTimeoutSeconds?: number;
94
+ /** After the rollouts, GET this URL until it returns 2xx (`httpHealthCheck`: 5 × 5 s unless given), or fail the step. */
95
+ healthUrl?: Configurable<string> | ({ url: Configurable<string> } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
51
96
  timeoutSeconds?: number;
52
97
  }
53
98
 
54
- const SAFE = /^[A-Za-z0-9_\/.:=@%+,-]+$/;
55
- /** POSIX shell quoting (safe words are left bare, so logs stay readable). */
56
- export function shq(s: string): string {
57
- return SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
99
+ export interface HelmImageFlags {
100
+ prefix: string;
101
+ repository: string;
102
+ tag: string;
103
+ pullPolicy: "Never" | "IfNotPresent";
104
+ /** Pins the image: `<prefix>.digest` (the chart renders `repository:tag@digest`). */
105
+ digest?: string;
58
106
  }
59
107
 
60
- /** Remote `helm upgrade --install`, run from inside the copied chart directory. */
61
- export function helmUpgradeCommand(o: {
62
- remoteChart: string;
63
- release: string;
64
- namespace: string;
65
- values: readonly string[];
66
- set: Readonly<Record<string, string>>;
108
+ /** A registry image the chart only references (`imageRefs`). */
109
+ export interface HelmImageRefFlags {
110
+ prefix: string;
67
111
  repository: string;
68
112
  tag: string;
69
- }): string {
113
+ digest: string;
114
+ }
115
+
116
+ export interface HelmFlagOptions {
117
+ values: readonly string[];
118
+ set: Readonly<Record<string, string>>;
119
+ images: readonly HelmImageFlags[];
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[] {
70
125
  // Chart-relative values paths (`-f values-beta.yaml`) resolve because helm runs from inside the
71
- // copied chart dir. Extra flags come before the image flags, so the image overrides win. The image
72
- // is local to the node (imported above), never pulled: pullPolicy=Never.
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.
73
128
  const flags = [
74
129
  ...o.values.flatMap((v) => ["-f", v]),
75
130
  ...Object.entries(o.set).flatMap(([k, v]) => ["--set", `${k}=${v}`]),
76
- "--set", `image.repository=${o.repository}`,
77
- "--set", `image.tag=${o.tag}`,
78
- "--set", "image.pullPolicy=Never",
131
+ ...o.images.flatMap((i) => [
132
+ "--set", `${i.prefix}.repository=${i.repository}`,
133
+ "--set", `${i.prefix}.tag=${i.tag}`,
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}`,
141
+ ]),
79
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);
80
149
  return [
81
150
  `cd ${shq(o.remoteChart)}`,
82
151
  ["helm", "upgrade", "--install", o.release, ".", "-n", o.namespace, ...flags].map(shq).join(" "),
@@ -84,68 +153,152 @@ export function helmUpgradeCommand(o: {
84
153
  ].join(" && ");
85
154
  }
86
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
+
87
165
  /**
88
- * Deploys `image` with the Helm chart at `<source>/<chart>` on a k3s host over SSH:
89
- * stream the image tar into the host's k3s containerd, copy the chart, `helm upgrade --install`,
90
- * wait for `deploy/<release>` to roll out, then (with `healthUrl`) poll the health endpoint.
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.
91
168
  */
92
- export function helmDeployOverSsh<const N extends string, const K extends string>(
93
- def: HelmDeployOverSshDef<N, K>,
94
- ): Step<N, HelmDeployOverSshInput, typeof HelmRelease, K> & LibraryStep {
95
- const values = def.values ?? [];
96
- const set = def.set ?? {};
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
+
214
+ /**
215
+ * Deploys images with the Helm chart at `<source>/<chart>` on a k3s host over SSH: get each image
216
+ * into the host's k3s containerd (stream a tarball in, or pull from the registry), copy the chart,
217
+ * `helm upgrade --install`, wait for each rollout, then (with `healthUrl`) poll the health endpoint.
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;
225
+ export function helmDeployOverSsh<
226
+ const N extends string,
227
+ const K extends string,
228
+ const Im extends Record<string, string> = DefaultImages,
229
+ const R extends boolean = false,
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 {
239
+ const images: Record<string, string> = def.images ?? { image: "image" };
240
+ const imageEntries = Object.entries(images);
241
+ const fromRegistry = def.fromRegistry === true;
242
+ const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
243
+ if (imageEntries.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': images must not be empty`);
244
+ if ("source" in images) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
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`);
250
+ const imageModel = fromRegistry ? PushedImage : BuiltImage;
251
+ const input = {
252
+ source: Source,
253
+ ...Object.fromEntries(imageEntries.map(([key]) => [key, imageModel])),
254
+ ...Object.fromEntries(refEntries.map(([key]) => [key, PushedImage])),
255
+ };
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 };
258
+
97
259
  return makeStep(
98
260
  "helmDeployOverSsh",
99
261
  { name: def.name, description: def.description, image: HELM_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.sshKey], timeoutSeconds: def.timeoutSeconds },
100
262
  {
101
263
  manifestConfig: () => ({
102
- host: def.host,
264
+ host: recordConfigRefs(def.host),
103
265
  sshKey: def.sshKey.name,
104
- release: def.release,
105
- namespace: def.namespace,
266
+ release: recordConfigRefs(def.release),
267
+ namespace: recordConfigRefs(def.namespace),
106
268
  chart: def.chart,
107
- values: [...values],
108
- set: { ...set },
109
- ...(def.healthUrl ? { healthUrl: def.healthUrl } : {}),
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) } : {}),
110
277
  }),
111
- async run(
112
- { source, image }: { source: { tree: FileRef }; image: { repository: string; tag: string; tar: FileRef } },
113
- ctx: Ctx,
114
- ) {
115
- const tree = await ctx.files.fetch(source.tree);
116
- const chartDir = join(tree, def.chart);
117
- if (!existsSync(chartDir) || !statSync(chartDir).isDirectory()) throw new Error(`chart directory '${def.chart}' not found in the source tree`);
118
- const imageTar = await ctx.files.fetch(image.tar);
119
-
120
- // SSH setup. The key file needs a trailing newline (OpenSSH rejects the key otherwise) and
121
- // mode 600. The host key is not verified (as in v1): the host is reached over the tailnet.
122
- await ctx.exec(["apk", "add", "--no-cache", "openssh-client"]);
123
- const sshDir = join(ctx.tmpDir, "ssh");
124
- mkdirSync(sshDir, { recursive: true, mode: 0o700 });
125
- const keyFile = join(sshDir, "id");
126
- const key = ctx.secrets[def.sshKey.name]!;
127
- writeFileSync(keyFile, key.endsWith("\n") ? key : key + "\n", { mode: 0o600 });
128
- chmodSync(keyFile, 0o600);
129
- const opts = ["-i", keyFile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "BatchMode=yes"];
130
- const ssh = (remote: string) => ctx.exec(["ssh", ...opts, def.host, remote]);
131
-
132
- const ref = `${image.repository}:${image.tag}`;
133
- ctx.log("importing image", { image: ref, host: def.host });
134
- // Streamed over ssh's stdin (ctx.exec has none, hence the shell redirect).
135
- await ctx.sh(["ssh", ...opts, def.host, K3S_IMAGE_IMPORT].map(shq).join(" ") + ` < ${shq(imageTar)}`);
136
-
137
- const remoteChart = `/tmp/${def.release}-helm-${def.namespace}`;
138
- ctx.log("helm upgrade", { release: def.release, namespace: def.namespace, image: ref });
139
- await ssh(`rm -rf ${shq(remoteChart)}`);
140
- await ctx.exec(["scp", ...opts, "-r", chartDir, `${def.host}:${remoteChart}`]);
141
- await ssh(helmUpgradeCommand({ remoteChart, release: def.release, namespace: def.namespace, values, set, repository: image.repository, tag: image.tag }));
142
-
143
- ctx.log("waiting for rollout", { deployment: def.release });
144
- await ssh(`kubectl -n ${shq(def.namespace)} rollout status ${shq(`deploy/${def.release}`)} --timeout=${ROLLOUT_TIMEOUT}`);
145
-
146
- if (def.healthUrl) await httpHealthCheck(ctx, def.healthUrl);
147
- return { release: def.release, namespace: def.namespace, image: ref };
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}` };
148
300
  },
149
301
  },
302
+ configurable,
150
303
  ) as never;
151
304
  }