@olivervea/pipelines 2.0.0-beta.8 → 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.
package/README.md CHANGED
@@ -146,7 +146,7 @@ Library steps carry their own implementation and image; they take typed settings
146
146
  |---|---|---|---|
147
147
  | `steps.gitHubSource` | `alpine:3.22` (work happens in the harness) | `{ repo: "owner/name", sha }` (plain schemas: needs a mapper) → `Source` | `token?: Secret` (sent as `Authorization: Bearer` when set; unset = anonymous; becomes the step's secret), `apiUrl?` (default `https://api.github.com`) |
148
148
  | `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `context?` (dir in the source tree with the `Dockerfile`; default the root), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default: the homelab pull-through caches, `{}` = off), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2), `registryAuth?` (`{ username, password: Secret }`: log in to the `repository` registry; or a config reference to `{ username, password: { secret: NAME } }`, with `NAME` in `secrets`), `insecureRegistry?` (that registry is plain HTTP), `secrets?` (declared secrets a config `registryAuth` may name), `enabled?` (`false` skips the step) |
149
- | `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, <key>: BuiltImage }` per `images` (`PushedImage` with `fromRegistry`) → `HelmRelease { release, namespace, image? }` (`image`: the first image; absent without images) | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `images?` (input key → values prefix; default `{ image: "image" }`; `{}` = the chart alone), `fromRegistry?`, `imageRefs?` (`PushedImage` input key → values prefix: set as `<prefix>.repository`/`.tag`/`.digest`, not pulled; works next to tarball `images`), `deployments?` (default `[release]`, none without images), `rolloutTimeoutSeconds?` (default 120), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
149
+ | `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, <key>: BuiltImage }` per `images` (`PushedImage` with `fromRegistry`) → `HelmRelease { release, namespace, image? }` (`image`: the first image; absent without images) | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `images?` (input key → values prefix; default `{ image: "image" }`; `{}` = the chart alone), `fromRegistry?`, `imageRefs?` (`PushedImage` input key → values prefix: set as `<prefix>.repository`/`.tag`/`.digest`, not pulled; works next to tarball `images`), `deployments?` (default `[release]`, none without images), `rolloutTimeoutSeconds?` (default 120), `dependencies?` (pull `Chart.yaml`'s dependencies on the host), `helmWait?` / `atomic?` (`helm --wait` / `--atomic`), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
150
150
  | `steps.helmDeployInCluster` (and the `target` form of `helmDeployOverSsh`) | `alpine/helm:3.16.2` (ssh: `alpine:3.22`) | `{ source: Source, <key>: PushedImage }` per `images`, `<key>: optional(PushedImage)` per `imageRefs` → `HelmRelease` (`image`: `repository:tag@digest` of the first image) | `target` (a `select` case's reference to a `helmTarget()` variant: `{ kind: "inCluster", namespace, release, values, set, url? }` for helmDeployInCluster, the same plus `host` with `kind: "ssh"` for helmDeployOverSsh; `url` is where step pods reach the release, default its in-cluster Service URL, and is passed on in `HelmRelease.url`), `sshKey: Secret` (ssh only), `chart`, `inlineValues?` (objects written as values files after the target's), `images?`, `imageRefs?`, `deployments?` (ssh), `rolloutTimeoutSeconds?` (default 120), `healthPath?`; helmDeployInCluster declares `clusterAccess` |
151
151
  | `steps.composeDeploy` | `alpine:3.22` (work happens in the harness) | `{ image: PushedImage }` → `HelmRelease { release: <service>, namespace: "compose", image: <repository>@<digest> }` | `target` (a `select` case's reference to the compose variant of `deployTarget()`: `{ kind: "compose", deployerUrl, service, token: { secret }, healthUrl? }`), `token: Secret` (must be the secret the target names; the step's only secret), `timeoutSeconds?` |
152
152
 
@@ -154,7 +154,7 @@ Library steps carry their own implementation and image; they take typed settings
154
154
  - `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `--context=/kaniko/build-context[/<context>] --dockerfile=<that>/Dockerfile` with `--no-push --tar-path --digest-file`, and outputs the tarball. A missing context or Dockerfile fails the step before kaniko starts. `repository` in the output is fully qualified the way a runtime names a loaded image (`x` → `docker.io/library/x`). A **stall watchdog** kills kaniko when it prints nothing for `stallTimeoutSeconds` (a base-image pull can stall on an idle connection at "Unpacking rootfs", and kaniko has no read timeout), failing the attempt; `retries` then starts a fresh attempt. The retry is deliberately a new pod, never a second kaniko in the same container: kaniko unpacks the base image over the container root and does not clean up when killed.
