@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +407 -2
- package/manifest.schema.json +524 -0
- package/package.json +60 -4
- package/src/assets.d.ts +10 -0
- package/src/build/build.ts +125 -0
- package/src/build/cli.ts +52 -0
- package/src/build/elf.ts +35 -0
- package/src/build/hashes.ts +67 -0
- package/src/build/project.ts +79 -0
- package/src/builder/pipeline.ts +291 -0
- package/src/builder/refs.ts +26 -0
- package/src/core/brands.ts +13 -0
- package/src/core/handlers.ts +44 -0
- package/src/core/models.ts +69 -0
- package/src/core/secrets.ts +13 -0
- package/src/core/steps.ts +107 -0
- package/src/core/types.ts +6 -0
- package/src/ctx/files.ts +161 -0
- package/src/ctx/index.ts +119 -0
- package/src/ctx/sh.ts +196 -0
- package/src/ctx/types.ts +113 -0
- package/src/describe/describe.ts +379 -0
- package/src/describe/manifest-schema.ts +131 -0
- package/src/describe/mapper-source.ts +238 -0
- package/src/describe/record.ts +107 -0
- package/src/dev/cli.ts +80 -0
- package/src/harness/env.ts +58 -0
- package/src/harness/execute.ts +94 -0
- package/src/harness/exit.ts +19 -0
- package/src/harness/index.ts +9 -0
- package/src/harness/main.ts +219 -0
- package/src/harness/result.ts +75 -0
- package/src/harness/validate.ts +74 -0
- package/src/index.ts +11 -0
- package/src/library/github-source.ts +77 -0
- package/src/library/helm-deploy-ssh.ts +151 -0
- package/src/library/http-health.ts +34 -0
- package/src/library/image-build.ts +134 -0
- package/src/library/index.ts +14 -0
- package/src/library/models.ts +63 -0
- package/src/library/script.ts +85 -0
- package/src/testing/index.ts +86 -0
- package/src/util/canonical.ts +19 -0
- package/src/util/handler-hash.ts +22 -0
- package/src/util/hash.ts +22 -0
- package/src/util/tar.ts +154 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Static analysis of mapper source (`fn.toString()`), an allowlist parser.
|
|
2
|
+
//
|
|
3
|
+
// Recording proxies cannot observe truthiness, `!`, `??`, `||`, `&&`, ternaries or `===` against a
|
|
4
|
+
// value: they silently record a constant. So describe also checks the mapper's *source*: its body
|
|
5
|
+
// must be an object literal whose values are member chains, literals, constant arrays or nested
|
|
6
|
+
// object literals. Everything else is rejected with the offending construct named.
|
|
7
|
+
//
|
|
8
|
+
// Accepted shapes (after Bun's TypeScript transpilation):
|
|
9
|
+
// from => ({ a: from.input.sha, b: from["deploy-beta"].url, c: "lit", d: 1, e: { f: from.build } })
|
|
10
|
+
// (from) => { return { … }; }
|
|
11
|
+
// ({ input, "deploy-beta": beta }) => ({ sha: input.sha, url: beta.url })
|
|
12
|
+
// function (from) { return { … }; }
|
|
13
|
+
// Free identifiers (`RELEASE`, `CONFIG.url`) are allowed: they are module constants, recorded as
|
|
14
|
+
// `{const}` by the proxy. This requires the harness bundle not to be minified.
|
|
15
|
+
import { MapperError } from "./record.ts";
|
|
16
|
+
|
|
17
|
+
type Tok =
|
|
18
|
+
| { t: "ident"; v: string; pos: number }
|
|
19
|
+
| { t: "str"; v: string; pos: number }
|
|
20
|
+
| { t: "num"; v: string; pos: number }
|
|
21
|
+
| { t: "punc"; v: string; pos: number }
|
|
22
|
+
| { t: "op"; v: string; pos: number }
|
|
23
|
+
| { t: "eof"; v: ""; pos: number };
|
|
24
|
+
|
|
25
|
+
const PUNCS = ["...", "=>", "?.", "(", ")", "{", "}", "[", "]", ",", ":", ".", ";"];
|
|
26
|
+
const OPS = ["===", "!==", "**=", "??=", "&&=", "||=", "==", "!=", "<=", ">=", "&&", "||", "??", "++", "--", "**", "<<", ">>", "+=", "-=", "*=", "/=", "?", "!", "+", "-", "*", "/", "%", "<", ">", "=", "&", "|", "^", "~", "@", "#"];
|
|
27
|
+
|
|
28
|
+
function describeOp(op: string): string {
|
|
29
|
+
if (op === "?") return "a ternary (`? :`)";
|
|
30
|
+
if (op === "??") return "nullish coalescing (`??`)";
|
|
31
|
+
if (op === "&&" || op === "||") return `a logical operator (\`${op}\`)`;
|
|
32
|
+
if (op === "!") return "negation (`!`)";
|
|
33
|
+
if (["===", "!==", "==", "!=", "<", ">", "<=", ">="].includes(op)) return `a comparison (\`${op}\`)`;
|
|
34
|
+
if (["+", "-", "*", "/", "%", "**"].includes(op)) return `arithmetic/concatenation (\`${op}\`)`;
|
|
35
|
+
if (op.endsWith("=") || op === "++" || op === "--") return `an assignment (\`${op}\`)`;
|
|
36
|
+
return `the operator \`${op}\``;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const KEYWORDS = new Set([
|
|
40
|
+
"typeof", "void", "delete", "new", "await", "yield", "this", "super", "class", "function", "async",
|
|
41
|
+
"if", "else", "for", "while", "do", "switch", "case", "try", "catch", "throw", "var", "let", "const",
|
|
42
|
+
"in", "instanceof", "import", "export", "return", "arguments", "eval",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function tokenize(src: string): Tok[] {
|
|
46
|
+
const toks: Tok[] = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < src.length) {
|
|
49
|
+
const c = src[i]!;
|
|
50
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
51
|
+
if (src.startsWith("//", i)) { const e = src.indexOf("\n", i); i = e < 0 ? src.length : e + 1; continue; }
|
|
52
|
+
if (src.startsWith("/*", i)) {
|
|
53
|
+
const e = src.indexOf("*/", i + 2);
|
|
54
|
+
if (e < 0) throw new MapperError("mapper source has an unterminated comment");
|
|
55
|
+
i = e + 2;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === "`") throw new MapperError("mapper uses a template string");
|
|
59
|
+
if (c === '"' || c === "'") {
|
|
60
|
+
let j = i + 1;
|
|
61
|
+
let v = "";
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (j >= src.length) throw new MapperError("mapper source has an unterminated string");
|
|
64
|
+
const d = src[j]!;
|
|
65
|
+
if (d === c) break;
|
|
66
|
+
if (d === "\\") { v += src.slice(j, j + 2); j += 2; continue; }
|
|
67
|
+
v += d;
|
|
68
|
+
j++;
|
|
69
|
+
}
|
|
70
|
+
toks.push({ t: "str", v, pos: i });
|
|
71
|
+
i = j + 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const num = /^(?:0[xXoObB][0-9a-fA-F_]+|(?:\d[\d_]*)?\.?\d[\d_]*(?:[eE][+-]?\d+)?)n?/.exec(src.slice(i));
|
|
75
|
+
if (num && /[\d.]/.test(c) && num[0] !== ".") {
|
|
76
|
+
toks.push({ t: "num", v: num[0], pos: i });
|
|
77
|
+
i += num[0].length;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const id = /^[\p{ID_Start}$_][\p{ID_Continue}$]*/u.exec(src.slice(i));
|
|
81
|
+
if (id) {
|
|
82
|
+
toks.push({ t: "ident", v: id[0], pos: i });
|
|
83
|
+
i += id[0].length;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const p = PUNCS.find((x) => src.startsWith(x, i));
|
|
87
|
+
if (p && !(p === "?." && /\d/.test(src[i + 2] ?? ""))) {
|
|
88
|
+
toks.push({ t: "punc", v: p, pos: i });
|
|
89
|
+
i += p.length;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const o = OPS.find((x) => src.startsWith(x, i));
|
|
93
|
+
if (o) {
|
|
94
|
+
toks.push({ t: "op", v: o, pos: i });
|
|
95
|
+
i += o.length;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
throw new MapperError(`mapper source has an unexpected character '${c}'`);
|
|
99
|
+
}
|
|
100
|
+
toks.push({ t: "eof", v: "", pos: src.length });
|
|
101
|
+
return toks;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Throws MapperError unless `src` is an allowed mapper. */
|
|
105
|
+
export function analyzeMapperSource(src: string): void {
|
|
106
|
+
const toks = tokenize(src);
|
|
107
|
+
let k = 0;
|
|
108
|
+
const peek = (o = 0) => toks[Math.min(k + o, toks.length - 1)]!;
|
|
109
|
+
const next = () => toks[k++]!;
|
|
110
|
+
const is = (t: Tok["t"], v?: string, o = 0) => peek(o).t === t && (v === undefined || peek(o).v === v);
|
|
111
|
+
const unexpected = (what = "here"): never => {
|
|
112
|
+
const tok = peek();
|
|
113
|
+
if (tok.t === "op") throw new MapperError(`mapper uses ${describeOp(tok.v)}; mappers may only contain property access and literals — move logic into the handler or a when block`);
|
|
114
|
+
if (tok.t === "punc" && tok.v === "?.") throw new MapperError("mapper uses optional chaining (`?.`); declare the input optional(...) and map the whole ref instead");
|
|
115
|
+
if (tok.t === "punc" && tok.v === "...") throw new MapperError("mapper uses spread (`...`); list the fields explicitly");
|
|
116
|
+
if (tok.t === "punc" && tok.v === "(") throw new MapperError("mapper calls a function or method; mappers may only contain property access and literals");
|
|
117
|
+
if (tok.t === "ident" && KEYWORDS.has(tok.v)) throw new MapperError(`mapper uses \`${tok.v}\`; mappers may only contain property access and literals`);
|
|
118
|
+
throw new MapperError(`mapper source is not an object literal of property accesses and literals (unexpected ${tok.t === "eof" ? "end" : `'${tok.v}'`} ${what})`);
|
|
119
|
+
};
|
|
120
|
+
const expect = (t: Tok["t"], v?: string) => (is(t, v) ? next() : unexpected(`(expected ${v ?? t})`));
|
|
121
|
+
|
|
122
|
+
const identifier = () => {
|
|
123
|
+
if (!is("ident") || KEYWORDS.has(peek().v)) unexpected("(expected an identifier)");
|
|
124
|
+
return next();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ── parameters ──
|
|
128
|
+
const params = () => {
|
|
129
|
+
if (is("ident")) { identifier(); return; }
|
|
130
|
+
expect("punc", "(");
|
|
131
|
+
if (is("punc", ")")) { next(); return; }
|
|
132
|
+
if (is("punc", "{")) destructuring();
|
|
133
|
+
else identifier();
|
|
134
|
+
if (is("punc", ",")) next();
|
|
135
|
+
if (is("op", "=")) throw new MapperError("mapper parameter has a default value");
|
|
136
|
+
expect("punc", ")");
|
|
137
|
+
};
|
|
138
|
+
const destructuring = () => {
|
|
139
|
+
expect("punc", "{");
|
|
140
|
+
while (!is("punc", "}")) {
|
|
141
|
+
if (is("str")) { next(); expect("punc", ":"); identifier(); }
|
|
142
|
+
else {
|
|
143
|
+
identifier();
|
|
144
|
+
if (is("punc", ":")) { next(); identifier(); }
|
|
145
|
+
}
|
|
146
|
+
if (is("op", "=")) throw new MapperError("mapper parameter destructuring has a default value");
|
|
147
|
+
if (!is("punc", ",")) break;
|
|
148
|
+
next();
|
|
149
|
+
}
|
|
150
|
+
expect("punc", "}");
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// ── values ──
|
|
154
|
+
const value = (): void => {
|
|
155
|
+
if (is("punc", "(")) { next(); value(); expect("punc", ")"); return; }
|
|
156
|
+
if (is("punc", "{")) return object();
|
|
157
|
+
if (is("punc", "[")) return array();
|
|
158
|
+
if (is("str") || is("num")) { next(); return; }
|
|
159
|
+
if (is("op", "-") && is("num", undefined, 1)) { next(); next(); return; }
|
|
160
|
+
if (is("ident", "true") || is("ident", "false") || is("ident", "null")) { next(); return; }
|
|
161
|
+
if (is("ident", "undefined")) throw new MapperError("mapper uses `undefined`; leave optional fields out instead");
|
|
162
|
+
chain();
|
|
163
|
+
};
|
|
164
|
+
const chain = () => {
|
|
165
|
+
identifier();
|
|
166
|
+
for (;;) {
|
|
167
|
+
if (is("punc", ".")) { next(); if (!is("ident")) unexpected("(expected a property name)"); next(); continue; }
|
|
168
|
+
if (is("punc", "[")) {
|
|
169
|
+
next();
|
|
170
|
+
if (!is("str") && !is("num")) throw new MapperError("mapper uses a computed property access; only literal keys (from[\"step-name\"]) are allowed");
|
|
171
|
+
next();
|
|
172
|
+
expect("punc", "]");
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const array = () => {
|
|
179
|
+
expect("punc", "[");
|
|
180
|
+
while (!is("punc", "]")) {
|
|
181
|
+
if (is("punc", ",")) unexpected("(array holes are not allowed)");
|
|
182
|
+
value();
|
|
183
|
+
if (!is("punc", ",")) break;
|
|
184
|
+
next();
|
|
185
|
+
}
|
|
186
|
+
expect("punc", "]");
|
|
187
|
+
};
|
|
188
|
+
const object = () => {
|
|
189
|
+
expect("punc", "{");
|
|
190
|
+
while (!is("punc", "}")) {
|
|
191
|
+
if (is("punc", "...")) unexpected();
|
|
192
|
+
if (is("punc", "[")) throw new MapperError("mapper uses a computed key; only literal keys are allowed");
|
|
193
|
+
if (is("ident") && (is("punc", ",", 1) || is("punc", "}", 1))) {
|
|
194
|
+
// shorthand `{ input }` — a destructured root or a module constant
|
|
195
|
+
identifier();
|
|
196
|
+
} else {
|
|
197
|
+
if (is("ident") || is("str") || is("num")) next();
|
|
198
|
+
else unexpected("(expected a property name)");
|
|
199
|
+
if (is("punc", "(")) throw new MapperError("mapper defines a method; mappers may only contain property access and literals");
|
|
200
|
+
expect("punc", ":");
|
|
201
|
+
value();
|
|
202
|
+
}
|
|
203
|
+
if (!is("punc", ",")) break;
|
|
204
|
+
next();
|
|
205
|
+
}
|
|
206
|
+
expect("punc", "}");
|
|
207
|
+
};
|
|
208
|
+
const returnBody = () => {
|
|
209
|
+
expect("punc", "{");
|
|
210
|
+
if (!is("ident", "return")) unexpected("(a block-bodied mapper must consist of a single `return { … }`)");
|
|
211
|
+
next();
|
|
212
|
+
object();
|
|
213
|
+
if (is("punc", ";")) next();
|
|
214
|
+
expect("punc", "}");
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// ── function ──
|
|
218
|
+
if (is("ident", "async")) throw new MapperError("mapper must not be async");
|
|
219
|
+
if (is("ident", "function")) {
|
|
220
|
+
next();
|
|
221
|
+
if (is("op", "*")) throw new MapperError("mapper must not be a generator");
|
|
222
|
+
if (is("ident")) next();
|
|
223
|
+
params();
|
|
224
|
+
returnBody();
|
|
225
|
+
} else {
|
|
226
|
+
params();
|
|
227
|
+
expect("punc", "=>");
|
|
228
|
+
if (is("punc", "(")) {
|
|
229
|
+
next();
|
|
230
|
+
object();
|
|
231
|
+
expect("punc", ")");
|
|
232
|
+
} else if (is("punc", "{")) {
|
|
233
|
+
returnBody();
|
|
234
|
+
} else unexpected("(the mapper body must be an object literal: from => ({ … }))");
|
|
235
|
+
}
|
|
236
|
+
if (is("punc", ";")) next();
|
|
237
|
+
if (!is("eof")) unexpected("after the mapper body");
|
|
238
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Records a mapper as a tagged mapping by calling it with recording proxies.
|
|
2
|
+
//
|
|
3
|
+
// The proxy throws on coercion (template strings, `+`, String()), method calls, spreading,
|
|
4
|
+
// `in`, assignment and JSON.stringify. It CANNOT see truthiness, `!`, `??`, `||`, ternaries or
|
|
5
|
+
// `===` against a value — those are rejected by the static source analysis (mapper-source.ts),
|
|
6
|
+
// and primarily by the `Ref<T>` types.
|
|
7
|
+
import { REF_PATH } from "../core/brands.ts";
|
|
8
|
+
|
|
9
|
+
export class MapperError extends Error {
|
|
10
|
+
override name = "MapperError";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Manifest mapping node: `{ref}` | `{const}` | a nested object of mappings. */
|
|
14
|
+
export type Mapping = { ref: string } | { const: unknown } | { [key: string]: Mapping };
|
|
15
|
+
|
|
16
|
+
function refProxy(path: string): unknown {
|
|
17
|
+
const fail = (what: string): never => {
|
|
18
|
+
throw new MapperError(
|
|
19
|
+
`mapper ${what} on reference '${path}'; mappers may only use property access and literals ` +
|
|
20
|
+
`(no template strings, arithmetic, method calls or logic) — move computation into the handler`,
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
const target = function () {}; // function target so calls hit the `apply` trap
|
|
24
|
+
return new Proxy(target, {
|
|
25
|
+
get(_t, prop) {
|
|
26
|
+
if (prop === REF_PATH) return path;
|
|
27
|
+
if (prop === "then") return undefined; // `await ref` must not recurse
|
|
28
|
+
if (prop === Symbol.toPrimitive || prop === "toString" || prop === "valueOf")
|
|
29
|
+
return fail("coerces a value (template string / concatenation / arithmetic / comparison)");
|
|
30
|
+
if (prop === "toJSON") return fail("serializes a reference (JSON.stringify)");
|
|
31
|
+
if (typeof prop === "symbol") return fail(`uses ${String(prop)} (iteration/spread/inspection)`);
|
|
32
|
+
if (prop.includes(".")) return fail(`accesses field '${prop}' (field names containing '.' cannot be mapped)`);
|
|
33
|
+
return refProxy(`${path}.${prop}`);
|
|
34
|
+
},
|
|
35
|
+
apply: () => fail("calls a function/method"),
|
|
36
|
+
construct: () => fail("constructs"),
|
|
37
|
+
set: () => fail("assigns"),
|
|
38
|
+
deleteProperty: () => fail("deletes"),
|
|
39
|
+
has: () => fail("uses the `in` operator"),
|
|
40
|
+
ownKeys: () => fail("enumerates/spreads"),
|
|
41
|
+
defineProperty: () => fail("defines a property"),
|
|
42
|
+
getPrototypeOf: () => fail("inspects the prototype (instanceof)"),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `from` argument: `from.input`, `from.<step-name>`. */
|
|
47
|
+
export function rootProxy(available: ReadonlySet<string>): unknown {
|
|
48
|
+
return new Proxy(
|
|
49
|
+
{},
|
|
50
|
+
{
|
|
51
|
+
get(_t, prop) {
|
|
52
|
+
if (typeof prop === "symbol" || prop === "then") return undefined;
|
|
53
|
+
if (!available.has(prop))
|
|
54
|
+
throw new MapperError(`mapper references '${prop}', which is not available at this stage (available: ${[...available].sort().join(", ") || "none"})`);
|
|
55
|
+
return refProxy(prop);
|
|
56
|
+
},
|
|
57
|
+
has: () => {
|
|
58
|
+
throw new MapperError("mapper uses the `in` operator on its argument");
|
|
59
|
+
},
|
|
60
|
+
ownKeys: () => {
|
|
61
|
+
throw new MapperError("mapper enumerates/spreads its argument");
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const refPathOf = (v: unknown): string | undefined =>
|
|
68
|
+
typeof v === "function" ? ((v as unknown as Record<symbol, unknown>)[REF_PATH] as string | undefined) : undefined;
|
|
69
|
+
|
|
70
|
+
function toMapping(v: unknown, at: string): Mapping {
|
|
71
|
+
const path = refPathOf(v);
|
|
72
|
+
if (path !== undefined) return { ref: path };
|
|
73
|
+
if (typeof v === "function") throw new MapperError(`mapper returns a function at '${at}'`);
|
|
74
|
+
if (v === undefined) throw new MapperError(`mapper returns undefined at '${at}'`);
|
|
75
|
+
if (v === null || typeof v === "string" || typeof v === "boolean") return { const: v };
|
|
76
|
+
if (typeof v === "number") {
|
|
77
|
+
if (!Number.isFinite(v)) throw new MapperError(`mapper returns a non-finite number at '${at}'`);
|
|
78
|
+
return { const: v };
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(v)) {
|
|
81
|
+
const items = v.map((x, i) => toMapping(x, `${at}[${i}]`));
|
|
82
|
+
if (items.every((e) => "const" in e && Object.keys(e).length === 1)) return { const: items.map((e) => (e as { const: unknown }).const) };
|
|
83
|
+
throw new MapperError(`mapper puts references inside an array at '${at}' (only constant arrays are supported)`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof v === "object") {
|
|
86
|
+
if (Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null)
|
|
87
|
+
throw new MapperError(`mapper returns a non-plain object at '${at}'`);
|
|
88
|
+
const keys = Object.keys(v);
|
|
89
|
+
if (keys.length === 1 && (keys[0] === "ref" || keys[0] === "const"))
|
|
90
|
+
throw new MapperError(`mapper builds an object whose only field is '${keys[0]}' at '${at}'; that is reserved by the manifest's mapping encoding — map the whole object with a ref instead`);
|
|
91
|
+
const out: Record<string, Mapping> = {};
|
|
92
|
+
for (const k of keys) out[k] = toMapping((v as Record<string, unknown>)[k], at ? `${at}.${k}` : k);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
throw new MapperError(`mapper returns an unsupported value (${typeof v}) at '${at}'`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Calls `fn` with recording proxies and returns `{ inputKey: Mapping }`. */
|
|
99
|
+
export function recordMapper(fn: (from: unknown) => unknown, available: ReadonlySet<string>): Record<string, Mapping> {
|
|
100
|
+
const result = fn(rootProxy(available));
|
|
101
|
+
if (refPathOf(result) !== undefined || typeof result !== "object" || result === null || Array.isArray(result))
|
|
102
|
+
throw new MapperError("mapper must return an object literal");
|
|
103
|
+
if (result instanceof Promise) throw new MapperError("mapper must be synchronous");
|
|
104
|
+
const out: Record<string, Mapping> = {};
|
|
105
|
+
for (const k of Object.keys(result)) out[k] = toMapping((result as Record<string, unknown>)[k], k);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
package/src/dev/cli.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// olve-dev — run a pipeline from source through the same harness path as the compiled binary.
|
|
3
|
+
// olve-dev describe <dir> [--sentinels]
|
|
4
|
+
// olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
|
|
5
|
+
// olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
|
|
6
|
+
// $OLVE_DIR defaults to <dir>/.olve; outputs land in <dir>/.olve/work/.
|
|
7
|
+
import { copyFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { runHarness, type HarnessConfig } from "../harness/main.ts";
|
|
11
|
+
import { EXIT } from "../harness/exit.ts";
|
|
12
|
+
import { handlerHashes } from "../build/hashes.ts";
|
|
13
|
+
import { harnessInfo, loadProject, ProjectError } from "../build/project.ts";
|
|
14
|
+
|
|
15
|
+
const USAGE = `usage: olve-dev describe <dir> [--sentinels]
|
|
16
|
+
olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
|
|
17
|
+
olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]`;
|
|
18
|
+
|
|
19
|
+
export async function devConfig(dir: string, withHashes: boolean): Promise<HarnessConfig> {
|
|
20
|
+
const project = loadProject(dir);
|
|
21
|
+
const mod = (await import(project.pipelineFile)) as { default?: unknown };
|
|
22
|
+
if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
|
|
23
|
+
const harness = harnessInfo();
|
|
24
|
+
const hashes = withHashes ? await handlerHashes(project, harness) : {};
|
|
25
|
+
return {
|
|
26
|
+
pipeline: mod.default,
|
|
27
|
+
handlers: Object.fromEntries(project.handlers.map(([name, file]) => [name, () => import(file) as Promise<{ default: unknown }>])),
|
|
28
|
+
handlerHashes: Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])),
|
|
29
|
+
harness,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function devMain(argv: string[]): Promise<number> {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = parseArgs({
|
|
37
|
+
args: argv,
|
|
38
|
+
allowPositionals: true,
|
|
39
|
+
options: {
|
|
40
|
+
"input-file": { type: "string" },
|
|
41
|
+
"event-file": { type: "string" },
|
|
42
|
+
"olve-dir": { type: "string" },
|
|
43
|
+
secret: { type: "string", multiple: true },
|
|
44
|
+
sentinels: { type: "boolean", default: false },
|
|
45
|
+
help: { type: "boolean", short: "h", default: false },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error(`${(e as Error).message}\n${USAGE}`);
|
|
50
|
+
return EXIT.usage;
|
|
51
|
+
}
|
|
52
|
+
const { values, positionals } = parsed;
|
|
53
|
+
if (values.help) return console.log(USAGE), 0;
|
|
54
|
+
const [mode, dir, name] = positionals;
|
|
55
|
+
if (!mode || !dir) return console.error(USAGE), EXIT.usage;
|
|
56
|
+
try {
|
|
57
|
+
if (mode === "describe") return await runHarness(await devConfig(dir, true), ["describe", ...(values.sentinels ? ["--sentinels"] : [])]);
|
|
58
|
+
if (mode !== "run" && mode !== "map-trigger") return console.error(USAGE), EXIT.usage;
|
|
59
|
+
if (!name) return console.error(USAGE), EXIT.usage;
|
|
60
|
+
const olveDir = resolve(values["olve-dir"] ?? join(dir, ".olve"));
|
|
61
|
+
mkdirSync(olveDir, { recursive: true });
|
|
62
|
+
process.env.OLVE_DIR = olveDir;
|
|
63
|
+
process.env.TMPDIR ??= join(olveDir, "tmp");
|
|
64
|
+
for (const s of values.secret ?? []) {
|
|
65
|
+
const i = s.indexOf("=");
|
|
66
|
+
if (i <= 0) return console.error(`--secret expects NAME=value, got '${s}'`), EXIT.usage;
|
|
67
|
+
process.env[s.slice(0, i)] = s.slice(i + 1);
|
|
68
|
+
}
|
|
69
|
+
const file = mode === "run" ? values["input-file"] : values["event-file"];
|
|
70
|
+
if (file) copyFileSync(resolve(file), join(olveDir, "input.json"));
|
|
71
|
+
else if (mode === "map-trigger") return console.error("--event-file is required"), EXIT.usage;
|
|
72
|
+
return await runHarness(await devConfig(dir, false), [mode, name]);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e instanceof ProjectError) return console.error(e.message), EXIT.configInvalid;
|
|
75
|
+
console.error((e as Error)?.stack ?? String(e));
|
|
76
|
+
return EXIT.configInvalid;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (import.meta.main) process.exit(await devMain(process.argv.slice(2)));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The environment contract between the service and the harness. Documented in README.md.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const ENV = {
|
|
5
|
+
/** Root of the harness IO directory (default /olve). */
|
|
6
|
+
dir: "OLVE_DIR",
|
|
7
|
+
pipeline: "OLVE_PIPELINE",
|
|
8
|
+
runId: "OLVE_RUN_ID",
|
|
9
|
+
run: "OLVE_RUN",
|
|
10
|
+
attempt: "OLVE_ATTEMPT",
|
|
11
|
+
versionStamp: "OLVE_VERSION_STAMP",
|
|
12
|
+
/** Pre-signed PUT for result.tar.gz (run / map-trigger). Optional. */
|
|
13
|
+
resultUrl: "OLVE_RESULT_URL",
|
|
14
|
+
/** JSON object: file key (`<run>/<step>/<attempt>/<path>`) → pre-signed GET URL. Optional. */
|
|
15
|
+
fileUrls: "OLVE_FILE_URLS",
|
|
16
|
+
/** Base URL for file downloads: GET `<base>/<run>/<step>/<attempt>/<object path>`. Optional. */
|
|
17
|
+
filesBaseUrl: "OLVE_FILES_BASE_URL",
|
|
18
|
+
/** Bearer token sent with OLVE_FILES_BASE_URL requests. Optional. */
|
|
19
|
+
filesToken: "OLVE_FILES_TOKEN",
|
|
20
|
+
/** Local directory laid out as `<run>/<step>/<attempt>/files/<object path>` (dev). Optional. */
|
|
21
|
+
filesDir: "OLVE_FILES_DIR",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/** Env vars never passed to child processes (they carry credentials for the harness only). */
|
|
25
|
+
export const PRIVATE_ENV: readonly string[] = [ENV.resultUrl, ENV.fileUrls, ENV.filesToken];
|
|
26
|
+
|
|
27
|
+
export const olveDir = () => process.env[ENV.dir] || "/olve";
|
|
28
|
+
export const paths = () => {
|
|
29
|
+
const root = olveDir();
|
|
30
|
+
return {
|
|
31
|
+
root,
|
|
32
|
+
input: join(root, "input.json"),
|
|
33
|
+
work: join(root, "work"),
|
|
34
|
+
output: join(root, "work", "output.json"),
|
|
35
|
+
files: join(root, "work", "files"),
|
|
36
|
+
tmp: join(root, "tmp"),
|
|
37
|
+
fileUrls: join(root, "files.json"),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function int(name: string, fallback: number): number {
|
|
42
|
+
const v = process.env[name];
|
|
43
|
+
if (v === undefined || v === "") return fallback;
|
|
44
|
+
const n = Number(v);
|
|
45
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`${name} must be a non-negative integer, got '${v}'`);
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runInfoFromEnv(step: string) {
|
|
50
|
+
return {
|
|
51
|
+
pipeline: process.env[ENV.pipeline] ?? "local",
|
|
52
|
+
id: process.env[ENV.runId] ?? "local",
|
|
53
|
+
number: int(ENV.run, 0),
|
|
54
|
+
attempt: Math.max(1, int(ENV.attempt, 1)),
|
|
55
|
+
step,
|
|
56
|
+
version: process.env[ENV.versionStamp] ?? "0.0.0-local",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The shared execution path: validate input → handler → validate output → envelope.
|
|
2
|
+
// Used by the compiled harness (`run`), the dev runner and testHandler.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isSkipped } from "../core/handlers.ts";
|
|
6
|
+
import type { AnyStep } from "../core/steps.ts";
|
|
7
|
+
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
8
|
+
import { objectPath } from "../ctx/files.ts";
|
|
9
|
+
import { sha256File } from "../util/hash.ts";
|
|
10
|
+
import { EXIT } from "./exit.ts";
|
|
11
|
+
import { validateInput, validateOutput } from "./validate.ts";
|
|
12
|
+
|
|
13
|
+
export type FailureKind = "handler" | "input" | "output";
|
|
14
|
+
export type Envelope =
|
|
15
|
+
| { status: "succeeded"; output: unknown }
|
|
16
|
+
| { status: "skipped"; reason: string }
|
|
17
|
+
| { status: "failed"; error: { kind: FailureKind; message: string } };
|
|
18
|
+
|
|
19
|
+
export interface Execution {
|
|
20
|
+
envelope: Envelope;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type StepFunction = (input: Record<string, unknown>, ctx: Ctx) => unknown;
|
|
25
|
+
|
|
26
|
+
/** Signal → exit code, when the handler failed after a cancellation. */
|
|
27
|
+
export function cancelExitCode(signal: AbortSignal): number | undefined {
|
|
28
|
+
if (!signal.aborted) return undefined;
|
|
29
|
+
return signal.reason === "SIGINT" ? EXIT.sigint : EXIT.sigterm;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const errorMessage = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
33
|
+
|
|
34
|
+
/** File refs in `value` that claim to be this attempt's own outputs must exist with the right hash. */
|
|
35
|
+
function checkOwnFileRefs(value: unknown, run: RunInfo, workFiles: string): string[] {
|
|
36
|
+
const problems: string[] = [];
|
|
37
|
+
const walk = (v: unknown, at: string) => {
|
|
38
|
+
if (Array.isArray(v)) return v.forEach((x, i) => walk(x, `${at}[${i}]`));
|
|
39
|
+
if (typeof v !== "object" || v === null) return;
|
|
40
|
+
const o = v as Record<string, unknown>;
|
|
41
|
+
if (typeof o.path === "string" && typeof o.sha256 === "string" && o.run === run.number && o.step === run.step && o.attempt === run.attempt) {
|
|
42
|
+
const file = join(workFiles, objectPath(o.path));
|
|
43
|
+
if (!existsSync(file)) problems.push(`${at}: file reference '${o.path}' points to this attempt but no such output file was produced`);
|
|
44
|
+
else if (sha256File(file) !== o.sha256) problems.push(`${at}: file reference '${o.path}' has the wrong sha256 (the file changed after it was referenced)`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const [k, x] of Object.entries(o)) walk(x, `${at}.${k}`);
|
|
48
|
+
};
|
|
49
|
+
walk(value, "output");
|
|
50
|
+
return problems;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function executeStep(args: {
|
|
54
|
+
step: AnyStep;
|
|
55
|
+
fn: StepFunction;
|
|
56
|
+
rawInput: unknown;
|
|
57
|
+
ctx: Ctx;
|
|
58
|
+
workFiles: string;
|
|
59
|
+
log: (line: string) => void;
|
|
60
|
+
}): Promise<Execution> {
|
|
61
|
+
const { step, ctx, log } = args;
|
|
62
|
+
const input = validateInput(step, args.rawInput);
|
|
63
|
+
if (!input.ok) {
|
|
64
|
+
const message = `step '${step.name}': input does not match the step's inputs:\n ${input.issues.join("\n ")}`;
|
|
65
|
+
log(message);
|
|
66
|
+
return { envelope: { status: "failed", error: { kind: "input", message } }, exitCode: EXIT.inputInvalid };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let value: unknown;
|
|
70
|
+
try {
|
|
71
|
+
value = await args.fn(input.value, ctx);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
const cancelled = cancelExitCode(ctx.signal);
|
|
74
|
+
const message = cancelled
|
|
75
|
+
? `step '${step.name}' was cancelled (${String(ctx.signal.reason)}): ${errorMessage(e)}`
|
|
76
|
+
: `step '${step.name}' failed: ${errorMessage(e)}`;
|
|
77
|
+
log(message);
|
|
78
|
+
if (e instanceof Error && e.stack && !cancelled) log(e.stack);
|
|
79
|
+
return { envelope: { status: "failed", error: { kind: "handler", message } }, exitCode: cancelled ?? EXIT.handlerFailed };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isSkipped(value)) {
|
|
83
|
+
log(`step '${step.name}' skipped: ${value.reason}`);
|
|
84
|
+
return { envelope: { status: "skipped", reason: value.reason }, exitCode: EXIT.ok };
|
|
85
|
+
}
|
|
86
|
+
const out = validateOutput(step, value);
|
|
87
|
+
const problems = out.ok ? checkOwnFileRefs(out.value, ctx.run, args.workFiles) : out.issues;
|
|
88
|
+
if (!out.ok || problems.length) {
|
|
89
|
+
const message = `step '${step.name}': returned value does not match ${step.output?.name ?? "its (absent) output model"}:\n ${problems.join("\n ")}`;
|
|
90
|
+
log(message);
|
|
91
|
+
return { envelope: { status: "failed", error: { kind: "output", message } }, exitCode: EXIT.outputInvalid };
|
|
92
|
+
}
|
|
93
|
+
return { envelope: { status: "succeeded", output: out.value }, exitCode: EXIT.ok };
|
|
94
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Harness exit codes (sysexits-style). Part of the service contract. */
|
|
2
|
+
export const EXIT = {
|
|
3
|
+
ok: 0,
|
|
4
|
+
/** The handler (or trigger mapper) threw. */
|
|
5
|
+
handlerFailed: 1,
|
|
6
|
+
/** Bad command line: unknown mode, step or trigger. */
|
|
7
|
+
usage: 64,
|
|
8
|
+
/** input.json missing, not JSON, or not valid for the step's inputs (or the trigger event). */
|
|
9
|
+
inputInvalid: 65,
|
|
10
|
+
/** The handler's return value (or the mapped trigger input) failed strict validation. */
|
|
11
|
+
outputInvalid: 66,
|
|
12
|
+
/** Uploading result.tar.gz to OLVE_RESULT_URL failed (after retries). The envelope was still printed. */
|
|
13
|
+
uploadFailed: 74,
|
|
14
|
+
/** The pipeline is invalid: describe problems, missing/invalid handler module. */
|
|
15
|
+
configInvalid: 78,
|
|
16
|
+
/** Cancelled by SIGINT / SIGTERM (128 + signal). */
|
|
17
|
+
sigint: 130,
|
|
18
|
+
sigterm: 143,
|
|
19
|
+
} as const;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @olve/pipelines/harness — the runtime the build tool compiles into the step binary.
|
|
2
|
+
export { runHarness, defaultIO, allSteps, resolveStepFunction, type HarnessConfig, type HandlerRegistry, type HarnessIO } from "./main.ts";
|
|
3
|
+
export { EXIT } from "./exit.ts";
|
|
4
|
+
export { ENV, paths, runInfoFromEnv } from "./env.ts";
|
|
5
|
+
export { executeStep, type Envelope, type Execution, type FailureKind } from "./execute.ts";
|
|
6
|
+
export { RESULT_BEGIN, RESULT_END, MANIFEST_BEGIN, MANIFEST_END } from "./result.ts";
|
|
7
|
+
export { describePipeline, DescribeError, manifestText } from "../describe/describe.ts";
|
|
8
|
+
export type { ManifestT as Manifest } from "../describe/manifest-schema.ts";
|
|
9
|
+
export type { HarnessInfo } from "../util/handler-hash.ts";
|