@cloud-cli/on 0.1.9 → 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 +188 -127
- 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 -5195
package/README.md
CHANGED
|
@@ -1,180 +1,241 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 🏃 `@cloud-cli/on`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
General-purpose workflows
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
Every workflow is a set of steps, which can run on containers or in a shell on the host.
|
|
5
|
+
A self-hosted, lightweight, high-performance CI/CD runner engine built for Node.js.
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
Designed with a strict **security-first boundary**, native **JavaScript AST evaluation**, zero-DSL template literals, and a **built-in terminal log web dashboard**.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
- All steps run in the same workspace folder.
|
|
12
|
-
- The current folder is mounted as a volume at /workspace by default. This can be changed by specifying a volume with `.` as the host path.
|
|
9
|
+
---
|
|
13
10
|
|
|
14
|
-
##
|
|
11
|
+
## 🌟 Key Highlights
|
|
15
12
|
|
|
16
|
-
-
|
|
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.
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
---
|
|
19
21
|
|
|
20
|
-
|
|
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
|
|
21
32
|
|
|
22
|
-
```sh
|
|
23
|
-
curl -X POST http://localhost:11235/ -d '{ "event-name": {...} }'
|
|
24
33
|
```
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
description: Run tests and build
|
|
35
|
+
---
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
image: node:latest
|
|
37
|
+
## 🚀 Quick Start
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
event-name:
|
|
34
|
-
runner: docker
|
|
35
|
-
if:
|
|
36
|
-
- ${inputs.action} == 'published'
|
|
37
|
-
secrets:
|
|
38
|
-
- /path/to/secrets
|
|
39
|
-
- /path/to/.env
|
|
40
|
-
mappings:
|
|
41
|
-
<field>: <path.to.value.in.inputs>
|
|
42
|
-
env:
|
|
43
|
-
A_SECRET: "${secrets.A_SECRET}"
|
|
44
|
-
A_VALUE: "${inputs.some.value}"
|
|
45
|
-
defaults:
|
|
46
|
-
<<: *vars
|
|
47
|
-
volumes:
|
|
48
|
-
.: /home
|
|
49
|
-
/dev/shm: /dev/shm
|
|
50
|
-
args:
|
|
51
|
-
net: host
|
|
52
|
-
dns: 1.2.3.4
|
|
53
|
-
steps:
|
|
54
|
-
- pnpm i
|
|
55
|
-
- pnpm run build
|
|
56
|
-
- pnpm run test
|
|
57
|
-
triggers:
|
|
58
|
-
- path/to/output.json
|
|
59
|
-
```
|
|
39
|
+
### 1. Install & Run
|
|
60
40
|
|
|
61
|
-
|
|
41
|
+
Run the engine directly via `npx` or `pnpm dlx`:
|
|
62
42
|
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
|
|
43
|
+
```bash
|
|
44
|
+
# Start full engine (Ingress HTTP Gateway + 5 Worker Loops)
|
|
45
|
+
npx @cloud-cli/on start
|
|
66
46
|
|
|
67
|
-
```yaml
|
|
68
|
-
description: Auto-release library
|
|
69
|
-
on:
|
|
70
|
-
event-name:
|
|
71
|
-
runner: shell
|
|
72
|
-
secrets:
|
|
73
|
-
- /path/to/secrets
|
|
74
|
-
- /path/to/.env
|
|
75
|
-
mappings:
|
|
76
|
-
<field>: <path.to.value.in.json.payload>
|
|
77
|
-
env:
|
|
78
|
-
A_SECRET: "${secrets.A_SECRET}"
|
|
79
|
-
A_VALUE: "${inputs.some.value}"
|
|
80
|
-
steps:
|
|
81
|
-
- pnpm i
|
|
82
|
-
- pnpm run build
|
|
83
|
-
- pnpm run release
|
|
84
|
-
triggers:
|
|
85
|
-
- path/to/output.json
|
|
86
47
|
```
|
|
87
48
|
|
|
88
|
-
###
|
|
49
|
+
### 2. Configure Secrets (`.env`)
|
|
89
50
|
|
|
90
|
-
|
|
51
|
+
Secrets are automatically loaded from `.env` at the root of your project. Prefix secrets with `SECRET_`:
|
|
91
52
|
|
|
92
|
-
|
|
53
|
+
```env
|
|
54
|
+
SECRET_NPM_TOKEN="npm_1234567890abcdef"
|
|
55
|
+
SECRET_GITHUB_TOKEN="ghp_1234567890abcdef"
|
|
56
|
+
SECRET_GITHUB_WEBHOOK_SECRET="my-webhook-secret"
|
|
93
57
|
|
|
94
|
-
```sh
|
|
95
|
-
curl -X POST http://localhost:11235/ -d '{ "published": { "value" : 123 } }'
|
|
96
58
|
```
|
|
97
59
|
|
|
98
|
-
|
|
60
|
+
### 3. Define a Workflow (`.on/release.yml`)
|
|
99
61
|
|
|
100
62
|
```yaml
|
|
63
|
+
name: Build and Publish Release
|
|
64
|
+
|
|
101
65
|
on:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
105
96
|
```
|
|
106
97
|
|
|
107
|
-
|
|
108
|
-
The expression `${inputs.value}` contains `123`.
|
|
98
|
+
---
|
|
109
99
|
|
|
110
|
-
|
|
111
|
-
The incoming webhook URL can have a path, and that is considered as the event source.
|
|
100
|
+
## 💻 CLI Usage & Commands
|
|
112
101
|
|
|
113
|
-
|
|
102
|
+
```bash
|
|
103
|
+
npx @cloud-cli/on [command] [options]
|
|
114
104
|
|
|
115
|
-
```sh
|
|
116
|
-
curl -X POST http://localhost:11235/source -d '{ "event": { "value" : 123 } }'
|
|
117
105
|
```
|
|
118
106
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
};
|
|
127
159
|
```
|
|
128
160
|
|
|
129
|
-
|
|
161
|
+
---
|
|
130
162
|
|
|
131
|
-
|
|
163
|
+
## 📐 Deterministic Evaluation Rules
|
|
132
164
|
|
|
133
|
-
|
|
165
|
+
To prevent syntax ambiguity and injection risks, fields in workflow definitions operate under **three strict modes**:
|
|
134
166
|
|
|
135
|
-
|
|
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
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
136
188
|
|
|
137
|
-
|
|
189
|
+
```
|
|
138
190
|
|
|
139
|
-
###
|
|
191
|
+
### Context Scope Available in Expressions
|
|
140
192
|
|
|
141
|
-
|
|
193
|
+
Within `${...}`, `if:`, and `eval:` contexts, the following object scopes are exposed:
|
|
142
194
|
|
|
143
|
-
|
|
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`.
|
|
144
200
|
|
|
145
|
-
|
|
201
|
+
---
|
|
146
202
|
|
|
147
|
-
|
|
148
|
-
mappings:
|
|
149
|
-
image: ${inputs.package.package_version.package_url}
|
|
150
|
-
```
|
|
203
|
+
## 🌐 Webhook Ingress Gateway & Dashboard
|
|
151
204
|
|
|
152
|
-
|
|
205
|
+
The Ingress Gateway listens for incoming HTTP requests and serves the live web UI.
|
|
153
206
|
|
|
154
|
-
|
|
207
|
+
### Endpoint Matrix
|
|
155
208
|
|
|
156
|
-
|
|
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. |
|
|
157
214
|
|
|
158
|
-
|
|
215
|
+
### Dashboard Features
|
|
159
216
|
|
|
160
|
-
|
|
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.
|
|
161
220
|
|
|
162
|
-
|
|
163
|
-
- Load secrets
|
|
164
|
-
- Map inputs
|
|
165
|
-
- Populate env with secrets
|
|
166
|
-
- Populate env with additional workflow definitions (section `env`)
|
|
167
|
-
- Create a temporary working directory
|
|
168
|
-
- Add a volume to defaults at `/workspace`, or a custom path, if a volume with a host path `.` is defined in the workflow
|
|
169
|
-
- Run steps:
|
|
170
|
-
- For every step, either a string, or a step definition is accepted.
|
|
171
|
-
- If string, run with the defaults defined in the workflow
|
|
172
|
-
- If a definition, merge defaults into it, and run the step
|
|
173
|
-
- The step is a shell command, executed inside a short-lived container
|
|
174
|
-
- Trigger new events
|
|
221
|
+
---
|
|
175
222
|
|
|
176
|
-
##
|
|
223
|
+
## 🔒 Security & Hardening
|
|
177
224
|
|
|
178
|
-
|
|
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.
|
|
179
233
|
|
|
180
|
-
|
|
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
|
+
}
|