@olivervea/pipelines 2.0.0-beta.22 → 2.0.0-beta.24

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
@@ -139,7 +139,7 @@ Library steps carry their own implementation and image; they take typed settings
139
139
  | Step | Image | Input → output | Config |
140
140
  |---|---|---|---|
141
141
  | `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`) |
142
- | `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 none), `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) |
142
+ | `steps.imageBuild` | `gcr.io/kaniko-project/executor:v1.24.0-debug`, pinned by digest | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest, cached }` | `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-<hash>` of the context and the build recipe; skip when the registry has it, with `cached: true`), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default none), `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) |
143
143
  | `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? }`) |
144
144
  | `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` |
145
145
  | `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?` |
@@ -166,7 +166,7 @@ Library steps carry their own implementation and image; they take typed settings
166
166
  | `httpHealthCheck(ctx, url, { attempts?, delaySeconds?, headers? })` | GETs `url` until it returns 2xx (redirects followed), retrying network errors and non-2xx statuses; defaults 5 attempts, 5 s apart. Throws the last error when the attempts run out. Runs in the harness, so the image needs no curl. `helmDeployOverSsh`'s `healthUrl` uses it. |
167
167
  | `serviceUrl(release)` | `http://<release>.<namespace>.svc.cluster.local` for a `HelmRelease` (charts that name the Service after the release). |
168
168
  | `checkSpa(ctx, url)` | `GET <url>/` must reference an `/assets/*.js` bundle (Vite's layout) and the bundle must be served as JavaScript; returns `{ html, bundle }` for app-specific checks. |
169
- | `sshSession(ctx, { host, key })` | Installs openssh-client when the image has no ssh (apk or apt-get), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it. |
169
+ | `sshSession(ctx, { host, key })` | Installs openssh-client when the image has no ssh (apk or apt-get), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote, { tty? }), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it. |
170
170
 
