@cloud-cli/on 0.1.7 → 1.2.2
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 +241 -0
- package/dist/config.js +15 -0
- package/dist/db-client.js +38 -0
- package/dist/drivers/index.js +11 -0
- package/dist/drivers/standard-process.driver.js +156 -0
- package/dist/drivers/systemd.driver.js +151 -0
- package/dist/evaluator/safe-eval.js +213 -0
- package/dist/index.js +145 -0
- package/dist/ingress/preprocessors/github.js +30 -0
- package/dist/ingress/server.js +241 -0
- package/dist/ingress/types.js +1 -0
- package/dist/logging/redactor.js +20 -0
- package/dist/parser/include-resolver.js +47 -0
- package/dist/parser/matrix-expander.js +43 -0
- package/dist/parser/yaml-loader.js +45 -0
- package/dist/plugins/github-status.plugin.js +19 -0
- package/dist/plugins/manager.js +21 -0
- package/dist/plugins/types.js +1 -0
- package/dist/queue/dispatcher.js +112 -0
- package/dist/reporters/html.reporter.js +108 -0
- package/dist/reporters/json-file.reporter.js +16 -0
- package/dist/reporters/slack.reporter.js +25 -0
- package/dist/reporters/types.js +1 -0
- package/dist/runner/step-runner.js +42 -0
- package/dist/secrets/store.js +40 -0
- package/dist/types.js +1 -0
- package/dist/worker.js +249 -0
- package/package.json +31 -16
- package/dist/on.js +0 -5164
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import * as acorn from 'acorn';
|
|
2
|
+
export const BUILTIN_HELPERS = {
|
|
3
|
+
String: (val) => String(val ?? ''),
|
|
4
|
+
Number: (val) => Number(val),
|
|
5
|
+
Boolean: (val) => Boolean(val),
|
|
6
|
+
JSON: {
|
|
7
|
+
parse: (str) => JSON.parse(str),
|
|
8
|
+
stringify: (obj) => JSON.stringify(obj, null, 2),
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
export class SafeExpressionEvaluator {
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic Value Evaluator (Used for `env:`, `name:`, `image:`, `concurrency:`)
|
|
14
|
+
* - Native non-string types (booleans, numbers, objects) pass through untouched.
|
|
15
|
+
* - Strings WITHOUT `${` are returned as 100% literal strings (zero JS AST overhead).
|
|
16
|
+
* - Strings WITH `${` are evaluated strictly as ES Template Literals.
|
|
17
|
+
*/
|
|
18
|
+
static async evaluateValue(val, context = {}) {
|
|
19
|
+
if (typeof val !== 'string') {
|
|
20
|
+
return val;
|
|
21
|
+
}
|
|
22
|
+
// 1. Literal Passthrough: String does not contain `${`
|
|
23
|
+
if (!val.includes('${')) {
|
|
24
|
+
return val;
|
|
25
|
+
}
|
|
26
|
+
// 2. Dynamic Template Interpolation: Evaluates as ES Template Literal
|
|
27
|
+
const templateExpr = `\`${val}\``;
|
|
28
|
+
return this.evaluateExpression(templateExpr, context);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Deterministic Condition Evaluator (Used for `if:`)
|
|
32
|
+
* Strictly parses code as a JavaScript expression and coerces result to boolean.
|
|
33
|
+
* Throws an explicit AST Parse Error on invalid syntax (fails fast and loud).
|
|
34
|
+
*/
|
|
35
|
+
static async evaluateCondition(code, context = {}) {
|
|
36
|
+
const result = await this.evaluateExpression(code, context);
|
|
37
|
+
return Boolean(result);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Evaluates direct JS code (Used for `eval:` steps or internal expression resolution).
|
|
41
|
+
*/
|
|
42
|
+
static async evaluateExpression(code, context = {}) {
|
|
43
|
+
if (!code || typeof code !== 'string') {
|
|
44
|
+
return code;
|
|
45
|
+
}
|
|
46
|
+
const trimmed = code.trim();
|
|
47
|
+
let ast;
|
|
48
|
+
try {
|
|
49
|
+
ast = acorn.parseExpressionAt(trimmed, 0, {
|
|
50
|
+
ecmaVersion: 2020,
|
|
51
|
+
allowAwaitOutsideFunction: false,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (parseErr) {
|
|
55
|
+
throw new Error(`Invalid expression syntax in '${trimmed}': ${parseErr.message}`);
|
|
56
|
+
}
|
|
57
|
+
return this.evalNodeAsync(ast, context);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Alias wrapper for backward compatibility with step runners
|
|
61
|
+
*/
|
|
62
|
+
static async evaluateAsync(code, context = {}) {
|
|
63
|
+
return this.evaluateExpression(code, context);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Asynchronous AST Node Walker with Security Guards
|
|
67
|
+
*/
|
|
68
|
+
static async evalNodeAsync(node, ctx) {
|
|
69
|
+
switch (node.type) {
|
|
70
|
+
// Primitive Literals: 123, "hello", true, null
|
|
71
|
+
case 'Literal':
|
|
72
|
+
return node.value;
|
|
73
|
+
// Variables / Identifiers: inputs, steps, String
|
|
74
|
+
case 'Identifier':
|
|
75
|
+
if (node.name in ctx)
|
|
76
|
+
return ctx[node.name];
|
|
77
|
+
if (node.name in BUILTIN_HELPERS)
|
|
78
|
+
return BUILTIN_HELPERS[node.name];
|
|
79
|
+
return undefined;
|
|
80
|
+
// Property Access: inputs.branch or secrets["TOKEN"]
|
|
81
|
+
case 'MemberExpression': {
|
|
82
|
+
const object = await this.evalNodeAsync(node.object, ctx);
|
|
83
|
+
if (object == null)
|
|
84
|
+
return undefined;
|
|
85
|
+
const property = node.computed ? await this.evalNodeAsync(node.property, ctx) : node.property.name;
|
|
86
|
+
// SECURITY GUARD: Block Prototype Pollution Escapes
|
|
87
|
+
if (['constructor', '__proto__', 'prototype'].includes(property)) {
|
|
88
|
+
throw new Error(`Security Guard Violation: Access to '${property}' is blocked.`);
|
|
89
|
+
}
|
|
90
|
+
return object[property];
|
|
91
|
+
}
|
|
92
|
+
// Function & Method Calls: slack_notify(...) or url.replace(...)
|
|
93
|
+
case 'CallExpression': {
|
|
94
|
+
let fn;
|
|
95
|
+
let targetObj = null;
|
|
96
|
+
if (node.callee.type === 'MemberExpression') {
|
|
97
|
+
targetObj = await this.evalNodeAsync(node.callee.object, ctx);
|
|
98
|
+
const prop = node.callee.computed
|
|
99
|
+
? await this.evalNodeAsync(node.callee.property, ctx)
|
|
100
|
+
: node.callee.property.name;
|
|
101
|
+
if (['constructor', '__proto__', 'prototype'].includes(prop)) {
|
|
102
|
+
throw new Error(`Security Guard Violation: Invoking method '${prop}' is blocked.`);
|
|
103
|
+
}
|
|
104
|
+
if (targetObj != null && typeof targetObj[prop] === 'function') {
|
|
105
|
+
fn = targetObj[prop];
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
throw new Error(`Property '${prop}' is not a callable function on target object.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (node.callee.type === 'Identifier') {
|
|
112
|
+
const fnName = node.callee.name;
|
|
113
|
+
fn = ctx[fnName] ?? BUILTIN_HELPERS[fnName];
|
|
114
|
+
if (!fn || typeof fn !== 'function') {
|
|
115
|
+
throw new Error(`Unknown function helper '${fnName}'.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!fn) {
|
|
119
|
+
throw new Error('Invalid function invocation target.');
|
|
120
|
+
}
|
|
121
|
+
const args = await Promise.all(node.arguments.map((arg) => this.evalNodeAsync(arg, ctx)));
|
|
122
|
+
return await fn.apply(targetObj, args);
|
|
123
|
+
}
|
|
124
|
+
// Binary Operators: a === b, x + y, p > q
|
|
125
|
+
case 'BinaryExpression': {
|
|
126
|
+
const left = await this.evalNodeAsync(node.left, ctx);
|
|
127
|
+
const right = await this.evalNodeAsync(node.right, ctx);
|
|
128
|
+
switch (node.operator) {
|
|
129
|
+
case '===':
|
|
130
|
+
case '==':
|
|
131
|
+
return left == right;
|
|
132
|
+
case '!==':
|
|
133
|
+
case '!=':
|
|
134
|
+
return left != right;
|
|
135
|
+
case '>':
|
|
136
|
+
return left > right;
|
|
137
|
+
case '<':
|
|
138
|
+
return left < right;
|
|
139
|
+
case '>=':
|
|
140
|
+
return left >= right;
|
|
141
|
+
case '<=':
|
|
142
|
+
return left <= right;
|
|
143
|
+
case '+':
|
|
144
|
+
return left + right;
|
|
145
|
+
case '-':
|
|
146
|
+
return left - right;
|
|
147
|
+
case '*':
|
|
148
|
+
return left * right;
|
|
149
|
+
case '/':
|
|
150
|
+
return left / right;
|
|
151
|
+
default:
|
|
152
|
+
throw new Error(`Unsupported binary operator: ${node.operator}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Logical Operators: a && b, x || y
|
|
156
|
+
case 'LogicalExpression': {
|
|
157
|
+
const left = await this.evalNodeAsync(node.left, ctx);
|
|
158
|
+
if (node.operator === '&&') {
|
|
159
|
+
return left ? await this.evalNodeAsync(node.right, ctx) : left;
|
|
160
|
+
}
|
|
161
|
+
if (node.operator === '||') {
|
|
162
|
+
return left ? left : await this.evalNodeAsync(node.right, ctx);
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`Unsupported logical operator: ${node.operator}`);
|
|
165
|
+
}
|
|
166
|
+
// Unary Operators: !x, -y
|
|
167
|
+
case 'UnaryExpression': {
|
|
168
|
+
const argument = await this.evalNodeAsync(node.argument, ctx);
|
|
169
|
+
if (node.operator === '!')
|
|
170
|
+
return !argument;
|
|
171
|
+
if (node.operator === '-')
|
|
172
|
+
return -argument;
|
|
173
|
+
if (node.operator === '+')
|
|
174
|
+
return +argument;
|
|
175
|
+
throw new Error(`Unsupported unary operator: ${node.operator}`);
|
|
176
|
+
}
|
|
177
|
+
// Template Strings: `node:${matrix.version}-alpine`
|
|
178
|
+
case 'TemplateLiteral': {
|
|
179
|
+
const quasis = node.quasis.map((q) => q.value.cooked);
|
|
180
|
+
const expressions = await Promise.all(node.expressions.map((e) => this.evalNodeAsync(e, ctx)));
|
|
181
|
+
let result = '';
|
|
182
|
+
for (let i = 0; i < quasis.length; i++) {
|
|
183
|
+
result += quasis[i];
|
|
184
|
+
if (i < expressions.length) {
|
|
185
|
+
result += expressions[i] ?? '';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
// Array Literals: [1, 2, "three"]
|
|
191
|
+
case 'ArrayExpression': {
|
|
192
|
+
return Promise.all(node.elements.map((elem) => this.evalNodeAsync(elem, ctx)));
|
|
193
|
+
}
|
|
194
|
+
// Object Literals: { a: 1, b: "hello" }
|
|
195
|
+
case 'ObjectExpression': {
|
|
196
|
+
const obj = {};
|
|
197
|
+
for (const prop of node.properties) {
|
|
198
|
+
if (prop.type === 'Property') {
|
|
199
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name : await this.evalNodeAsync(prop.key, ctx);
|
|
200
|
+
obj[key] = await this.evalNodeAsync(prop.value, ctx);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return obj;
|
|
204
|
+
}
|
|
205
|
+
// Optional Chaining: inputs?.repo
|
|
206
|
+
case 'ChainExpression': {
|
|
207
|
+
return this.evalNodeAsync(node.expression, ctx);
|
|
208
|
+
}
|
|
209
|
+
default:
|
|
210
|
+
throw new Error(`Security Guard Violation: AST Node type '${node.type}' is disallowed.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs, { statSync } from 'node:fs';
|
|
5
|
+
import { QueueManager } from './queue/dispatcher.js';
|
|
6
|
+
import { SecretStore } from './secrets/store.js';
|
|
7
|
+
import { WebhookServer } from './ingress/server.js';
|
|
8
|
+
import { WorkflowIncludeResolver } from './parser/include-resolver.js';
|
|
9
|
+
import { expandMatrix } from './parser/matrix-expander.js';
|
|
10
|
+
import { startWorkers } from './worker.js';
|
|
11
|
+
import { YamlLoader } from './parser/yaml-loader.js';
|
|
12
|
+
import { resolveConfig } from './config.js';
|
|
13
|
+
const { values, positionals } = parseArgs({
|
|
14
|
+
allowPositionals: true,
|
|
15
|
+
options: {
|
|
16
|
+
config: { type: 'string', short: 'c', default: process.env.RUNNER_CONFIG_PATH || './runner.config.mjs' },
|
|
17
|
+
database: { type: 'string', short: 'd', default: process.env.RUNNER_DATABASE_URL },
|
|
18
|
+
workflows: { type: 'string', short: 'w', default: process.env.RUNNER_WORKFLOWS_PATH || '.on/' },
|
|
19
|
+
port: { type: 'string', short: 'p', default: String(process.env.PORT || '11235') },
|
|
20
|
+
workers: { type: 'string', short: 'k', default: process.env.RUNNER_WORKERS || '5' },
|
|
21
|
+
help: { type: 'boolean', short: 'h' },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
function printHelp() {
|
|
25
|
+
console.log(`
|
|
26
|
+
🏃 Runner CLI 🏃
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
npx -y @cloud-cli/on <command> [options]
|
|
30
|
+
pnpm dlx -y @cloud-cli/on <command> [options]
|
|
31
|
+
|
|
32
|
+
Commands:
|
|
33
|
+
start Runs both Webhook Ingress Server and Workers (Default)
|
|
34
|
+
start-server Runs Webhook Ingress Server only (API Gateway mode)
|
|
35
|
+
start-workers Runs Worker Polling loops only (Scalable Worker mode)
|
|
36
|
+
validate Parses and validates workflow YAML files without running
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
-c, --config Path to runner.config.mjs (default: ./runner.config.mjs, env: RUNNER_CONFIG_PATH)
|
|
40
|
+
-d, --database SQLite Database URL (env: RUNNER_DATABASE_URL)
|
|
41
|
+
-w, --workflows Path to where your workflows are defined (default: .on/, env: RUNNER_WORKFLOWS_PATH)
|
|
42
|
+
-p, --port Port for Webhook Ingress Server (default: 11235, env: PORT)
|
|
43
|
+
-k, --workers Number of worker thread loops to spawn (default: 5, env: RUNNER_WORKERS)
|
|
44
|
+
-h, --help Show this help message
|
|
45
|
+
`);
|
|
46
|
+
}
|
|
47
|
+
if (values.help) {
|
|
48
|
+
printHelp();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
async function loadConfig() {
|
|
52
|
+
const cliOverrides = {
|
|
53
|
+
port: Number(values.port),
|
|
54
|
+
sqliteUrl: values.database,
|
|
55
|
+
workflowsDir: values.workflows,
|
|
56
|
+
workersCount: values.workers ? Number(values.workers) : undefined,
|
|
57
|
+
};
|
|
58
|
+
let userFileConfig = {};
|
|
59
|
+
const configPath = path.resolve(values.config);
|
|
60
|
+
if (fs.existsSync(configPath) && statSync(configPath).isFile()) {
|
|
61
|
+
userFileConfig = (await import(configPath)).default || {};
|
|
62
|
+
}
|
|
63
|
+
// Merge CLI flags over file config over defaults
|
|
64
|
+
const finalConfig = resolveConfig({
|
|
65
|
+
...userFileConfig,
|
|
66
|
+
...cliOverrides,
|
|
67
|
+
});
|
|
68
|
+
if (!fs.existsSync(finalConfig.workflowsDir)) {
|
|
69
|
+
console.warn(`⚠️ Warning: Workflows directory '${finalConfig.workflowsDir}' not found.`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return finalConfig;
|
|
73
|
+
}
|
|
74
|
+
function onValidate(config) {
|
|
75
|
+
console.log('🔍 Validating Workflows in:', config.workflowsDir);
|
|
76
|
+
const resolver = new WorkflowIncludeResolver(config.workflowsDir);
|
|
77
|
+
const files = fs.readdirSync(config.workflowsDir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const resolved = resolver.resolve(file);
|
|
80
|
+
const expanded = expandMatrix(resolved);
|
|
81
|
+
console.log(` ✅ ${file} -> Valid! (${expanded.length} job matrix variant(s) generated)`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function onServe(config, workflows) {
|
|
85
|
+
const { queue, secrets } = await init();
|
|
86
|
+
WebhookServer.withPort({
|
|
87
|
+
queue,
|
|
88
|
+
secrets,
|
|
89
|
+
adminToken: config.adminToken,
|
|
90
|
+
workflows,
|
|
91
|
+
port: config.port,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async function onWorkers(config) {
|
|
95
|
+
const { queue, secrets } = await init();
|
|
96
|
+
startWorkers(config.workersCount, queue, secrets, config);
|
|
97
|
+
}
|
|
98
|
+
async function init() {
|
|
99
|
+
const secrets = new SecretStore('./.env');
|
|
100
|
+
const queue = new QueueManager('cli-node');
|
|
101
|
+
await queue.init();
|
|
102
|
+
return { secrets, queue };
|
|
103
|
+
}
|
|
104
|
+
async function main() {
|
|
105
|
+
const command = positionals[0] || 'start';
|
|
106
|
+
const config = await loadConfig();
|
|
107
|
+
if (!config) {
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
switch (command) {
|
|
111
|
+
case 'validate': {
|
|
112
|
+
onValidate(config);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'start-server': {
|
|
116
|
+
console.log('🌐 Starting Ingress Gateway...');
|
|
117
|
+
onServe(config, []);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'start-workers': {
|
|
121
|
+
console.log(`⚙️ Starting ${config.workersCount} Worker Loop(s)...`);
|
|
122
|
+
onWorkers(config);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'start': {
|
|
126
|
+
console.log('🚀 Starting Full Runner Engine (Ingress + Workers)...');
|
|
127
|
+
const workflows = YamlLoader.from(config.workflowsDir);
|
|
128
|
+
onServe(config, workflows);
|
|
129
|
+
onWorkers(config);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
console.error(`❌ Unknown command: '${command}'`);
|
|
134
|
+
printHelp();
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const cleanupAndExit = async (signal) => {
|
|
139
|
+
console.log(`\n🛑 Received ${signal}. Gracefully shutting down workers...`);
|
|
140
|
+
// Cancel active jobs / close DB connections here
|
|
141
|
+
process.exit(0);
|
|
142
|
+
};
|
|
143
|
+
process.on('SIGINT', () => cleanupAndExit('SIGINT'));
|
|
144
|
+
process.on('SIGTERM', () => cleanupAndExit('SIGTERM'));
|
|
145
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
export class GitHubPreprocessor {
|
|
3
|
+
name = 'github';
|
|
4
|
+
parse(headers, body, rawBodyBuffer, secret) {
|
|
5
|
+
// 1. HMAC Signature Verification
|
|
6
|
+
let isValid = true;
|
|
7
|
+
const signature = headers['x-hub-signature-256'];
|
|
8
|
+
if (secret && signature) {
|
|
9
|
+
const hmac = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBodyBuffer).digest('hex');
|
|
10
|
+
isValid = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(hmac));
|
|
11
|
+
}
|
|
12
|
+
// 2. Normalize GitHub Event & Headers
|
|
13
|
+
const event = headers['x-github-event'] || 'unknown';
|
|
14
|
+
const ref = body.ref || '';
|
|
15
|
+
const branch = ref.replace('refs/heads/', '').replace('refs/tags/', '');
|
|
16
|
+
return {
|
|
17
|
+
isValid,
|
|
18
|
+
event,
|
|
19
|
+
inputs: {
|
|
20
|
+
event,
|
|
21
|
+
branch,
|
|
22
|
+
clone_url: body.repository?.clone_url,
|
|
23
|
+
commit_sha: body.after || body.head_commit?.id,
|
|
24
|
+
author: body.pusher?.name || body.sender?.login,
|
|
25
|
+
action: body.action, // e.g., 'opened', 'synchronize' for PRs
|
|
26
|
+
},
|
|
27
|
+
rawBody: body,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { SafeExpressionEvaluator } from '../evaluator/safe-eval.js';
|
|
4
|
+
import { GitHubPreprocessor } from './preprocessors/github.js';
|
|
5
|
+
import { HtmlReporter } from '../reporters/html.reporter.js';
|
|
6
|
+
export class WebhookServer {
|
|
7
|
+
server;
|
|
8
|
+
preprocessors = new Map();
|
|
9
|
+
workflows = [];
|
|
10
|
+
queue;
|
|
11
|
+
secrets;
|
|
12
|
+
adminToken;
|
|
13
|
+
static withPort(options) {
|
|
14
|
+
const { port, ...o } = options;
|
|
15
|
+
return new WebhookServer(o).listen(port);
|
|
16
|
+
}
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.queue = options.queue;
|
|
19
|
+
this.secrets = options.secrets;
|
|
20
|
+
this.adminToken = options.adminToken;
|
|
21
|
+
this.workflows = options.workflows;
|
|
22
|
+
// Register built-in preprocessors
|
|
23
|
+
this.registerPreprocessor(new GitHubPreprocessor());
|
|
24
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
25
|
+
}
|
|
26
|
+
registerPreprocessor(preprocessor) {
|
|
27
|
+
this.preprocessors.set(preprocessor.name, preprocessor);
|
|
28
|
+
}
|
|
29
|
+
async handleRequest(req, res) {
|
|
30
|
+
const url = new URL(req.url || '/', `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`);
|
|
31
|
+
if (req.method === 'GET' && (url.pathname === '/runs' || url.pathname === '/')) {
|
|
32
|
+
return this.renderDashboard(res);
|
|
33
|
+
}
|
|
34
|
+
if (req.method === 'GET' && url.pathname.startsWith('/runs/')) {
|
|
35
|
+
const jobId = url.pathname.replace('/runs/', '');
|
|
36
|
+
return this.renderRunDetails(jobId, res);
|
|
37
|
+
}
|
|
38
|
+
if (req.method === 'POST' && url.pathname === '/admin/reload-secrets') {
|
|
39
|
+
return this.handleSecretReload(req, res);
|
|
40
|
+
}
|
|
41
|
+
if (req.method === 'POST' && url.pathname.startsWith('/webhooks/')) {
|
|
42
|
+
const provider = url.pathname.replace('/webhooks/', '');
|
|
43
|
+
return this.handleWebhook(provider, req, res);
|
|
44
|
+
}
|
|
45
|
+
// Fallback 404
|
|
46
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
47
|
+
res.end(JSON.stringify({ error: 'Endpoint not found' }));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Processes incoming HTTP webhooks
|
|
51
|
+
*/
|
|
52
|
+
async handleWebhook(provider, req, res) {
|
|
53
|
+
try {
|
|
54
|
+
// 5MB
|
|
55
|
+
const MAX_PAYLOAD_SIZE = 5 * 1024 * 1024;
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let receivedBytes = 0;
|
|
58
|
+
for await (const chunk of req) {
|
|
59
|
+
receivedBytes += chunk.length;
|
|
60
|
+
if (receivedBytes > MAX_PAYLOAD_SIZE) {
|
|
61
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
62
|
+
return res.end(JSON.stringify({ error: 'Payload size exceeds limit' }));
|
|
63
|
+
}
|
|
64
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
65
|
+
}
|
|
66
|
+
const rawBuffer = Buffer.concat(chunks);
|
|
67
|
+
let body = {};
|
|
68
|
+
try {
|
|
69
|
+
body = JSON.parse(rawBuffer.toString('utf-8'));
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v[0] : v || '']));
|
|
73
|
+
const preprocessor = this.preprocessors.get(provider);
|
|
74
|
+
const secret = this.secrets.get(`${provider.toUpperCase()}_WEBHOOK_SECRET`);
|
|
75
|
+
let isValid = true;
|
|
76
|
+
let inputs = { ...body };
|
|
77
|
+
if (preprocessor) {
|
|
78
|
+
const result = preprocessor.parse(headers, body, rawBuffer, secret);
|
|
79
|
+
isValid = result.isValid;
|
|
80
|
+
inputs = result.inputs;
|
|
81
|
+
}
|
|
82
|
+
// Reject unauthorized requests immediately
|
|
83
|
+
if (!isValid) {
|
|
84
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
85
|
+
return res.end(JSON.stringify({ error: 'Invalid HMAC signature or authentication failed' }));
|
|
86
|
+
}
|
|
87
|
+
// 3. Match Incoming Webhook to Registered Workflows
|
|
88
|
+
const triggeredJobs = [];
|
|
89
|
+
for (const workflow of this.workflows) {
|
|
90
|
+
// Match provider (e.g. 'github')
|
|
91
|
+
if (workflow.on.provider !== provider)
|
|
92
|
+
continue;
|
|
93
|
+
// Evaluate workflow trigger condition if defined (e.g. `if: inputs.event == 'push'`)
|
|
94
|
+
if (workflow.on.if) {
|
|
95
|
+
try {
|
|
96
|
+
const shouldRun = SafeExpressionEvaluator.evaluateCondition(workflow.on.if, { inputs });
|
|
97
|
+
if (!shouldRun)
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
catch (evalErr) {
|
|
101
|
+
console.error(`⚠️ Condition evaluation error in workflow [${workflow.id}]:`, evalErr.message);
|
|
102
|
+
continue; // Skip this workflow without crashing server
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// 4. Resolve Concurrency Key (if specified)
|
|
106
|
+
let concurrencyKey;
|
|
107
|
+
if (workflow.concurrency?.group) {
|
|
108
|
+
concurrencyKey = await SafeExpressionEvaluator.evaluateValue(workflow.concurrency.group, { inputs });
|
|
109
|
+
}
|
|
110
|
+
// 5. Enqueue Job to SQLite
|
|
111
|
+
const jobPayload = {
|
|
112
|
+
workflowId: workflow.id,
|
|
113
|
+
steps: workflow.steps,
|
|
114
|
+
inputs,
|
|
115
|
+
};
|
|
116
|
+
await this.queue.enqueue(workflow.id, jobPayload, concurrencyKey);
|
|
117
|
+
triggeredJobs.push(workflow.id);
|
|
118
|
+
}
|
|
119
|
+
// 6. Respond Fast (202 Accepted)
|
|
120
|
+
res.writeHead(202, { 'Content-Type': 'application/json' });
|
|
121
|
+
res.end(JSON.stringify({
|
|
122
|
+
message: 'Webhook processed',
|
|
123
|
+
triggeredWorkflows: triggeredJobs,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
console.error('❌ Webhook Ingress Error:', err);
|
|
128
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
129
|
+
res.end(JSON.stringify({ error: 'Internal Ingress Error', details: err.message }));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Handles Zero-Downtime Secret Reload
|
|
134
|
+
*/
|
|
135
|
+
async handleSecretReload(req, res) {
|
|
136
|
+
const authHeader = req.headers['authorization'];
|
|
137
|
+
if (authHeader !== `Bearer ${this.adminToken}`) {
|
|
138
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
139
|
+
return res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
140
|
+
}
|
|
141
|
+
// Trigger in-memory secret reload
|
|
142
|
+
this.secrets.reload();
|
|
143
|
+
console.log('🔄 SecretStore reloaded successfully without downtime!');
|
|
144
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
145
|
+
res.end(JSON.stringify({ message: 'Secrets reloaded successfully' }));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Serves the Server Health & Jobs Dashboard
|
|
149
|
+
*/
|
|
150
|
+
async renderDashboard(res) {
|
|
151
|
+
const jobs = await this.queue.listJobs(50);
|
|
152
|
+
const rows = jobs
|
|
153
|
+
.map((j) => {
|
|
154
|
+
const badge = j.status === 'success'
|
|
155
|
+
? 'bg-emerald-500/10 text-emerald-400'
|
|
156
|
+
: j.status === 'failed'
|
|
157
|
+
? 'bg-rose-500/10 text-rose-400'
|
|
158
|
+
: j.status === 'running'
|
|
159
|
+
? 'bg-indigo-500/10 text-indigo-400 animate-pulse'
|
|
160
|
+
: 'bg-gray-500/10 text-gray-400';
|
|
161
|
+
return `
|
|
162
|
+
<tr class="border-b border-gray-800 hover:bg-gray-900/50 transition">
|
|
163
|
+
<td class="py-3 px-4 font-mono text-indigo-400"><a href="/runs/${j.id}" class="hover:underline">#${j.id}</a></td>
|
|
164
|
+
<td class="py-3 px-4 font-medium text-white">${j.workflow_id}</td>
|
|
165
|
+
<td class="py-3 px-4">
|
|
166
|
+
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold ${badge}">${j.status.toUpperCase()}</span>
|
|
167
|
+
</td>
|
|
168
|
+
<td class="py-3 px-4 text-xs font-mono text-gray-400">${j.worker_id || '-'}</td>
|
|
169
|
+
<td class="py-3 px-4 text-xs text-gray-400">${j.created_at}</td>
|
|
170
|
+
<td class="py-3 px-4 text-right">
|
|
171
|
+
<a href="/runs/${j.id}" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-200 px-3 py-1 rounded border border-gray-700">View Trace →</a>
|
|
172
|
+
</td>
|
|
173
|
+
</tr>
|
|
174
|
+
`;
|
|
175
|
+
})
|
|
176
|
+
.join('');
|
|
177
|
+
const html = `<!DOCTYPE html>
|
|
178
|
+
<html lang="en" class="dark">
|
|
179
|
+
<head>
|
|
180
|
+
<meta charset="UTF-8">
|
|
181
|
+
<meta http-equiv="refresh" content="10"> <!-- Auto-refreshes every 10s -->
|
|
182
|
+
<title>Workflow Engine Dashboard</title>
|
|
183
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
184
|
+
</head>
|
|
185
|
+
<body class="bg-gray-950 text-gray-100 min-h-screen p-6 font-sans">
|
|
186
|
+
<div class="max-w-6xl mx-auto space-y-6">
|
|
187
|
+
<div class="flex items-center justify-between border-b border-gray-800 pb-4">
|
|
188
|
+
<div>
|
|
189
|
+
<h1 class="text-2xl font-bold text-white">⚙️ Runner Engine Status</h1>
|
|
190
|
+
<p class="text-xs text-gray-400">Live SQLite Job Queue & Execution Traces</p>
|
|
191
|
+
</div>
|
|
192
|
+
<span class="text-xs font-mono bg-emerald-500/10 text-emerald-400 px-3 py-1 rounded-full border border-emerald-500/20">
|
|
193
|
+
● System Operational
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
|
198
|
+
<table class="w-full text-left text-sm">
|
|
199
|
+
<thead class="bg-gray-800/50 text-gray-400 text-xs uppercase font-mono border-b border-gray-800">
|
|
200
|
+
<tr>
|
|
201
|
+
<th class="py-3 px-4">Job ID</th>
|
|
202
|
+
<th class="py-3 px-4">Workflow</th>
|
|
203
|
+
<th class="py-3 px-4">Status</th>
|
|
204
|
+
<th class="py-3 px-4">Worker</th>
|
|
205
|
+
<th class="py-3 px-4">Created At</th>
|
|
206
|
+
<th class="py-3 px-4 text-right">Action</th>
|
|
207
|
+
</tr>
|
|
208
|
+
</thead>
|
|
209
|
+
<tbody>${rows.length ? rows : '<tr><td colspan="6" class="p-6 text-center text-gray-500">No jobs recorded yet.</td></tr>'}</tbody>
|
|
210
|
+
</table>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</body>
|
|
214
|
+
</html>`;
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
216
|
+
res.end(html);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Serves single job HTML report
|
|
220
|
+
*/
|
|
221
|
+
async renderRunDetails(jobId, res) {
|
|
222
|
+
const job = await this.queue.getJob(jobId);
|
|
223
|
+
if (!job || !job.report) {
|
|
224
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
225
|
+
return res.end('<h1>404 - Report Not Found</h1><p>Job is still running or does not exist.</p><a href="/runs">← Back to Dashboard</a>');
|
|
226
|
+
}
|
|
227
|
+
const reportData = JSON.parse(job.report);
|
|
228
|
+
const htmlReporter = new HtmlReporter({ outputDir: '' });
|
|
229
|
+
const htmlContent = htmlReporter.generateHtml(reportData);
|
|
230
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
231
|
+
res.end(htmlContent);
|
|
232
|
+
}
|
|
233
|
+
listen(port) {
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
this.server.listen(port, () => {
|
|
236
|
+
console.log(`🌐 Webhook Ingress Server running on port ${port}`);
|
|
237
|
+
resolve();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Transform } from 'node:stream';
|
|
2
|
+
export class SecretRedactorStream extends Transform {
|
|
3
|
+
secretValues;
|
|
4
|
+
constructor(secretValues) {
|
|
5
|
+
super();
|
|
6
|
+
// Sort longest secrets first to prevent partial replacements
|
|
7
|
+
this.secretValues = secretValues.sort((a, b) => b.length - a.length);
|
|
8
|
+
}
|
|
9
|
+
_transform(chunk, encoding, callback) {
|
|
10
|
+
let logString = chunk.toString('utf-8');
|
|
11
|
+
// Replace all known secret values with ***
|
|
12
|
+
for (const secret of this.secretValues) {
|
|
13
|
+
if (secret) {
|
|
14
|
+
logString = logString.replaceAll(secret, '***');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
this.push(Buffer.from(logString));
|
|
18
|
+
callback();
|
|
19
|
+
}
|
|
20
|
+
}
|