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

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 (54) hide show
  1. package/README.md +125 -62
  2. package/manifest.schema.json +208 -11
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +121 -51
  6. package/src/build/cli.ts +29 -22
  7. package/src/build/elf.ts +8 -7
  8. package/src/build/hashes.ts +82 -20
  9. package/src/build/project.ts +95 -28
  10. package/src/builder/pipeline.ts +344 -88
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +7 -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 +61 -15
  17. package/src/core/models.ts +32 -15
  18. package/src/core/origin.ts +23 -0
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +89 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +111 -40
  24. package/src/ctx/index.ts +84 -31
  25. package/src/ctx/sh.ts +105 -44
  26. package/src/ctx/types.ts +12 -4
  27. package/src/describe/describe.ts +701 -133
  28. package/src/describe/manifest-schema.ts +258 -61
  29. package/src/describe/mapper-source.ts +249 -104
  30. package/src/describe/record.ts +67 -37
  31. package/src/dev/cli.ts +72 -35
  32. package/src/harness/env.ts +45 -25
  33. package/src/harness/execute.ts +58 -29
  34. package/src/harness/index.ts +22 -9
  35. package/src/harness/main.ts +239 -78
  36. package/src/harness/result.ts +38 -22
  37. package/src/harness/validate.ts +77 -15
  38. package/src/index.ts +124 -11
  39. package/src/library/checks.ts +28 -0
  40. package/src/library/compose-deploy.ts +113 -0
  41. package/src/library/github-source.ts +100 -47
  42. package/src/library/helm-deploy-ssh.ts +452 -108
  43. package/src/library/helm-deploy.ts +449 -0
  44. package/src/library/http-health.ts +7 -5
  45. package/src/library/image-build.ts +499 -105
  46. package/src/library/index.ts +48 -12
  47. package/src/library/models.ts +33 -9
  48. package/src/library/ssh.ts +67 -0
  49. package/src/testing/index.ts +74 -36
  50. package/src/util/canonical.ts +7 -4
  51. package/src/util/handler-hash.ts +17 -7
  52. package/src/util/hash.ts +5 -5
  53. package/src/util/tar.ts +86 -36
  54. package/src/library/script.ts +0 -85
package/src/build/elf.ts CHANGED
@@ -1,22 +1,23 @@
1
- import { closeSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, openSync, readSync } from 'node:fs';
2
2
 
3
- export const GLIBC_INTERP = "/lib64/ld-linux-x86-64.so.2";
4
- export const MUSL_INTERP = "/lib/ld-musl-x86-64.so.1";
3
+ export const GLIBC_INTERP = '/lib64/ld-linux-x86-64.so.2';
4
+ export const MUSL_INTERP = '/lib/ld-musl-x86-64.so.1';
5
5
 
6
6
  function read(fd: number, pos: number, len: number): Buffer {
7
7
  const b = Buffer.alloc(len);
8
8
  const n = readSync(fd, b, 0, len, pos);
9
- if (n !== len) throw new Error("truncated ELF file");
9
+ if (n !== len) throw new Error('truncated ELF file');
10
10
  return b;
11
11
  }
12
12
 
13
13
  /** PT_INTERP of an x86-64 ELF executable, or null if it has none (static). */
