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

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
@@ -141,7 +141,7 @@ Library steps carry their own implementation and image; they take typed settings
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
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
- | `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` |
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; a resolved `secretRef`, `{ secret: NAME }`, becomes that secret's value, and the file holding it is deleted after the upgrade), `secrets?` (the secrets those `secretRef`s name; the step declares them), `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?` |
146
146
 
147
147
  - `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.24",
3
+ "version": "2.0.0-beta.25",
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",
@@ -275,6 +275,8 @@ export async function deployOverSsh(
275
275
  rolloutTimeoutSeconds: number;
276
276
  dependencies?: readonly ChartDependency[];
277
277
  wait?: HelmWait;
278
+ /** 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). */
279
+ secretFiles?: readonly string[];
278
280
  },
279
281
  ): Promise<void> {
280
282
  const ssh = await sshSession(ctx, { host: o.host, key: o.key });
@@ -306,15 +308,22 @@ export async function deployOverSsh(
306
308
  });
307
309
  await ssh.run(helmPullCommand(dep, remoteChart));
308
310
  }
309
- await ssh.run(
310
- helmUpgradeCommand({
311
- ...o.flags,
312
- remoteChart,
313
- release: o.release,
314
- namespace: o.namespace,
315
- ...(o.wait ? { wait: o.wait } : {}),
316
- }),
317
- );
311
+ try {
312
+ await ssh.run(
313
+ helmUpgradeCommand({
314
+ ...o.flags,
315
+ remoteChart,
316
+ release: o.release,
317
+ namespace: o.namespace,
318
+ ...(o.wait ? { wait: o.wait } : {}),
319
+ }),
320
+ );
321
+ } finally {
322
+ if (o.secretFiles?.length)
323
+ await ssh.run(
324
+ `rm -f ${o.secretFiles.map((f) => shq(`${remoteChart}/${f}`)).join(' ')}`,
325
+ );
326
+ }
318
327
  for (const d of o.deployments) {
319
328
  const target = d.includes('/') ? d : `deploy/${d}`;
320
329
  ctx.log('waiting for rollout', { deployment: target });
@@ -1,7 +1,7 @@
1
1
  // Helm deploys to a target from config (`helmTarget()`): steps.helmDeployInCluster runs helm in the step
2
2
  // pod against its own cluster; steps.helmDeployOverSsh({ target, … }) runs it on a k3s host. Pick one
3
3
  // per target kind with `select`. Images come from a registry, pinned by digest.
4
- import { existsSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, rmSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import type { Ctx } from '../ctx/types.ts';
@@ -191,6 +191,13 @@ export interface HelmTargetDeployDef<
191
191
  * overrides only when set.
192
192
  */
193
193
  inlineValues?: readonly Readonly<Record<string, unknown>>[];
194
+ /**
195
+ * Secrets the `secretRef`s in `inlineValues` may name; the step declares them. Each `{ secret: NAME }`
196
+ * in the resolved values becomes the secret's value, so a chart can render its own Secret from a
197
+ * pipeline secret. The values files holding one are deleted after the upgrade (helm's release record
198
+ * keeps the values, as for any Helm value).
199
+ */
200
+ secrets?: readonly Secret[];
194
201
  /**
195
202
  * The images to deploy: step input key → the chart's values prefix. Each input is a `PushedImage`;
196
203
  * the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`. `{}` deploys