171
171
  ```ts
172
172
  import { httpHealthCheck, implement, step } from "@olivervea/pipelines";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.22",
3
+ "version": "2.0.0-beta.24",
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",
@@ -18,12 +18,30 @@ import { isConfigRef, type ConfigRef, type Configurable } from '../core/config.t
18
18
  import { isLiteral, resolveConfigRefs } from '../core/config-refs.ts';
19
19
  import { implement, type Implementation } from '../core/handlers.ts';
20
20
  import { step, type LiteralName, type Step } from '../core/steps.ts';
21
- import { sha256File } from '../util/hash.ts';
21
+ import { sha256File, sha256Hex } from '../util/hash.ts';
22
22
  import { dirEntries, writeTar } from '../util/tar.ts';
23
23
  import { BuiltImage, PushedImage, Source } from './models.ts';
24
24
 
25
- /** kaniko's debug image (it has a busybox shell for debugging; the executor itself needs none). */
26
- export const KANIKO_IMAGE = 'gcr.io/kaniko-project/executor:debug';
25
+ /** kaniko's debug image, pinned by digest. The handler needs its busybox for `mkdir` and `cp`. */
26
+ export const KANIKO_IMAGE =
27
+ 'gcr.io/kaniko-project/executor:v1.24.0-debug@sha256:2562c4fe551399514277ffff7dcca9a3b1628c4ea38cb017d7286dc6ea52f4cd';
28
+
29
+ /** The executor flags every build uses; part of `BUILD_RECIPE`. */
30
+ export const KANIKO_BUILD_FLAGS: readonly string[] = [
31
+ '--snapshot-mode=redo',
32
+ '--use-new-run',
33
+ '--compressed-caching=false',
34
+ ];
35
+
36
+ /**
37
+ * What decides the built image besides its context: the executor and its flags. A content tag hashes
38
+ * it with the context, so a change here rebuilds every `contentTag` image once instead of reusing an
39
+ * image another build produced.
40
+ */
41
+ export const BUILD_RECIPE = JSON.stringify({
42
+ kaniko: KANIKO_IMAGE,
43
+ flags: KANIKO_BUILD_FLAGS,
44
+ });
27
45
 
28
46
  /**
29
47
  * Footgun: the build context must live under /kaniko, NOT /workspace (or another plain directory in
@@ -216,14 +234,17 @@ function checkRelative(what: string, path: string): string {
216
234
  return clean === '.' ? '' : clean;
217
235
  }
218
236
 
219
- /** The `src-<12 hex>` tag of a directory: the sha256 of its deterministic tar (as `ctx.output.dir` writes it). */
220
- export function contentTagOf(dir: string, tmpDir: string): string {
237
+ /**
238
+ * The `src-<12 hex>` tag of a build context: the sha256 of the build recipe and of the directory's
239
+ * deterministic tar (as `ctx.output.dir` writes it).
240
+ */
241
+ export function contentTagOf(dir: string, tmpDir: string, recipe = BUILD_RECIPE): string {
221
242
  mkdirSync(tmpDir, { recursive: true });
222
243
  const work = mkdtempSync(join(tmpDir, 'content-tag-'));
223
244
  try {
224
245
  const tar = join(work, 'context.tar');
225
246
  writeTar(tar, dirEntries(dir));
226
- return `src-${sha256File(tar).slice(0, 12)}`;
247
+ return `src-${sha256Hex(`${recipe}\n${sha256File(tar)}`).slice(0, 12)}`;
227
248
  } finally {
228
249
  rmSync(work, { recursive: true, force: true });
229
250
  }
@@ -486,11 +507,11 @@ export function imageBuild<const N extends string, const K extends string = neve
486
507
  if (contentTag) {
487
508
  const digest = await registryDigest(ctx.http, repository, tag, access);
488
509
  if (digest) {
489
- ctx.log('image already in the registry', {
510
+ ctx.log('image already in the registry; build skipped', {
490
511
  image: `${repository}:${tag}`,
491
512
  digest,
492
513
  });
493
- return { repository, tag, digest };
514
+ return { repository, tag, digest, cached: true };
494
515
  }
495
516
  }
496
517
 
@@ -521,9 +542,7 @@ export function imageBuild<const N extends string, const K extends string = neve
521
542
  `--digest-file=${digestPath}`,
522
543
  `--destination=${repository}:${tag}`,
523
544
  ...(insecure ? ['--insecure', `--insecure-registry=${host}`] : []),
524
- '--snapshot-mode=redo',
525
- '--use-new-run',
526
- '--compressed-caching=false',
545
+ ...KANIKO_BUILD_FLAGS,
527
546
  ...Object.entries(mirrors).map(([from, to]) => `--registry-map=${from}=${to}`),
528
547
  ],
529
548
  { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS },
@@ -539,7 +558,7 @@ export function imageBuild<const N extends string, const K extends string = neve
539
558
  throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
540
559
  if (push) {
541
560
  ctx.log('image pushed', { image: `${repository}:${tag}`, digest });
542
- return { repository, tag, digest };
561
+ return { repository, tag, digest, cached: false };
543
562
  }
544
563
  const tar = await ctx.output.file(tarPath, { name: 'image.tar' });
545
564
  rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
@@ -24,11 +24,17 @@ export const BuiltImage = model(
24
24
 
25
25
  /**
26
26
  * A container image pushed to a registry by a step (`imageBuild` with `push: true`), pulled from there
27
- * by the deploy. `repository` includes the registry host.
27
+ * by the deploy. `repository` includes the registry host. `cached` is true when `contentTag` found the
28
+ * image already in the registry and the build was skipped.
28
29
  */
29
30
  export const PushedImage = model(
30
31
  'PushedImage',
31
- z.object({ repository: z.string(), tag: z.string(), digest: z.string() }),
32
+ z.object({
33
+ repository: z.string(),
34
+ tag: z.string(),
35
+ digest: z.string(),
36
+ cached: z.boolean().optional(),
37
+ }),
32
38
  );
33
39
 
34
40
  /** A Helm release that was installed or upgraded. */
@@ -16,8 +16,11 @@ export const SSH_CLIENT_INSTALL =
16
16
 
17
17
  export interface SshSession {
18
18
  readonly host: string;
19
- /** Runs a remote command; a non-zero exit throws. */
20
- run(remote: string): Promise<void>;
19
+ /**
20
+ * Runs a remote command; a non-zero exit throws. With `tty`, the command gets a terminal, so a client that
21
+ * dies (a cancelled step) hangs it up: without one, the command gets no signal and carries on.
22
+ */
23
+ run(remote: string, options?: { tty?: boolean }): Promise<void>;
21
24
  /** Runs a remote command and returns its stdout. */
22
25
  text(remote: string): Promise<string>;
23
26
  /** `scp -r` a local file or directory to a remote path. */
@@ -58,7 +61,8 @@ export async function sshSession(
58
61
  const argv = (remote: string) => ['ssh', ...sshOpts, opts.host, remote];
59
62
  return {
60
63
  host: opts.host,
61
- run: (remote) => ctx.exec(argv(remote)),
64
+ run: (remote, options) =>
65
+ ctx.exec(options?.tty ? ['ssh', '-tt', ...argv(remote).slice(1)] : argv(remote)),
62
66
  text: (remote) => ctx.sh.text(argv(remote).map(shq).join(' ')),
63
67
  copy: (local, remote) =>
64
68
  ctx.exec(['scp', ...sshOpts, '-r', local, `${opts.host}:${remote}`]),