155
155
  - **Caches never block a build.** `registryMap` defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`: `mcr.microsoft.com` → `mcr-cache.ovea.pro`, `index.docker.io` → `hub-cache.ovea.pro`; kaniko under gVisor stalls pulling from mcr.microsoft.com directly). A map you pass replaces the default; `{}` turns mapping off. Before kaniko starts, every mirror host gets a 5 s `GET https://<host>/v2/` (200 or 401 = up); one that doesn't answer is dropped with a log line (`mcr-cache.ovea.pro unreachable; pulling from mcr.microsoft.com directly`). After that, kaniko itself copes: it falls back to the original registry when a mirror can't serve an image's manifest (`RetrieveRemoteImage`; `--skip-default-registry-fallback` is never set). A cache that dies mid-download (after the manifest came from it) fails or stalls the attempt; `retries` starts a fresh one, which probes again.
156
156
  - **Registry mode** (`push: true`, for images too big for the step's ~1 GiB scratch space and Garage): kaniko runs without `--no-push`/`--tar-path`, pushes to `repository` (it must start with a registry host; anonymous unless `registryAuth`) and the output is a `PushedImage` with the digest from `--digest-file`. With `contentTag`, the tag is `src-` + the first 12 hex of the sha256 of the context directory's deterministic tar (the bytes `ctx.output.dir` would write), computed from the fetched tree; a `HEAD https://<host>/v2/<path>/manifests/<tag>` (answering a Basic or Bearer challenge: anonymous token, or the `registryAuth` login; an anonymous lookup that is denied counts as "not there") that answers a `Docker-Content-Digest` skips kaniko and outputs that digest. With `registryAuth`, the step declares the password secret and writes `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in the kaniko image) for that registry before kaniko runs, so pushes and base-image pulls from it are logged in. `insecureRegistry` makes the lookups plain HTTP and adds kaniko `--insecure --insecure-registry=<host>`.
157
- - `helmDeployOverSsh` gets each image into the host's **k3s** containerd: a `BuiltImage` tar is streamed in (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), a `PushedImage` (`fromRegistry`) is pulled there (`… pull -q`, so the rollout doesn't wait on the pull). It copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then per image `<prefix>.repository`/`.tag`/`.pullPolicy` — `Never` for tarballs, `IfNotPresent` for registry images — which always win), waits for `kubectl rollout status` of each of `deployments` in order (`deploy/<name>` for a bare name), and with `healthUrl` polls it (5 × 5 s unless given) from the harness. `images: {}` deploys the chart alone: the step's only input is `source`, no image is imported and no image flag set, and nothing is waited on unless `deployments` names it. Several images (one chart, two Deployments):
157
+ - `helmDeployOverSsh` gets each image into the host's **k3s** containerd: a `BuiltImage` tar is streamed in (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), a `PushedImage` (`fromRegistry`) is pulled there (`… pull -q`, so the rollout doesn't wait on the pull). It copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then per image `<prefix>.repository`/`.tag`/`.pullPolicy` — `Never` for tarballs, `IfNotPresent` for registry images — which always win), waits for `kubectl rollout status` of each of `deployments` in order (`deploy/<name>` for a bare name), and with `healthUrl` polls it (5 × 5 s unless given) from the harness. `images: {}` deploys the chart alone: the step's only input is `source`, no image is imported and no image flag set, and nothing is waited on unless `deployments` names it. `dependencies: true` runs `helm pull <name> --repo <repository> --version <version>` (or the `oci://` reference) into the copied chart's `charts/` for each dependency in `Chart.yaml`; `helmWait` / `atomic` add `--wait` / `--atomic` with `--timeout=<rolloutTimeoutSeconds>s` and make `deployments` default to none. Several images (one chart, two Deployments):
158
158
 
159
159
  ```ts
160
160
  steps.helmDeployOverSsh({ name: "deploy-beta", description: "", host: "oliver@bulwark-m2", sshKey: SSH_PRIVATE_KEY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.8",
3
+ "version": "2.0.0-beta.9",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,7 +3,7 @@
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";
@@ -86,10 +86,20 @@ export interface HelmDeployOverSshDef<
86
86
  * next to tarball `images`.
87
87
  */
88
88
  imageRefs?: Rf & { readonly source?: never };
89
- /** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name, or none without images. */
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. */
90
90
  deployments?: readonly string[];
91
- /** Per rollout (`kubectl rollout status --timeout`). Default 120. */
91
+ /** Per rollout (`kubectl rollout status --timeout`), or for helm's own wait. Default 120. */
92
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;
93
103
  /** After the rollouts, GET this URL until it returns 2xx (`httpHealthCheck`: 5 × 5 s unless given), or fail the step. */
94
104
  healthUrl?: Configurable<string> | ({ url: Configurable<string> } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
95
105
  timeoutSeconds?: number;
@@ -143,15 +153,45 @@ export function helmFlags(o: HelmFlagOptions): string[] {
143
153
  }
144
154
 
145
155
  /** Remote `helm upgrade --install`, run from inside the copied chart directory. */
146
- 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 {
147
157
  const flags = helmFlags(o);
158
+ const wait = o.wait ? [o.wait.atomic ? "--atomic" : "--wait", `--timeout=${o.wait.timeoutSeconds}s`] : [];
148
159
  return [
149
160
  `cd ${shq(o.remoteChart)}`,
150
- ["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(" "),
151
162
  `cd / && rm -rf ${shq(o.remoteChart)}`,
152
163
  ].join(" && ");
153
164
  }
154
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
+
155
195
  /** One image to get onto the host before the upgrade. */
156
196
  export interface HostImage {
157
197
  repository: string;
@@ -177,6 +217,8 @@ export async function deployOverSsh(
177
217
  flags: HelmFlagOptions;
178
218
  deployments: readonly string[];
179
219
  rolloutTimeoutSeconds: number;
220
+ dependencies?: readonly ChartDependency[];
221
+ wait?: HelmWait;
180
222
  },
181
223
  ): Promise<void> {
182
224
  const ssh = await sshSession(ctx, { host: o.host, key: o.key });
@@ -195,7 +237,12 @@ export async function deployOverSsh(
195
237
  ctx.log("helm upgrade", { release: o.release, namespace: o.namespace, host: o.host });
196
238
  await ssh.run(`rm -rf ${shq(remoteChart)}`);
197
239
  await ssh.copy(o.chartDir, remoteChart);
198
- 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 } : {}) }));
199
246
  for (const d of o.deployments) {
200
247
  const target = d.includes("/") ? d : `deploy/${d}`;
201
248
  ctx.log("waiting for rollout", { deployment: target });
@@ -239,6 +286,7 @@ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any,
239
286
  const imageEntries = Object.entries(images);
240
287
  const fromRegistry = def.fromRegistry === true;
241
288
  const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
289
+ const wait: HelmWait | undefined = def.atomic || def.helmWait ? { atomic: def.atomic === true, timeoutSeconds: rolloutTimeout } : undefined;
242
290
  if ("source" in images) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
243
291
  if (def.deployments?.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
244
292
  const imageRefs: Record<string, string> = def.imageRefs ?? {};
@@ -276,6 +324,8 @@ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any,
276
324
  ...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
277
325
  ...(def.deployments ? { deployments: [...def.deployments] } : {}),
278
326
  ...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
327
+ ...(def.dependencies ? { dependencies: true } : {}),
328
+ ...(def.atomic ? { atomic: true } : def.helmWait ? { helmWait: true } : {}),
279
329
  ...(def.healthUrl !== undefined ? { healthUrl: def.healthUrl } : {}),
280
330
  },
281
331
  });
@@ -297,7 +347,9 @@ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any,
297
347
  await deployOverSsh(ctx, {
298
348
  host: d.host, key: ctx.secrets[def.sshKey.name]!, chartDir, release: d.release, namespace: d.namespace, hostImages,
299
349
  flags: { values: d.values, set: d.set, images: flags, imageRefs: refs },
300
- deployments: def.deployments ?? (imageEntries.length ? [d.release] : []), rolloutTimeoutSeconds: rolloutTimeout,
350
+ deployments: def.deployments ?? (imageEntries.length && !wait ? [d.release] : []), rolloutTimeoutSeconds: rolloutTimeout,
351
+ ...(def.dependencies ? { dependencies: chartDependencies(chartDir) } : {}),
352
+ ...(wait ? { wait } : {}),
301
353
  });
302
354
  const health = typeof d.healthUrl === "string" ? { url: d.healthUrl } : d.healthUrl;
303
355
  if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });