@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
package/README.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# 🏃 `@cloud-cli/on`
|
|
2
|
+
|
|
3
|
+
General-purpose workflows
|
|
4
|
+
|
|
5
|
+
A self-hosted, lightweight, high-performance CI/CD runner engine built for Node.js.
|
|
6
|
+
|
|
7
|
+
Designed with a strict **security-first boundary**, native **JavaScript AST evaluation**, zero-DSL template literals, and a **built-in terminal log web dashboard**.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🌟 Key Highlights
|
|
12
|
+
|
|
13
|
+
- **Strict Code/Data Separation:** `run:` steps are executed verbatim as raw process scripts. Expressions and dynamic data bindings are isolated strictly to `env:`, eliminating shell-injection vectors entirely.
|
|
14
|
+
- **Standard ES Template Syntax (`${...}`):** No custom DSL wrappers like `${{ }}` or `{{ }}`. If a field contains `${...}`, it evaluates standard JavaScript template string logic via AST.
|
|
15
|
+
- **Deterministic Field Evaluation:** No silent fallbacks or ambiguous type conversions. Plain strings remain literal strings; conditions in `if:` fields run as strict JS boolean expressions.
|
|
16
|
+
- **Built-in Dark Mode Web UI (`/runs`):** Monitor job statuses live, inspect workspace inputs, and view ANSI-colored terminal log streams rendered in real-time.
|
|
17
|
+
- **System & Container Execution Drivers:** Run steps directly as detached host process groups or inside isolated Docker/Systemd transient units.
|
|
18
|
+
- **Automatic Secret Redaction:** Secrets loaded from `.env` are automatically masked (`***`) across all terminal log outputs and report snapshots.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 📦 Project Structure
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
my-project/
|
|
26
|
+
├── .on/ # Workflow definitions directory
|
|
27
|
+
│ ├── release.yml
|
|
28
|
+
│ └── test.yml
|
|
29
|
+
├── .env # Local secrets (git-ignored)
|
|
30
|
+
├── runner.config.mjs # (Optional) Engine configuration
|
|
31
|
+
└── package.json
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 🚀 Quick Start
|
|
38
|
+
|
|
39
|
+
### 1. Install & Run
|
|
40
|
+
|
|
41
|
+
Run the engine directly via `npx` or `pnpm dlx`:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Start full engine (Ingress HTTP Gateway + 5 Worker Loops)
|
|
45
|
+
npx @cloud-cli/on start
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Configure Secrets (`.env`)
|
|
50
|
+
|
|
51
|
+
Secrets are automatically loaded from `.env` at the root of your project. Prefix secrets with `SECRET_`:
|
|
52
|
+
|
|
53
|
+
```env
|
|
54
|
+
SECRET_NPM_TOKEN="npm_1234567890abcdef"
|
|
55
|
+
SECRET_GITHUB_TOKEN="ghp_1234567890abcdef"
|
|
56
|
+
SECRET_GITHUB_WEBHOOK_SECRET="my-webhook-secret"
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3. Define a Workflow (`.on/release.yml`)
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
name: Build and Publish Release
|
|
64
|
+
|
|
65
|
+
on:
|
|
66
|
+
github:
|
|
67
|
+
if: inputs.event === 'push' && inputs.branch === 'main'
|
|
68
|
+
|
|
69
|
+
concurrency:
|
|
70
|
+
group: release-${inputs.repo}
|
|
71
|
+
cancel-in-progress: true
|
|
72
|
+
|
|
73
|
+
steps:
|
|
74
|
+
- id: checkout
|
|
75
|
+
name: Checkout Code Repository
|
|
76
|
+
env:
|
|
77
|
+
CLONE_URL: ${inputs.clone_url}
|
|
78
|
+
COMMIT_SHA: ${inputs.commit_sha}
|
|
79
|
+
run: |
|
|
80
|
+
git clone --depth 1 "$CLONE_URL" .
|
|
81
|
+
git checkout "$COMMIT_SHA"
|
|
82
|
+
|
|
83
|
+
- id: install-and-build
|
|
84
|
+
name: Install Dependencies & Build
|
|
85
|
+
run: |
|
|
86
|
+
pnpm install
|
|
87
|
+
pnpm run build
|
|
88
|
+
|
|
89
|
+
- id: publish
|
|
90
|
+
name: Publish to NPM
|
|
91
|
+
env:
|
|
92
|
+
NPM_TOKEN: ${secrets.NPM_TOKEN}
|
|
93
|
+
run: |
|
|
94
|
+
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
|
95
|
+
npx --yes semantic-release@24 -b main --no-ci
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 💻 CLI Usage & Commands
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npx @cloud-cli/on [command] [options]
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Commands
|
|
108
|
+
|
|
109
|
+
| Command | Description |
|
|
110
|
+
| ----------------------- | ------------------------------------------------------------------------- |
|
|
111
|
+
| **`start`** _(default)_ | Runs both Webhook Ingress Gateway and Worker execution loops together. |
|
|
112
|
+
| **`start-server`** | Runs Webhook Ingress Gateway only (API / Gateway mode). |
|
|
113
|
+
| **`start-workers`** | Runs Worker Polling loops only (Scalable Worker mode). |
|
|
114
|
+
| **`validate`** | Parses and validates all YAML workflows in `.on/` without executing jobs. |
|
|
115
|
+
|
|
116
|
+
### CLI Options
|
|
117
|
+
|
|
118
|
+
| Flag | Option | Default | Description |
|
|
119
|
+
| ---- | ------------- | -------------------------- | ----------------------------------------- |
|
|
120
|
+
| `-c` | `--config` | `./runner.config.mjs` | Path to JavaScript configuration file. |
|
|
121
|
+
| `-d` | `--database` | `process.env.DATABASE_URL` | SQLite database file path or HTTP URL. |
|
|
122
|
+
| `-w` | `--workflows` | `.on/` | Directory where workflow YAML files live. |
|
|
123
|
+
| `-p` | `--port` | `3000` | Port for the Ingress HTTP server. |
|
|
124
|
+
| `-k` | `--workers` | `5` | Number of worker loop threads to spawn. |
|
|
125
|
+
| `-h` | `--help` | — | Prints CLI help message and exits. |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## ⚙️ Configuration Reference
|
|
130
|
+
|
|
131
|
+
You can customize engine behavior using `runner.config.mjs` in your project root:
|
|
132
|
+
|
|
133
|
+
```javascript
|
|
134
|
+
// runner.config.mjs
|
|
135
|
+
import { HtmlReporter, SlackReporter, JsonFileReporter } from '@cloud-cli/on/reporters';
|
|
136
|
+
|
|
137
|
+
export default {
|
|
138
|
+
port: 3000,
|
|
139
|
+
workersCount: 5,
|
|
140
|
+
workflowsDir: '.on/',
|
|
141
|
+
storagePath: '/tmp/workspaces',
|
|
142
|
+
sqliteUrl: 'sqlite.db',
|
|
143
|
+
|
|
144
|
+
// Global environment variables passed to all steps
|
|
145
|
+
env: {
|
|
146
|
+
NODE_ENV: 'production',
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
// Custom execution reporters
|
|
150
|
+
reporters: [
|
|
151
|
+
new JsonFileReporter({ outputDir: './reports/json' }),
|
|
152
|
+
new HtmlReporter({ outputDir: './reports/html' }),
|
|
153
|
+
new SlackReporter({
|
|
154
|
+
webhookUrl: process.env.SLACK_WEBHOOK_URL,
|
|
155
|
+
channel: '#ci-deployments',
|
|
156
|
+
}),
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 📐 Deterministic Evaluation Rules
|
|
164
|
+
|
|
165
|
+
To prevent syntax ambiguity and injection risks, fields in workflow definitions operate under **three strict modes**:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
┌──────────────────────────────────────────────────────────────────────────┐
|
|
169
|
+
│ 1. RAW PASSTHROUGH MODE (`run:`) │
|
|
170
|
+
│ • Executed verbatim as a shell process command. │
|
|
171
|
+
│ • No string replacements or engine parsing performed. │
|
|
172
|
+
│ • Access environment variables strictly via shell syntax: $MY_VAR. │
|
|
173
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
174
|
+
|
|
175
|
+
┌──────────────────────────────────────────────────────────────────────────┐
|
|
176
|
+
│ 2. EXPRESSION MODE (`if:`, `eval:`) │
|
|
177
|
+
│ • Evaluated strictly as pure JavaScript expressions via Acorn AST. │
|
|
178
|
+
│ • Must be valid JS syntax (e.g. `inputs.branch === 'main'`). │
|
|
179
|
+
│ • Automatically coerced to boolean in `if:` conditions. │
|
|
180
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
181
|
+
|
|
182
|
+
┌──────────────────────────────────────────────────────────────────────────┐
|
|
183
|
+
│ 3. DETERMINISTIC VALUE MODE (`env:`, `name:`, `image:`, `group:`) │
|
|
184
|
+
│ • Plain strings WITHOUT `${}` remain 100% raw literal strings. │
|
|
185
|
+
│ • Strings WITH `${...}` evaluate as standard ES Template Literals. │
|
|
186
|
+
│ • Example: `node:${inputs.node_version}-alpine` │
|
|
187
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Context Scope Available in Expressions
|
|
192
|
+
|
|
193
|
+
Within `${...}`, `if:`, and `eval:` contexts, the following object scopes are exposed:
|
|
194
|
+
|
|
195
|
+
- **`inputs`**: Payload key-values received from incoming webhooks.
|
|
196
|
+
- **`env`**: Merged environment variables from global config and workflow definitions.
|
|
197
|
+
- **`secrets`**: Unmasked secret values loaded from `.env` (`SECRET_` prefix stripped).
|
|
198
|
+
- **`steps`**: Execution statuses and outputs from previous steps in the workflow (`steps.<id>.status`, `steps.<id>.outputs`).
|
|
199
|
+
- **`BUILTIN_HELPERS`**: JS utilities including `String`, `Number`, `Boolean`, and `JSON.parse` / `JSON.stringify`.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 🌐 Webhook Ingress Gateway & Dashboard
|
|
204
|
+
|
|
205
|
+
The Ingress Gateway listens for incoming HTTP requests and serves the live web UI.
|
|
206
|
+
|
|
207
|
+
### Endpoint Matrix
|
|
208
|
+
|
|
209
|
+
| Method | Endpoint | Description |
|
|
210
|
+
| ---------- | ------------------ | -------------------------------------------------------------------------------------- |
|
|
211
|
+
| **`POST`** | `/webhooks/github` | Webhook endpoint for GitHub events. Evaluates `on.github.if` triggers. |
|
|
212
|
+
| **`GET`** | `/runs` | **Dashboard:** Live dark-mode monitoring page listing recent jobs and worker health. |
|
|
213
|
+
| **`GET`** | `/runs/:jobId` | **Job Report:** Interactive HTML trace view with step timings and terminal log output. |
|
|
214
|
+
|
|
215
|
+
### Dashboard Features
|
|
216
|
+
|
|
217
|
+
- **Real-time Auto-Refresh:** `/runs` automatically refreshes job queue statuses (`PENDING`, `RUNNING`, `SUCCESS`, `FAILED`, `CANCELLED`).
|
|
218
|
+
- **ANSI Terminal Rendering:** Uses `ansi_up` to render bash colors, bold highlights, and console outputs accurately in step log boxes.
|
|
219
|
+
- **Payload Inspection:** View JSON inputs received from webhooks for easy debugging.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## 🔒 Security & Hardening
|
|
224
|
+
|
|
225
|
+
1. **Environment Variable Boundary:**
|
|
226
|
+
By forcing shell steps to consume data via process environment variables (`$CLONE_URL`), malicious webhook payloads containing shell delimiters (e.g. `; rm -rf /`) cannot mutate shell script execution trees.
|
|
227
|
+
2. **Prototype Pollution Protection:**
|
|
228
|
+
AST evaluation explicitly blocks access to dangerous JS properties (`constructor`, `__proto__`, `prototype`).
|
|
229
|
+
3. **Payload Size Guard:**
|
|
230
|
+
The Ingress server enforces a strict 5MB payload limit to prevent Out-Of-Memory (OOM) denial-of-service attacks.
|
|
231
|
+
4. **Signal Traps & Resource Cleanup:**
|
|
232
|
+
Graceful process traps (`SIGINT`, `SIGTERM`) ensure active job handles are safely terminated, file descriptors are closed, and temp `.env`/`.out` files are removed via `try ... finally` blocks.
|
|
233
|
+
|
|
234
|
+
## Development
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
pnpm i
|
|
238
|
+
pnpm run lint
|
|
239
|
+
pnpm run test
|
|
240
|
+
pnpm run build
|
|
241
|
+
```
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merges user config overrides with baseline defaults
|
|
3
|
+
*/
|
|
4
|
+
export function resolveConfig(userConfig = {}) {
|
|
5
|
+
return {
|
|
6
|
+
port: userConfig.port ?? 3000,
|
|
7
|
+
adminToken: userConfig.adminToken ?? process.env.RUNNER_ADMIN_SECRET ?? '',
|
|
8
|
+
sqliteUrl: userConfig.sqliteUrl ?? process.env.DATABASE_URL ?? 'sqlite.db',
|
|
9
|
+
workflowsDir: userConfig.workflowsDir ?? '.on/',
|
|
10
|
+
workersCount: userConfig.workersCount ?? 5,
|
|
11
|
+
storagePath: userConfig.storagePath ?? process.env.RUNNER_TMP ?? '/tmp/workspaces',
|
|
12
|
+
env: userConfig.env ?? {},
|
|
13
|
+
reporters: userConfig.reporters ?? [],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const baseURL = process.env.DATABASE_URL;
|
|
2
|
+
let pragmas = [];
|
|
3
|
+
async function query(method, statement, data, pragma = pragmas) {
|
|
4
|
+
let req;
|
|
5
|
+
let error;
|
|
6
|
+
let retries = 1;
|
|
7
|
+
let max = 3;
|
|
8
|
+
while (retries < max) {
|
|
9
|
+
try {
|
|
10
|
+
req = await fetch(new URL('/query', baseURL), {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
body: JSON.stringify({
|
|
13
|
+
s: statement,
|
|
14
|
+
d: data,
|
|
15
|
+
m: method,
|
|
16
|
+
p: pragma,
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
if (req.ok) {
|
|
20
|
+
return await req.json();
|
|
21
|
+
}
|
|
22
|
+
await new Promise((r) => setTimeout(r, retries++ * 1000));
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
error = e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
throw new Error(error || (await req.text()));
|
|
29
|
+
}
|
|
30
|
+
export const get = query.bind(null, 'get');
|
|
31
|
+
export const run = query.bind(null, 'run');
|
|
32
|
+
export const all = query.bind(null, 'all');
|
|
33
|
+
export function pragma(p) {
|
|
34
|
+
if (Array.isArray(p) && p.every((s) => typeof s === 'string')) {
|
|
35
|
+
pragmas = p;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export default { query, get, run, all, pragma };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SystemdDriver } from './systemd.driver.js';
|
|
2
|
+
import { StandardProcessDriver } from './standard-process.driver.js';
|
|
3
|
+
export async function resolveDriver() {
|
|
4
|
+
const systemd = new SystemdDriver();
|
|
5
|
+
if (await systemd.isSupported()) {
|
|
6
|
+
console.log('⚡ Selected Execution Driver: Systemd (cgroups enabled)');
|
|
7
|
+
return systemd;
|
|
8
|
+
}
|
|
9
|
+
console.log('📦 Selected Execution Driver: Standard Process (Fallback)');
|
|
10
|
+
return new StandardProcessDriver();
|
|
11
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export class StandardProcessDriver {
|
|
5
|
+
name = 'standard-process';
|
|
6
|
+
async isSupported() {
|
|
7
|
+
return true; // Supported on all OS platforms
|
|
8
|
+
}
|
|
9
|
+
async execute(ctx) {
|
|
10
|
+
const startTime = Date.now();
|
|
11
|
+
let logFd = null;
|
|
12
|
+
let logFilePath = '';
|
|
13
|
+
// 1. Guard Log Directory & File Handle Creation
|
|
14
|
+
try {
|
|
15
|
+
const logDir = path.join(ctx.workspacePath, '.logs');
|
|
16
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
17
|
+
logFilePath = path.join(logDir, `step-${ctx.stepId}.log`);
|
|
18
|
+
logFd = fs.openSync(logFilePath, 'a');
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
return {
|
|
22
|
+
done: Promise.resolve({
|
|
23
|
+
exitCode: 1,
|
|
24
|
+
durationMs: 0,
|
|
25
|
+
error: new Error(`Failed to initialize step log file: ${err.message}`),
|
|
26
|
+
}),
|
|
27
|
+
cancel: async () => { },
|
|
28
|
+
logFilePath: '',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// 2. Format Execution Command
|
|
32
|
+
let cmd;
|
|
33
|
+
let args;
|
|
34
|
+
if (ctx.image) {
|
|
35
|
+
cmd = 'docker';
|
|
36
|
+
args = [
|
|
37
|
+
'run',
|
|
38
|
+
'--rm',
|
|
39
|
+
'--init',
|
|
40
|
+
'-v',
|
|
41
|
+
`${ctx.workspacePath}:/workspace`,
|
|
42
|
+
'-w',
|
|
43
|
+
'/workspace',
|
|
44
|
+
'--entrypoint',
|
|
45
|
+
'sh',
|
|
46
|
+
ctx.image,
|
|
47
|
+
'-c',
|
|
48
|
+
ctx.command,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
cmd = 'sh';
|
|
53
|
+
args = ['-c', ctx.command];
|
|
54
|
+
}
|
|
55
|
+
// 3. Spawn Detached Child Process
|
|
56
|
+
let child;
|
|
57
|
+
try {
|
|
58
|
+
child = spawn(cmd, args, {
|
|
59
|
+
cwd: ctx.workspacePath,
|
|
60
|
+
env: { ...process.env, ...ctx.env },
|
|
61
|
+
detached: true, // Creates separate Process Group ID
|
|
62
|
+
stdio: ['ignore', logFd, logFd],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (spawnErr) {
|
|
66
|
+
try {
|
|
67
|
+
if (logFd !== null)
|
|
68
|
+
fs.closeSync(logFd);
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
return {
|
|
72
|
+
done: Promise.resolve({
|
|
73
|
+
exitCode: 1,
|
|
74
|
+
durationMs: Date.now() - startTime,
|
|
75
|
+
error: new Error(`Failed to spawn process: ${spawnErr.message}`),
|
|
76
|
+
}),
|
|
77
|
+
cancel: async () => { },
|
|
78
|
+
logFilePath,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
let isCancelled = false;
|
|
82
|
+
let timeoutTimer = null;
|
|
83
|
+
// 4. Safe Promise Resolution & Lifecycle Tracking
|
|
84
|
+
const done = new Promise((resolve) => {
|
|
85
|
+
let isResolved = false;
|
|
86
|
+
const safeResolve = (result) => {
|
|
87
|
+
if (isResolved)
|
|
88
|
+
return; // Prevent double-resolution
|
|
89
|
+
isResolved = true;
|
|
90
|
+
if (timeoutTimer)
|
|
91
|
+
clearTimeout(timeoutTimer);
|
|
92
|
+
// Always close log File Descriptor safely
|
|
93
|
+
try {
|
|
94
|
+
if (logFd !== null)
|
|
95
|
+
fs.closeSync(logFd);
|
|
96
|
+
}
|
|
97
|
+
catch { }
|
|
98
|
+
resolve(result);
|
|
99
|
+
};
|
|
100
|
+
// Optional step timeout
|
|
101
|
+
if (ctx.timeoutMs) {
|
|
102
|
+
timeoutTimer = setTimeout(() => {
|
|
103
|
+
isCancelled = true;
|
|
104
|
+
this.killProcessGroup(child);
|
|
105
|
+
}, ctx.timeoutMs);
|
|
106
|
+
// CRUCIAL: Unref timer so it doesn't hold event loop open
|
|
107
|
+
timeoutTimer.unref();
|
|
108
|
+
}
|
|
109
|
+
child.on('close', (code) => {
|
|
110
|
+
safeResolve({
|
|
111
|
+
exitCode: code ?? (isCancelled ? 130 : 1),
|
|
112
|
+
durationMs: Date.now() - startTime,
|
|
113
|
+
error: isCancelled ? new Error('Step timed out or was cancelled by user') : undefined,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
child.on('error', (err) => {
|
|
117
|
+
safeResolve({
|
|
118
|
+
exitCode: 1,
|
|
119
|
+
durationMs: Date.now() - startTime,
|
|
120
|
+
error: err,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
// 5. Cancellation Hook
|
|
125
|
+
const cancel = async () => {
|
|
126
|
+
isCancelled = true;
|
|
127
|
+
this.killProcessGroup(child);
|
|
128
|
+
};
|
|
129
|
+
return { done, cancel, logFilePath };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Kills the entire process group tree (-PID) with unref escalation
|
|
133
|
+
*/
|
|
134
|
+
killProcessGroup(child) {
|
|
135
|
+
if (child.pid && !child.killed) {
|
|
136
|
+
try {
|
|
137
|
+
// Send SIGTERM to entire process group (-PID)
|
|
138
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
139
|
+
// Escalate to SIGKILL after 5 seconds if process tree is still alive
|
|
140
|
+
const killTimer = setTimeout(() => {
|
|
141
|
+
try {
|
|
142
|
+
if (child.pid && !child.killed) {
|
|
143
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
}, 5000);
|
|
148
|
+
// CRUCIAL: Unref escalation timer so Node process can exit cleanly
|
|
149
|
+
killTimer.unref();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Process group may already be dead
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn, exec } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
export class SystemdDriver {
|
|
7
|
+
name = 'systemd';
|
|
8
|
+
/**
|
|
9
|
+
* Check if systemd bus is available on Linux host
|
|
10
|
+
*/
|
|
11
|
+
async isSupported() {
|
|
12
|
+
try {
|
|
13
|
+
return fs.existsSync('/run/systemd/system');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async execute(ctx) {
|
|
20
|
+
const startTime = Date.now();
|
|
21
|
+
let logFd = null;
|
|
22
|
+
let logFilePath = '';
|
|
23
|
+
// 1. Guard Log Directory & File Handle Creation
|
|
24
|
+
try {
|
|
25
|
+
const logDir = path.join(ctx.workspacePath, '.logs');
|
|
26
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
27
|
+
logFilePath = path.join(logDir, `step-${ctx.stepId}.log`);
|
|
28
|
+
logFd = fs.openSync(logFilePath, 'a');
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return {
|
|
32
|
+
done: Promise.resolve({
|
|
33
|
+
exitCode: 1,
|
|
34
|
+
durationMs: 0,
|
|
35
|
+
error: new Error(`Failed to initialize step log file: ${err.message}`),
|
|
36
|
+
}),
|
|
37
|
+
cancel: async () => { },
|
|
38
|
+
logFilePath: '',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// 2. Format Sanitized Systemd Unit Name
|
|
42
|
+
const unitName = `workflow-${ctx.jobId}-${ctx.stepId}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
43
|
+
// 3. Build systemd-run Flags
|
|
44
|
+
const systemdFlags = [
|
|
45
|
+
`--unit=${unitName}`,
|
|
46
|
+
'--wait', // Block until unit completes
|
|
47
|
+
'--pipe', // Stream stdio directly to file handle
|
|
48
|
+
`--working-directory=${ctx.workspacePath}`,
|
|
49
|
+
];
|
|
50
|
+
if (ctx.env) {
|
|
51
|
+
for (const [key, val] of Object.entries(ctx.env)) {
|
|
52
|
+
systemdFlags.push(`--setenv=${key}=${val}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (ctx.timeoutMs) {
|
|
56
|
+
const timeoutSec = Math.ceil(ctx.timeoutMs / 1000);
|
|
57
|
+
systemdFlags.push(`--property=RuntimeMaxSec=${timeoutSec}`);
|
|
58
|
+
}
|
|
59
|
+
// 4. Construct Command
|
|
60
|
+
let commandArgs;
|
|
61
|
+
if (ctx.image) {
|
|
62
|
+
commandArgs = [
|
|
63
|
+
'docker',
|
|
64
|
+
'run',
|
|
65
|
+
'--rm',
|
|
66
|
+
'--init',
|
|
67
|
+
`--name=${unitName}`, // Predictable container name for stopping
|
|
68
|
+
'-v',
|
|
69
|
+
`${ctx.workspacePath}:/workspace`,
|
|
70
|
+
'-w',
|
|
71
|
+
'/workspace',
|
|
72
|
+
ctx.image,
|
|
73
|
+
'sh',
|
|
74
|
+
'-c',
|
|
75
|
+
ctx.command,
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
commandArgs = ['sh', '-c', ctx.command];
|
|
80
|
+
}
|
|
81
|
+
// 5. Spawn systemd-run
|
|
82
|
+
let child;
|
|
83
|
+
try {
|
|
84
|
+
child = spawn('systemd-run', [...systemdFlags, '--', ...commandArgs], {
|
|
85
|
+
stdio: ['ignore', logFd, logFd],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (spawnErr) {
|
|
89
|
+
try {
|
|
90
|
+
if (logFd !== null)
|
|
91
|
+
fs.closeSync(logFd);
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
return {
|
|
95
|
+
done: Promise.resolve({
|
|
96
|
+
exitCode: 1,
|
|
97
|
+
durationMs: Date.now() - startTime,
|
|
98
|
+
error: new Error(`Failed to spawn systemd-run: ${spawnErr.message}`),
|
|
99
|
+
}),
|
|
100
|
+
cancel: async () => { },
|
|
101
|
+
logFilePath,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
let isCancelled = false;
|
|
105
|
+
// 6. Safe Promise Resolution & File Handle Cleanup
|
|
106
|
+
const done = new Promise((resolve) => {
|
|
107
|
+
let isResolved = false;
|
|
108
|
+
const safeResolve = (result) => {
|
|
109
|
+
if (isResolved)
|
|
110
|
+
return; // Prevent double-resolution
|
|
111
|
+
isResolved = true;
|
|
112
|
+
try {
|
|
113
|
+
if (logFd !== null)
|
|
114
|
+
fs.closeSync(logFd);
|
|
115
|
+
}
|
|
116
|
+
catch { }
|
|
117
|
+
resolve(result);
|
|
118
|
+
};
|
|
119
|
+
child.on('close', (code) => {
|
|
120
|
+
safeResolve({
|
|
121
|
+
exitCode: code ?? (isCancelled ? 130 : 1),
|
|
122
|
+
durationMs: Date.now() - startTime,
|
|
123
|
+
error: isCancelled ? new Error('Step cancelled by user or systemd timeout') : undefined,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
child.on('error', (err) => {
|
|
127
|
+
safeResolve({
|
|
128
|
+
exitCode: 1,
|
|
129
|
+
durationMs: Date.now() - startTime,
|
|
130
|
+
error: err,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
// 7. Systemd / Docker Graceful Cancellation
|
|
135
|
+
const cancel = async () => {
|
|
136
|
+
isCancelled = true;
|
|
137
|
+
try {
|
|
138
|
+
if (ctx.image) {
|
|
139
|
+
// Stop docker container gracefully if running
|
|
140
|
+
await execAsync(`docker stop -t 2 ${unitName}`).catch(() => { });
|
|
141
|
+
}
|
|
142
|
+
// Stop systemd transient unit (sends SIGTERM -> SIGKILL to Cgroup tree)
|
|
143
|
+
await execAsync(`systemctl stop ${unitName}.service`).catch(() => { });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Unit or container may already be stopped
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return { done, cancel, logFilePath };
|
|
150
|
+
}
|
|
151
|
+
}
|