@@ -274,6 +281,7 @@ interface AnyTargetDef {
274
281
  chart: string;
275
282
  target: unknown;
276
283
  inlineValues?: readonly Readonly<Record<string, unknown>>[];
284
+ secrets?: readonly Secret[];
277
285
  images?: Readonly<Record<string, string>>;
278
286
  imageRefs?: Readonly<Record<string, string>>;
279
287
  rolloutTimeoutSeconds?: number;
@@ -308,6 +316,11 @@ function targetDeploy(
308
316
  ...Object.fromEntries(refEntries.map(([key]) => [key, optional(PushedImage)])),
309
317
  };
310
318
  const configurable = { target: def.target, inlineValues: def.inlineValues ?? [] };
319
+ const declaredSecrets = [
320
+ ...(sshKey ? [sshKey] : []),
321
+ ...(def.secrets ?? []).filter((s) => s.name !== sshKey?.name),
322
+ ];
323
+ const valueSecrets = new Set((def.secrets ?? []).map((s) => s.name));
311
324
 
312
325
  const contract = step({
313
326
  name: def.name,
@@ -316,7 +329,7 @@ function targetDeploy(
316
329
  image: inCluster ? HELM_IMAGE : HELM_DEPLOY_IMAGE,
317
330
  input,
318
331
  output: HelmRelease,
319
- secrets: sshKey ? [sshKey] : [],
332
+ secrets: declaredSecrets,
320
333
  timeoutSeconds: def.timeoutSeconds,
321
334
  ...(inCluster ? { clusterAccess: true } : {}),
322
335
  settings: {
@@ -349,11 +362,27 @@ function targetDeploy(
349
362
  );
350
363
  const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
351
364
 
365
+ const secretFiles: string[] = [];
352
366
  const generated = inlineValues.map((values, i) => {
353
367
  const file = `olve-values-${i}.json`;
354
- writeFileSync(join(chartDir, file), JSON.stringify(values));
368
+ const resolved = resolveSecretRefs(values, (name) => {
369
+ if (!valueSecrets.has(name))
370
+ throw new Error(
371
+ `${kind} '${def.name}': inlineValues names secret ${name}, which the step does not declare (add it to secrets)`,
372
+ );
373
+ const value = (ctx.secrets as Record<string, string | undefined>)[name];
374
+ if (!value) throw new Error(`secret ${name} (inlineValues) is not set`);
375
+ return value;
376
+ });
377
+ if (resolved.secret) secretFiles.push(file);
378
+ writeFileSync(join(chartDir, file), JSON.stringify(resolved.value), {
379
+ mode: resolved.secret ? 0o600 : 0o644,
380
+ });
355
381
  return file;
356
382
  });
383
+ const removeSecretFiles = () => {
384
+ for (const file of secretFiles) rmSync(join(chartDir, file), { force: true });
385
+ };
357
386
  const flags: HelmImageFlags[] = imageEntries.map(([key, prefix]) => {
358
387
  const image = input[key]!;
359
388
  return {
@@ -395,7 +424,8 @@ function targetDeploy(
395
424
  flags: helm,
396
425
  deployments: deployments ?? (imageEntries.length ? [release] : []),
397
426
  rolloutTimeoutSeconds: rolloutTimeout,
398
- });
427
+ secretFiles,
428
+ }).finally(removeSecretFiles);
399
429
  } else {
400
430
  if (!existsSync(SERVICE_ACCOUNT_TOKEN))
401
431
  throw new Error(
@@ -407,29 +437,33 @@ function targetDeploy(
407
437
  namespace,
408
438
  images: flags.map((f) => `${f.repository}@${f.digest}`).join(' '),
409
439
  });
410
- await ctx.exec(
411
- [
412
- 'helm',
413
- 'upgrade',
414
- '--install',
415
- release,
416
- '.',
417
- '-n',
418
- namespace,
419
- ...helmFlags(helm),
420
- '--wait',
421
- `--timeout=${rolloutTimeout}s`,
422
- ],
423
- {
424
- cwd: chartDir,
425
- env: {
426
- HOME: home,
427
- HELM_CACHE_HOME: join(home, 'cache'),
428
- HELM_CONFIG_HOME: join(home, 'config'),
429
- HELM_DATA_HOME: join(home, 'data'),
440
+ try {
441
+ await ctx.exec(
442
+ [
443
+ 'helm',
444
+ 'upgrade',
445
+ '--install',
446
+ release,
447
+ '.',
448
+ '-n',
449
+ namespace,
450
+ ...helmFlags(helm),
451
+ '--wait',
452
+ `--timeout=${rolloutTimeout}s`,
453
+ ],
454
+ {
455
+ cwd: chartDir,
456
+ env: {
457
+ HOME: home,
458
+ HELM_CACHE_HOME: join(home, 'cache'),
459
+ HELM_CONFIG_HOME: join(home, 'config'),
460
+ HELM_DATA_HOME: join(home, 'data'),
461
+ },
430
462
  },
431
- },
432
- );
463
+ );
464
+ } finally {
465
+ removeSecretFiles();
466
+ }
433
467
  }
434
468
 
435
469
  const url = (target.url ?? serviceUrl({ release, namespace })).replace(/\/+$/, '');
@@ -447,3 +481,35 @@ function targetDeploy(
447
481
  } as never;
448
482
  }) as never;
449
483
  }
484
+
485
+ /**
486
+ * `v` with every `{ secret: NAME }` (a resolved `secretRef`) replaced by `lookup(NAME)`; `secret` says
487
+ * whether there was one.
488
+ */
489
+ export function resolveSecretRefs(
490
+ v: unknown,
491
+ lookup: (name: string) => string,
492
+ ): { value: unknown; secret: boolean } {
493
+ let secret = false;
494
+ const walk = (x: unknown): unknown => {
495
+ if (Array.isArray(x)) return x.map(walk);
496
+ if (
497
+ x !== null &&
498
+ typeof x === 'object' &&
499
+ Object.getPrototypeOf(x) === Object.prototype
500
+ ) {
501
+ const entries = Object.entries(x as Record<string, unknown>);
502
+ if (
503
+ entries.length === 1 &&
504
+ entries[0]![0] === 'secret' &&
505
+ typeof entries[0]![1] === 'string'
506
+ ) {
507
+ secret = true;
508
+ return lookup(entries[0]![1]);
509
+ }
510
+ return Object.fromEntries(entries.map(([k, y]) => [k, walk(y)]));
511
+ }
512
+ return x;
513
+ };
514
+ return { value: walk(v), secret };
515
+ }