14
14
  export function readInterpreter(path: string): string | null {
15
- const fd = openSync(path, "r");
15
+ const fd = openSync(path, 'r');
16
16
  try {
17
17
  const h = read(fd, 0, 64);
18
18
  if (h.readUInt32BE(0) !== 0x7f454c46) throw new Error(`${path} is not an ELF file`);
19
- if (h[4] !== 2 || h[5] !== 1) throw new Error(`${path} is not a 64-bit little-endian ELF file`);
19
+ if (h[4] !== 2 || h[5] !== 1)
20
+ throw new Error(`${path} is not a 64-bit little-endian ELF file`);
20
21
  const phoff = Number(h.readBigUInt64LE(0x20));
21
22
  const phentsize = h.readUInt16LE(0x36);
22
23
  const phnum = h.readUInt16LE(0x38);
@@ -25,7 +26,7 @@ export function readInterpreter(path: string): string | null {
25
26
  if (ph.readUInt32LE(0) === 3 /* PT_INTERP */) {
26
27
  const off = Number(ph.readBigUInt64LE(0x08));
27
28
  const size = Number(ph.readBigUInt64LE(0x20));
28
- return read(fd, off, size).toString("latin1").replace(/\0+$/, "");
29
+ return read(fd, off, size).toString('latin1').replace(/\0+$/, '');
29
30
  }
30
31
  }
31
32
  return null;
@@ -2,11 +2,15 @@
2
2
  // (tree-shaken → function-level granularity; embedded scripts are bundle outputs too), then hash the
3
3
  // outputs. Contract changes are versioned through the manifest's model/step entries, SDK changes
4
4
  // through `harness`; both are combined in composeHandlerHash.
5
- import { dirname, join, resolve } from "node:path";
6
- import type { BunPlugin } from "bun";
7
- import { composeHandlerHash, type HarnessInfo } from "../util/handler-hash.ts";
8
- import { sha256Hex } from "../util/hash.ts";
9
- import type { Project } from "./project.ts";
5
+ import { dirname, join, relative, resolve } from 'node:path';
6
+ import type { BunPlugin } from 'bun';
7
+ import { isImplementation } from '../core/handlers.ts';
8
+ import { originsOf } from '../core/origin.ts';
9
+ import { pipelineData } from '../builder/pipeline.ts';
10
+ import { allSteps } from '../harness/main.ts';
11
+ import { composeHandlerHash, type HarnessInfo } from '../util/handler-hash.ts';
12
+ import { sha256Hex } from '../util/hash.ts';
13
+ import type { Project } from './project.ts';
10
14
 
11
15
  export interface HandlerHash {
12
16
  /** Hash of the bundle outputs alone. */
@@ -20,48 +24,106 @@ export interface HandlerHash {
20
24
  function externals(project: Project): BunPlugin {
21
25
  const contracts = new Set(project.contractFiles);
22
26
  return {
23
- name: "olve-handler-hash-externals",
27
+ name: 'olve-handler-hash-externals',
24
28
  setup(b) {
25
- b.onResolve({ filter: /^@olve\/pipelines(\/.*)?$/ }, (args) => ({ path: args.path, external: true }));
29
+ b.onResolve({ filter: /^@olivervea\/pipelines(\/.*)?$/ }, (args) => ({
30
+ path: args.path,
31
+ external: true,
32
+ }));
26
33
  b.onResolve({ filter: /^\.{1,2}\// }, (args) => {
27
34
  const base = resolve(dirname(args.importer), args.path);
28
- for (const cand of [base, `${base}.ts`, join(base, "index.ts")]) if (contracts.has(cand)) return { path: args.path, external: true };
35
+ for (const cand of [base, `${base}.ts`, join(base, 'index.ts')])
36
+ if (contracts.has(cand)) return { path: args.path, external: true };
29
37
  return undefined;
30
38
  });
31
39
  },
32
40
  };
33
41
  }
34
42
 
35
- export async function bundleHash(project: Project, entry: string): Promise<{ hash: string; outputs: string[] }> {
43
+ export async function bundleHash(
44
+ project: Project,
45
+ entry: string,
46
+ ): Promise<{ hash: string; outputs: string[] }> {
47
+ const inside = !relative(project.dir, entry).startsWith('..');
36
48
  const r = await Bun.build({
37
49
  entrypoints: [entry],
38
- target: "bun",
39
- format: "esm",
40
- root: project.dir,
41
- naming: { entry: "[dir]/[name].[ext]", chunk: "[dir]/[name].[ext]", asset: "[dir]/[name].[ext]" },
50
+ target: 'bun',
51
+ format: 'esm',
52
+ root: inside ? project.dir : dirname(entry),
53
+ naming: {
54
+ entry: '[dir]/[name].[ext]',
55
+ chunk: '[dir]/[name].[ext]',
56
+ asset: '[dir]/[name].[ext]',
57
+ },
42
58
  plugins: [externals(project)],
43
59
  throw: false,
44
60
  });
45
- if (!r.success) throw new AggregateError(r.logs, `bundling ${entry} failed:\n${r.logs.map(String).join("\n")}`);
61
+ if (!r.success)
62
+ throw new AggregateError(
63
+ r.logs,
64
+ `bundling ${entry} failed:\n${r.logs.map(String).join('\n')}`,
65
+ );
46
66
  const parts: string[] = [];
47
67
  const outputs: string[] = [];
48
- for (const o of [...r.outputs].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
49
- const name = o.path.replace(/^\.\//, "");
68
+ for (const o of [...r.outputs].sort((a, b) =>
69
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
70
+ )) {
71
+ const name = o.path.replace(/^\.\//, '');
50
72
  outputs.push(name);
51
73
  const bytes = new Uint8Array(await o.arrayBuffer());
52
74
  // Bun prefixes each module with a `// <path>` comment relative to the *process cwd*; drop those
53
75
  // so the hash depends only on code, not on where the repo or the build tool runs.
54
- const content = name.endsWith(".js") ? new TextDecoder().decode(bytes).replace(/^\/\/ \S+\n/gm, "") : bytes;
76
+ const content = name.endsWith('.js')
77
+ ? new TextDecoder().decode(bytes).replace(/^\/\/ \S+\n/gm, '')
78
+ : bytes;
55
79
  parts.push(`${name}\0${sha256Hex(content)}`);
56
80
  }
57
- return { hash: "sha256:" + sha256Hex(parts.join("\n")), outputs };
81
+ return { hash: 'sha256:' + sha256Hex(parts.join('\n')), outputs };
58
82
  }
59
83
 
60
- export async function handlerHashes(project: Project, harness: HarnessInfo): Promise<Record<string, HandlerHash>> {
84
+ export async function handlerHashes(
85
+ project: Project,
86
+ harness: HarnessInfo,
87
+ ): Promise<Record<string, HandlerHash>> {
61
88
  const out: Record<string, HandlerHash> = {};
62
89
  for (const [name, file] of project.handlers) {
63
90
  const b = await bundleHash(project, file);
64
- out[name] = { bundleHash: b.hash, handlerHash: composeHandlerHash(b.hash, harness), outputs: b.outputs };
91
+ out[name] = {
92
+ bundleHash: b.hash,
93
+ handlerHash: composeHandlerHash(b.hash, harness),
94
+ outputs: b.outputs,
95
+ };
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Kind → hash of the modules that implement the steps of that kind which carry their implementation
102
+ * (each module bundled like a handler: the SDK and contract files external).
103
+ */
104
+ export async function kindHashes(
105
+ project: Project,
106
+ pipeline: unknown,
107
+ ): Promise<Record<string, string>> {
108
+ const origins = new Map<string, Set<string>>();
109
+ for (const s of allSteps(pipelineData(pipeline))) {
110
+ if (!isImplementation(s)) continue;
111
+ const files = originsOf(s).filter((f) => !project.contractFiles.includes(f));
112
+ if (files.length === 0)
113
+ throw new Error(
114
+ `step '${s.name}' carries its implementation but is defined in ${project.contractFiles.map((f) => relative(project.dir, f)).join(' or ')}; define and implement it in a module of its own (or a package)`,
115
+ );
116
+ const set = origins.get(s.kind) ?? new Set<string>();
117
+ for (const f of files) set.add(f);
118
+ origins.set(s.kind, set);
119
+ }
120
+ const out: Record<string, string> = {};
121
+ for (const [kind, files] of [...origins].sort(([a], [b]) =>
122
+ a < b ? -1 : a > b ? 1 : 0,
123
+ )) {
124
+ const parts: string[] = [];
125
+ for (const f of [...files].sort()) parts.push((await bundleHash(project, f)).hash);
126
+ out[kind] = 'sha256:' + sha256Hex(parts.join('\n'));
65
127
  }
66
128
  return out;
67
129
  }
@@ -1,38 +1,96 @@
1
1
  // Layout of a pipeline directory and the identity of the SDK building it.
2
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
- import { dirname, join, relative, resolve } from "node:path";
4
- import { sha256Hex } from "../util/hash.ts";
5
- import type { HarnessInfo } from "../util/handler-hash.ts";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
+ import { isImplementation } from '../core/handlers.ts';
5
+ import { sha256Hex } from '../util/hash.ts';
6
+ import type { HarnessInfo } from '../util/handler-hash.ts';
6
7
 
7
- export const SDK_ROOT = resolve(import.meta.dir, "../..");
8
+ export const SDK_ROOT = resolve(import.meta.dir, '../..');
8
9
 
9
10
  export interface Project {
10
11
  dir: string;
11
12
  pipelineFile: string;
12
13
  /** Contract files (excluded from handler bundles): pipeline.ts, models.ts. */
13
14
  contractFiles: string[];
14
- /** step name → absolute handler file, sorted by name. */
15
+ /** step name → absolute handler file, sorted by step name. The step is the one the file's `implement(step, …)` names. */
15
16
  handlers: [string, string][];
16
17
  }
17
18
 
18
19
  export class ProjectError extends Error {
19
- override name = "ProjectError";
20
+ override name = 'ProjectError';
20
21
  }
21
22
 
22
- export function loadProject(dirArg: string): Project {
23
+ export async function loadProject(dirArg: string): Promise<Project> {
23
24
  const dir = resolve(dirArg);
24
- if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new ProjectError(`${dirArg} is not a directory`);
25
- const pipelineFile = join(dir, "pipeline.ts");
26
- if (!existsSync(pipelineFile)) throw new ProjectError(`${join(dirArg, "pipeline.ts")} not found (it must default-export pipeline({...}))`);
27
- const contractFiles = ["pipeline.ts", "models.ts"].map((f) => join(dir, f)).filter((f) => existsSync(f));
28
- const hdir = join(dir, "handlers");
29
- const handlers: [string, string][] = existsSync(hdir)
25
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
26
+ throw new ProjectError(`${dirArg} is not a directory`);
27
+ const pipelineFile = join(dir, 'pipeline.ts');
28
+ if (!existsSync(pipelineFile))
29
+ throw new ProjectError(
30
+ `${join(dirArg, 'pipeline.ts')} not found (it must default-export pipeline({...}))`,
31
+ );
32
+ const contractFiles = ['pipeline.ts', 'models.ts']
33
+ .map((f) => join(dir, f))
34
+ .filter((f) => existsSync(f));
35
+ const hdir = join(dir, 'handlers');
36
+ const files = existsSync(hdir)
30
37
  ? readdirSync(hdir)
31
- .filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".d.ts") && statSync(join(hdir, f)).isFile())
38
+ .filter(
39
+ (f) =>
40
+ f.endsWith('.ts') &&
41
+ !f.endsWith('.test.ts') &&
42
+ !f.endsWith('.d.ts') &&
43
+ statSync(join(hdir, f)).isFile(),
44
+ )
32
45
  .sort()
33
- .map((f) => [f.slice(0, -3), join(hdir, f)])
34
46
  : [];
35
- return { dir, pipelineFile, contractFiles, handlers };
47
+ return {
48
+ dir,
49
+ pipelineFile,
50
+ contractFiles,
51
+ handlers: await resolveHandlers(hdir, files),
52
+ };
53
+ }
54
+
55
+ /** The default export of pipeline.ts. */
56
+ export async function loadPipeline(project: Project): Promise<unknown> {
57
+ const mod = (await import(project.pipelineFile)) as { default?: unknown };
58
+ if (!mod.default)
59
+ throw new ProjectError(
60
+ `${project.pipelineFile} has no default export (export default pipeline({...})...)`,
61
+ );
62
+ return mod.default;
63
+ }
64
+
65
+ /** Pairs each handler file with the step its default export implements (file names don't matter). */
66
+ async function resolveHandlers(
67
+ hdir: string,
68
+ files: string[],
69
+ ): Promise<[string, string][]> {
70
+ const byStep = new Map<string, string>();
71
+ for (const f of files) {
72
+ const file = join(hdir, f);
73
+ let impl: unknown;
74
+ try {
75
+ impl = ((await import(file)) as { default?: unknown }).default;
76
+ } catch (e) {
77
+ throw new ProjectError(
78
+ `handlers/${f} failed to load: ${(e as Error)?.message ?? String(e)}`,
79
+ );
80
+ }
81
+ if (!isImplementation(impl))
82
+ throw new ProjectError(
83
+ `handlers/${f} must default-export implement(<step>, …); put shared code in a subfolder such as handlers/lib/`,
84
+ );
85
+ const step = (impl.step as { name: string }).name;
86
+ const other = byStep.get(step);
87
+ if (other)
88
+ throw new ProjectError(
89
+ `handlers/${other.slice(hdir.length + 1)} and handlers/${f} both implement step '${step}'`,
90
+ );
91
+ byStep.set(step, file);
92
+ }
93
+ return [...byStep].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
36
94
  }
37
95
 
38
96
  function listFiles(dir: string): string[] {
@@ -52,28 +110,37 @@ let cached: HarnessInfo | undefined;
52
110
  */
53
111
  export function harnessInfo(): HarnessInfo {
54
112
  if (cached) return cached;
55
- const pkg = JSON.parse(readFileSync(join(SDK_ROOT, "package.json"), "utf8")) as { version: string };
56
- const src = join(SDK_ROOT, "src");
113
+ const pkg = JSON.parse(readFileSync(join(SDK_ROOT, 'package.json'), 'utf8')) as {
114
+ version: string;
115
+ };
116
+ const src = join(SDK_ROOT, 'src');
57
117
  const files = listFiles(src)
58
- .map((f) => relative(src, f).split("\\").join("/"))
59
- .filter((f) => !f.startsWith("build/") && !f.startsWith("dev/") && !f.startsWith("testing/") && f.endsWith(".ts"))
118
+ .map((f) => relative(src, f).split('\\').join('/'))
119
+ .filter(
120
+ (f) =>
121
+ !f.startsWith('build/') &&
122
+ !f.startsWith('dev/') &&
123
+ !f.startsWith('testing/') &&
124
+ f.endsWith('.ts'),
125
+ )
60
126
  .sort();
61
- const h = new Bun.CryptoHasher("sha256");
127
+ const h = new Bun.CryptoHasher('sha256');
62
128
  for (const f of files) h.update(`${f}\0${sha256Hex(readFileSync(join(src, f)))}\n`);
63
- let zodVersion = "unknown";
129
+ let zodVersion = 'unknown';
64
130
  try {
65
- const zodPkg = Bun.resolveSync("zod/package.json", SDK_ROOT);
66
- zodVersion = (JSON.parse(readFileSync(zodPkg, "utf8")) as { version: string }).version;
131
+ const zodPkg = Bun.resolveSync('zod/package.json', SDK_ROOT);
132
+ zodVersion = (JSON.parse(readFileSync(zodPkg, 'utf8')) as { version: string })
133
+ .version;
67
134
  } catch {
68
135
  /* keep unknown */
69
136
  }
70
137
  h.update(`zod@${zodVersion}\n`);
71
- cached = { bun: Bun.version, sdk: pkg.version, sdkHash: "sha256:" + h.digest("hex") };
138
+ cached = { bun: Bun.version, sdk: pkg.version, sdkHash: 'sha256:' + h.digest('hex') };
72
139
  return cached;
73
140
  }
74
141
 
75
142
  export const relPosix = (from: string, to: string) => {
76
- const r = relative(from, to).split("\\").join("/");
77
- return r.startsWith(".") ? r : `./${r}`;
143
+ const r = relative(from, to).split('\\').join('/');
144
+ return r.startsWith('.') ? r : `./${r}`;
78
145
  };
79
146
  export { dirname };