@livingdata/pipex 0.0.9 → 0.0.10
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 +154 -14
- package/dist/__tests__/errors.js +162 -0
- package/dist/__tests__/helpers.js +41 -0
- package/dist/__tests__/types.js +8 -0
- package/dist/cli/__tests__/condition.js +23 -0
- package/dist/cli/__tests__/dag.js +154 -0
- package/dist/cli/__tests__/pipeline-loader.js +267 -0
- package/dist/cli/__tests__/pipeline-runner.js +257 -0
- package/dist/cli/__tests__/state-persistence.js +80 -0
- package/dist/cli/__tests__/state.js +58 -0
- package/dist/cli/__tests__/step-runner.js +116 -0
- package/dist/cli/commands/bundle.js +35 -0
- package/dist/cli/commands/cat.js +54 -0
- package/dist/cli/commands/exec.js +89 -0
- package/dist/cli/commands/export.js +2 -2
- package/dist/cli/commands/inspect.js +1 -1
- package/dist/cli/commands/list.js +2 -1
- package/dist/cli/commands/logs.js +1 -1
- package/dist/cli/commands/prune.js +1 -1
- package/dist/cli/commands/rm-step.js +41 -0
- package/dist/cli/commands/run-bundle.js +59 -0
- package/dist/cli/commands/run.js +9 -4
- package/dist/cli/commands/show.js +42 -7
- package/dist/cli/condition.js +11 -0
- package/dist/cli/dag.js +143 -0
- package/dist/cli/index.js +6 -0
- package/dist/cli/interactive-reporter.js +227 -0
- package/dist/cli/pipeline-loader.js +10 -110
- package/dist/cli/pipeline-runner.js +164 -78
- package/dist/cli/reporter.js +2 -158
- package/dist/cli/state.js +8 -0
- package/dist/cli/step-loader.js +25 -0
- package/dist/cli/step-resolver.js +111 -0
- package/dist/cli/step-runner.js +226 -0
- package/dist/cli/utils.js +0 -46
- package/dist/core/__tests__/bundle.js +663 -0
- package/dist/core/__tests__/condition.js +23 -0
- package/dist/core/__tests__/dag.js +154 -0
- package/dist/core/__tests__/env-file.test.js +41 -0
- package/dist/core/__tests__/event-aggregator.js +244 -0
- package/dist/core/__tests__/pipeline-loader.js +267 -0
- package/dist/core/__tests__/pipeline-runner.js +257 -0
- package/dist/core/__tests__/state-persistence.js +80 -0
- package/dist/core/__tests__/state.js +58 -0
- package/dist/core/__tests__/step-runner.js +118 -0
- package/dist/core/__tests__/stream-reporter.js +142 -0
- package/dist/core/__tests__/transport.js +50 -0
- package/dist/core/__tests__/utils.js +40 -0
- package/dist/core/bundle.js +130 -0
- package/dist/core/condition.js +11 -0
- package/dist/core/dag.js +143 -0
- package/dist/core/env-file.js +6 -0
- package/dist/core/event-aggregator.js +114 -0
- package/dist/core/index.js +14 -0
- package/dist/core/pipeline-loader.js +81 -0
- package/dist/core/pipeline-runner.js +360 -0
- package/dist/core/reporter.js +11 -0
- package/dist/core/state.js +110 -0
- package/dist/core/step-loader.js +25 -0
- package/dist/core/step-resolver.js +117 -0
- package/dist/core/step-runner.js +225 -0
- package/dist/core/stream-reporter.js +41 -0
- package/dist/core/transport.js +9 -0
- package/dist/core/utils.js +56 -0
- package/dist/engine/__tests__/workspace.js +288 -0
- package/dist/engine/docker-executor.js +10 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/workspace.js +76 -12
- package/dist/errors.js +122 -0
- package/dist/index.js +3 -0
- package/dist/kits/__tests__/index.js +23 -0
- package/dist/kits/builtin/__tests__/node.js +74 -0
- package/dist/kits/builtin/__tests__/python.js +67 -0
- package/dist/kits/builtin/__tests__/shell.js +74 -0
- package/dist/kits/builtin/node.js +10 -5
- package/dist/kits/builtin/python.js +10 -5
- package/dist/kits/builtin/shell.js +2 -1
- package/dist/kits/index.js +2 -1
- package/package.json +6 -3
- package/dist/cli/types.js +0 -3
- package/dist/engine/docker-runtime.js +0 -65
- package/dist/engine/runtime.js +0 -2
- package/dist/kits/bash.js +0 -19
- package/dist/kits/builtin/bash.js +0 -19
- package/dist/kits/node.js +0 -56
- package/dist/kits/python.js +0 -51
- package/dist/kits/types.js +0 -1
- package/dist/reporter.js +0 -13
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { access, readdir, readlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import test from 'ava';
|
|
4
|
+
import { Workspace } from '../workspace.js';
|
|
5
|
+
import { WorkspaceError } from '../../errors.js';
|
|
6
|
+
import { createTmpDir } from '../../__tests__/helpers.js';
|
|
7
|
+
// -- create & open -----------------------------------------------------------
|
|
8
|
+
test('create makes staging/, runs/, caches/ directories', async (t) => {
|
|
9
|
+
const root = await createTmpDir();
|
|
10
|
+
const ws = await Workspace.create(root, 'ws-1');
|
|
11
|
+
const entries = await readdir(ws.root);
|
|
12
|
+
t.true(entries.includes('staging'));
|
|
13
|
+
t.true(entries.includes('runs'));
|
|
14
|
+
t.true(entries.includes('caches'));
|
|
15
|
+
});
|
|
16
|
+
test('create with custom ID uses that ID', async (t) => {
|
|
17
|
+
const root = await createTmpDir();
|
|
18
|
+
const ws = await Workspace.create(root, 'my-workspace');
|
|
19
|
+
t.is(ws.id, 'my-workspace');
|
|
20
|
+
});
|
|
21
|
+
test('create without ID auto-generates one', async (t) => {
|
|
22
|
+
const root = await createTmpDir();
|
|
23
|
+
const ws = await Workspace.create(root);
|
|
24
|
+
t.truthy(ws.id);
|
|
25
|
+
t.true(ws.id.length > 0);
|
|
26
|
+
});
|
|
27
|
+
test('open returns workspace with correct id and root', async (t) => {
|
|
28
|
+
const root = await createTmpDir();
|
|
29
|
+
await Workspace.create(root, 'existing');
|
|
30
|
+
const ws = await Workspace.open(root, 'existing');
|
|
31
|
+
t.is(ws.id, 'existing');
|
|
32
|
+
t.is(ws.root, join(root, 'existing'));
|
|
33
|
+
});
|
|
34
|
+
test('open throws if workspace missing', async (t) => {
|
|
35
|
+
const root = await createTmpDir();
|
|
36
|
+
await t.throwsAsync(async () => Workspace.open(root, 'nonexistent'));
|
|
37
|
+
});
|
|
38
|
+
// -- list & remove -----------------------------------------------------------
|
|
39
|
+
test('list returns sorted workspace IDs', async (t) => {
|
|
40
|
+
const root = await createTmpDir();
|
|
41
|
+
await Workspace.create(root, 'beta');
|
|
42
|
+
await Workspace.create(root, 'alpha');
|
|
43
|
+
await Workspace.create(root, 'gamma');
|
|
44
|
+
const ids = await Workspace.list(root);
|
|
45
|
+
t.deepEqual(ids, ['alpha', 'beta', 'gamma']);
|
|
46
|
+
});
|
|
47
|
+
test('list returns empty array if root does not exist', async (t) => {
|
|
48
|
+
const ids = await Workspace.list('/tmp/nonexistent-pipex-root-' + Date.now());
|
|
49
|
+
t.deepEqual(ids, []);
|
|
50
|
+
});
|
|
51
|
+
test('remove deletes workspace', async (t) => {
|
|
52
|
+
const root = await createTmpDir();
|
|
53
|
+
await Workspace.create(root, 'to-delete');
|
|
54
|
+
await Workspace.remove(root, 'to-delete');
|
|
55
|
+
const ids = await Workspace.list(root);
|
|
56
|
+
t.false(ids.includes('to-delete'));
|
|
57
|
+
});
|
|
58
|
+
test('remove throws WorkspaceError on path traversal with ..', async (t) => {
|
|
59
|
+
const root = await createTmpDir();
|
|
60
|
+
const error = await t.throwsAsync(async () => Workspace.remove(root, '../etc'));
|
|
61
|
+
t.true(error instanceof WorkspaceError);
|
|
62
|
+
});
|
|
63
|
+
test('remove throws WorkspaceError on path traversal with /', async (t) => {
|
|
64
|
+
const error = await t.throwsAsync(async () => Workspace.remove('/tmp', 'a/b'));
|
|
65
|
+
t.true(error instanceof WorkspaceError);
|
|
66
|
+
});
|
|
67
|
+
// -- run lifecycle: commit path ----------------------------------------------
|
|
68
|
+
test('prepareRun creates staging/{runId}/artifacts/', async (t) => {
|
|
69
|
+
const root = await createTmpDir();
|
|
70
|
+
const ws = await Workspace.create(root, 'run-test');
|
|
71
|
+
const runId = ws.generateRunId();
|
|
72
|
+
await ws.prepareRun(runId);
|
|
73
|
+
const stagingPath = ws.runStagingPath(runId);
|
|
74
|
+
await t.notThrowsAsync(async () => access(stagingPath));
|
|
75
|
+
await t.notThrowsAsync(async () => access(join(stagingPath, 'artifacts')));
|
|
76
|
+
});
|
|
77
|
+
test('commitRun moves staging to runs', async (t) => {
|
|
78
|
+
const root = await createTmpDir();
|
|
79
|
+
const ws = await Workspace.create(root, 'commit-test');
|
|
80
|
+
const runId = ws.generateRunId();
|
|
81
|
+
await ws.prepareRun(runId);
|
|
82
|
+
// Write a file into staging artifacts
|
|
83
|
+
await writeFile(join(ws.runStagingArtifactsPath(runId), 'file.txt'), 'hello');
|
|
84
|
+
await ws.commitRun(runId);
|
|
85
|
+
// Committed run should exist
|
|
86
|
+
await t.notThrowsAsync(async () => access(ws.runPath(runId)));
|
|
87
|
+
await t.notThrowsAsync(async () => access(join(ws.runArtifactsPath(runId), 'file.txt')));
|
|
88
|
+
});
|
|
89
|
+
test('committed run appears in listRuns', async (t) => {
|
|
90
|
+
const root = await createTmpDir();
|
|
91
|
+
const ws = await Workspace.create(root, 'list-runs');
|
|
92
|
+
const runId = ws.generateRunId();
|
|
93
|
+
await ws.prepareRun(runId);
|
|
94
|
+
await ws.commitRun(runId);
|
|
95
|
+
const runs = await ws.listRuns();
|
|
96
|
+
t.true(runs.includes(runId));
|
|
97
|
+
});
|
|
98
|
+
test('staging is empty after commit', async (t) => {
|
|
99
|
+
const root = await createTmpDir();
|
|
100
|
+
const ws = await Workspace.create(root, 'staging-empty');
|
|
101
|
+
const runId = ws.generateRunId();
|
|
102
|
+
await ws.prepareRun(runId);
|
|
103
|
+
await ws.commitRun(runId);
|
|
104
|
+
const staging = await readdir(join(ws.root, 'staging'));
|
|
105
|
+
t.deepEqual(staging, []);
|
|
106
|
+
});
|
|
107
|
+
// -- run lifecycle: discard path ---------------------------------------------
|
|
108
|
+
test('discardRun removes staging directory', async (t) => {
|
|
109
|
+
const root = await createTmpDir();
|
|
110
|
+
const ws = await Workspace.create(root, 'discard-test');
|
|
111
|
+
const runId = ws.generateRunId();
|
|
112
|
+
await ws.prepareRun(runId);
|
|
113
|
+
await ws.discardRun(runId);
|
|
114
|
+
const staging = await readdir(join(ws.root, 'staging'));
|
|
115
|
+
t.deepEqual(staging, []);
|
|
116
|
+
});
|
|
117
|
+
test('discarded run does not appear in listRuns', async (t) => {
|
|
118
|
+
const root = await createTmpDir();
|
|
119
|
+
const ws = await Workspace.create(root, 'discard-list');
|
|
120
|
+
const runId = ws.generateRunId();
|
|
121
|
+
await ws.prepareRun(runId);
|
|
122
|
+
await ws.discardRun(runId);
|
|
123
|
+
const runs = await ws.listRuns();
|
|
124
|
+
t.false(runs.includes(runId));
|
|
125
|
+
});
|
|
126
|
+
// -- cleanupStaging ----------------------------------------------------------
|
|
127
|
+
test('cleanupStaging removes all staging dirs', async (t) => {
|
|
128
|
+
const root = await createTmpDir();
|
|
129
|
+
const ws = await Workspace.create(root, 'cleanup-test');
|
|
130
|
+
await ws.prepareRun(ws.generateRunId());
|
|
131
|
+
await ws.prepareRun(ws.generateRunId());
|
|
132
|
+
await ws.cleanupStaging();
|
|
133
|
+
const staging = await readdir(join(ws.root, 'staging'));
|
|
134
|
+
t.deepEqual(staging, []);
|
|
135
|
+
});
|
|
136
|
+
test('cleanupStaging is no-op when empty', async (t) => {
|
|
137
|
+
const root = await createTmpDir();
|
|
138
|
+
const ws = await Workspace.create(root, 'cleanup-noop');
|
|
139
|
+
await t.notThrowsAsync(async () => ws.cleanupStaging());
|
|
140
|
+
});
|
|
141
|
+
// -- linkRun -----------------------------------------------------------------
|
|
142
|
+
test('linkRun creates step-runs/{stepId} symlink', async (t) => {
|
|
143
|
+
const root = await createTmpDir();
|
|
144
|
+
const ws = await Workspace.create(root, 'link-test');
|
|
145
|
+
const runId = ws.generateRunId();
|
|
146
|
+
await ws.prepareRun(runId);
|
|
147
|
+
await ws.commitRun(runId);
|
|
148
|
+
await ws.linkRun('my-step', runId);
|
|
149
|
+
const linkDir = join(ws.root, 'step-runs');
|
|
150
|
+
const entries = await readdir(linkDir);
|
|
151
|
+
t.true(entries.includes('my-step'));
|
|
152
|
+
});
|
|
153
|
+
test('linkRun replaces existing symlink with new target', async (t) => {
|
|
154
|
+
const root = await createTmpDir();
|
|
155
|
+
const ws = await Workspace.create(root, 'link-replace');
|
|
156
|
+
const runId1 = ws.generateRunId();
|
|
157
|
+
await ws.prepareRun(runId1);
|
|
158
|
+
await ws.commitRun(runId1);
|
|
159
|
+
await ws.linkRun('step-a', runId1);
|
|
160
|
+
const runId2 = ws.generateRunId();
|
|
161
|
+
await ws.prepareRun(runId2);
|
|
162
|
+
await ws.commitRun(runId2);
|
|
163
|
+
await ws.linkRun('step-a', runId2);
|
|
164
|
+
// Symlink must point to the second run, not the first
|
|
165
|
+
const target = await readlink(join(ws.root, 'step-runs', 'step-a'));
|
|
166
|
+
t.true(target.includes(runId2));
|
|
167
|
+
t.false(target.includes(runId1));
|
|
168
|
+
});
|
|
169
|
+
// -- pruneRuns ---------------------------------------------------------------
|
|
170
|
+
test('pruneRuns removes runs not in active set', async (t) => {
|
|
171
|
+
const root = await createTmpDir();
|
|
172
|
+
const ws = await Workspace.create(root, 'prune-test');
|
|
173
|
+
const keep = ws.generateRunId();
|
|
174
|
+
await ws.prepareRun(keep);
|
|
175
|
+
await ws.commitRun(keep);
|
|
176
|
+
const remove = ws.generateRunId();
|
|
177
|
+
await ws.prepareRun(remove);
|
|
178
|
+
await ws.commitRun(remove);
|
|
179
|
+
const removed = await ws.pruneRuns(new Set([keep]));
|
|
180
|
+
t.is(removed, 1);
|
|
181
|
+
const runs = await ws.listRuns();
|
|
182
|
+
t.true(runs.includes(keep));
|
|
183
|
+
t.false(runs.includes(remove));
|
|
184
|
+
});
|
|
185
|
+
test('pruneRuns keeps runs in active set', async (t) => {
|
|
186
|
+
const root = await createTmpDir();
|
|
187
|
+
const ws = await Workspace.create(root, 'prune-keep');
|
|
188
|
+
const run1 = ws.generateRunId();
|
|
189
|
+
await ws.prepareRun(run1);
|
|
190
|
+
await ws.commitRun(run1);
|
|
191
|
+
const run2 = ws.generateRunId();
|
|
192
|
+
await ws.prepareRun(run2);
|
|
193
|
+
await ws.commitRun(run2);
|
|
194
|
+
const removed = await ws.pruneRuns(new Set([run1, run2]));
|
|
195
|
+
t.is(removed, 0);
|
|
196
|
+
});
|
|
197
|
+
// -- caches ------------------------------------------------------------------
|
|
198
|
+
test('prepareCache creates cache directory', async (t) => {
|
|
199
|
+
const root = await createTmpDir();
|
|
200
|
+
const ws = await Workspace.create(root, 'cache-test');
|
|
201
|
+
const path = await ws.prepareCache('npm-cache');
|
|
202
|
+
await t.notThrowsAsync(async () => access(path));
|
|
203
|
+
});
|
|
204
|
+
test('listCaches lists cache names', async (t) => {
|
|
205
|
+
const root = await createTmpDir();
|
|
206
|
+
const ws = await Workspace.create(root, 'cache-list');
|
|
207
|
+
await ws.prepareCache('cache-a');
|
|
208
|
+
await ws.prepareCache('cache-b');
|
|
209
|
+
const caches = await ws.listCaches();
|
|
210
|
+
t.true(caches.includes('cache-a'));
|
|
211
|
+
t.true(caches.includes('cache-b'));
|
|
212
|
+
});
|
|
213
|
+
test('invalid cache name throws WorkspaceError', async (t) => {
|
|
214
|
+
const root = await createTmpDir();
|
|
215
|
+
const ws = await Workspace.create(root, 'cache-invalid');
|
|
216
|
+
const error = t.throws(() => ws.cachePath('invalid/name'));
|
|
217
|
+
t.true(error instanceof WorkspaceError);
|
|
218
|
+
t.true(error.message.includes('invalid/name'));
|
|
219
|
+
});
|
|
220
|
+
// -- running step markers ----------------------------------------------------
|
|
221
|
+
test('markStepRunning creates running/{stepId} file', async (t) => {
|
|
222
|
+
const root = await createTmpDir();
|
|
223
|
+
const ws = await Workspace.create(root, 'running-mark');
|
|
224
|
+
await ws.markStepRunning('build', { startedAt: '2024-01-01T00:00:00Z', pid: 12345 });
|
|
225
|
+
const entries = await readdir(join(ws.root, 'running'));
|
|
226
|
+
t.deepEqual(entries, ['build']);
|
|
227
|
+
});
|
|
228
|
+
test('markStepDone removes the running marker', async (t) => {
|
|
229
|
+
const root = await createTmpDir();
|
|
230
|
+
const ws = await Workspace.create(root, 'running-done');
|
|
231
|
+
await ws.markStepRunning('build', { startedAt: '2024-01-01T00:00:00Z', pid: 12345 });
|
|
232
|
+
await ws.markStepDone('build');
|
|
233
|
+
const entries = await readdir(join(ws.root, 'running'));
|
|
234
|
+
t.deepEqual(entries, []);
|
|
235
|
+
});
|
|
236
|
+
test('markStepDone is no-op for non-existent marker', async (t) => {
|
|
237
|
+
const root = await createTmpDir();
|
|
238
|
+
const ws = await Workspace.create(root, 'running-done-noop');
|
|
239
|
+
await t.notThrowsAsync(async () => ws.markStepDone('nonexistent'));
|
|
240
|
+
});
|
|
241
|
+
test('listRunningSteps returns all running steps with metadata', async (t) => {
|
|
242
|
+
const root = await createTmpDir();
|
|
243
|
+
const ws = await Workspace.create(root, 'running-list');
|
|
244
|
+
await ws.markStepRunning('build', { startedAt: '2024-01-01T00:00:00Z', pid: 111, stepName: 'Build' });
|
|
245
|
+
await ws.markStepRunning('test', { startedAt: '2024-01-01T00:01:00Z', pid: 222 });
|
|
246
|
+
const running = await ws.listRunningSteps();
|
|
247
|
+
t.is(running.length, 2);
|
|
248
|
+
const build = running.find(r => r.stepId === 'build');
|
|
249
|
+
t.is(build.pid, 111);
|
|
250
|
+
t.is(build.stepName, 'Build');
|
|
251
|
+
t.is(build.startedAt, '2024-01-01T00:00:00Z');
|
|
252
|
+
const testStep = running.find(r => r.stepId === 'test');
|
|
253
|
+
t.is(testStep.pid, 222);
|
|
254
|
+
t.is(testStep.stepName, undefined);
|
|
255
|
+
});
|
|
256
|
+
test('listRunningSteps returns empty array when no running directory', async (t) => {
|
|
257
|
+
const root = await createTmpDir();
|
|
258
|
+
const ws = await Workspace.create(root, 'running-empty');
|
|
259
|
+
const running = await ws.listRunningSteps();
|
|
260
|
+
t.deepEqual(running, []);
|
|
261
|
+
});
|
|
262
|
+
test('cleanupRunning removes all running markers', async (t) => {
|
|
263
|
+
const root = await createTmpDir();
|
|
264
|
+
const ws = await Workspace.create(root, 'running-cleanup');
|
|
265
|
+
await ws.markStepRunning('a', { startedAt: '2024-01-01T00:00:00Z', pid: 1 });
|
|
266
|
+
await ws.markStepRunning('b', { startedAt: '2024-01-01T00:00:00Z', pid: 2 });
|
|
267
|
+
await ws.cleanupRunning();
|
|
268
|
+
const running = await ws.listRunningSteps();
|
|
269
|
+
t.deepEqual(running, []);
|
|
270
|
+
});
|
|
271
|
+
test('cleanupRunning is no-op when no running directory', async (t) => {
|
|
272
|
+
const root = await createTmpDir();
|
|
273
|
+
const ws = await Workspace.create(root, 'running-cleanup-noop');
|
|
274
|
+
await t.notThrowsAsync(async () => ws.cleanupRunning());
|
|
275
|
+
});
|
|
276
|
+
// -- validation --------------------------------------------------------------
|
|
277
|
+
test('invalid run ID in runStagingPath throws WorkspaceError', async (t) => {
|
|
278
|
+
const root = await createTmpDir();
|
|
279
|
+
const ws = await Workspace.create(root, 'validate-staging');
|
|
280
|
+
const error = t.throws(() => ws.runStagingPath('bad/id'));
|
|
281
|
+
t.true(error instanceof WorkspaceError);
|
|
282
|
+
});
|
|
283
|
+
test('invalid run ID in runPath throws WorkspaceError', async (t) => {
|
|
284
|
+
const root = await createTmpDir();
|
|
285
|
+
const ws = await Workspace.create(root, 'validate-run');
|
|
286
|
+
const error = t.throws(() => ws.runPath('bad id'));
|
|
287
|
+
t.true(error instanceof WorkspaceError);
|
|
288
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import { execa } from 'execa';
|
|
3
|
+
import { DockerNotAvailableError, ImagePullError, ContainerTimeoutError } from '../errors.js';
|
|
3
4
|
import { ContainerExecutor } from './executor.js';
|
|
4
5
|
/**
|
|
5
6
|
* Build a minimal environment for the Docker CLI process.
|
|
@@ -22,8 +23,8 @@ export class DockerCliExecutor extends ContainerExecutor {
|
|
|
22
23
|
try {
|
|
23
24
|
await execa('docker', ['--version'], { env: this.env });
|
|
24
25
|
}
|
|
25
|
-
catch {
|
|
26
|
-
throw new
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new DockerNotAvailableError({ cause: error });
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
@@ -108,6 +109,13 @@ export class DockerCliExecutor extends ContainerExecutor {
|
|
|
108
109
|
exitCode = result.exitCode ?? 0;
|
|
109
110
|
}
|
|
110
111
|
catch (error_) {
|
|
112
|
+
if (error_ instanceof Error && 'timedOut' in error_ && error_.timedOut) {
|
|
113
|
+
throw new ContainerTimeoutError(request.timeoutSec ?? 0, { cause: error_ });
|
|
114
|
+
}
|
|
115
|
+
const stderr = error_ instanceof Error && 'stderr' in error_ ? String(error_.stderr) : '';
|
|
116
|
+
if (/unable to find image|pull access denied|manifest unknown/i.test(stderr)) {
|
|
117
|
+
throw new ImagePullError(request.image, { cause: error_ });
|
|
118
|
+
}
|
|
111
119
|
exitCode = 1;
|
|
112
120
|
error = error_ instanceof Error ? error_.message : String(error_);
|
|
113
121
|
}
|
package/dist/engine/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { Workspace } from './workspace.js';
|
|
2
2
|
export { ContainerExecutor } from './executor.js';
|
|
3
3
|
export { DockerCliExecutor } from './docker-executor.js';
|
|
4
|
+
export { PipexError, DockerError, DockerNotAvailableError, ImagePullError, ContainerTimeoutError, ContainerCrashError, ContainerCleanupError, WorkspaceError, ArtifactNotFoundError, StagingError, PipelineError, ValidationError, CyclicDependencyError, StepNotFoundError, KitError, MissingParameterError } from '../errors.js';
|
package/dist/engine/workspace.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { access, mkdir, readdir, rename, rm, symlink } from 'node:fs/promises';
|
|
1
|
+
import { access, mkdir, readdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { WorkspaceError, StagingError } from '../errors.js';
|
|
4
5
|
/**
|
|
5
6
|
* Isolated execution environment for container runs.
|
|
6
7
|
*
|
|
@@ -100,7 +101,7 @@ export class Workspace {
|
|
|
100
101
|
*/
|
|
101
102
|
static async remove(workdirRoot, id) {
|
|
102
103
|
if (id.includes('..') || id.includes('/')) {
|
|
103
|
-
throw new
|
|
104
|
+
throw new WorkspaceError('INVALID_WORKSPACE_ID', `Invalid workspace ID: ${id}`);
|
|
104
105
|
}
|
|
105
106
|
await rm(join(workdirRoot, id), { recursive: true, force: true });
|
|
106
107
|
}
|
|
@@ -164,10 +165,15 @@ export class Workspace {
|
|
|
164
165
|
* @returns Absolute path to the created staging directory
|
|
165
166
|
*/
|
|
166
167
|
async prepareRun(runId) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
try {
|
|
169
|
+
const path = this.runStagingPath(runId);
|
|
170
|
+
await mkdir(path, { recursive: true });
|
|
171
|
+
await mkdir(join(path, 'artifacts'), { recursive: true });
|
|
172
|
+
return path;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw new StagingError(`Failed to prepare run ${runId}`, { cause: error });
|
|
176
|
+
}
|
|
171
177
|
}
|
|
172
178
|
/**
|
|
173
179
|
* Commits a staging run to the runs directory.
|
|
@@ -175,7 +181,12 @@ export class Workspace {
|
|
|
175
181
|
* @param runId - Run identifier
|
|
176
182
|
*/
|
|
177
183
|
async commitRun(runId) {
|
|
178
|
-
|
|
184
|
+
try {
|
|
185
|
+
await rename(this.runStagingPath(runId), this.runPath(runId));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
throw new StagingError(`Failed to commit run ${runId}`, { cause: error });
|
|
189
|
+
}
|
|
179
190
|
}
|
|
180
191
|
/**
|
|
181
192
|
* Creates a symlink from `step-runs/{stepId}` to the committed run.
|
|
@@ -195,7 +206,12 @@ export class Workspace {
|
|
|
195
206
|
* @param runId - Run identifier
|
|
196
207
|
*/
|
|
197
208
|
async discardRun(runId) {
|
|
198
|
-
|
|
209
|
+
try {
|
|
210
|
+
await rm(this.runStagingPath(runId), { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
throw new StagingError(`Failed to discard run ${runId}`, { cause: error });
|
|
214
|
+
}
|
|
199
215
|
}
|
|
200
216
|
/**
|
|
201
217
|
* Removes all staging directories.
|
|
@@ -279,6 +295,54 @@ export class Workspace {
|
|
|
279
295
|
return [];
|
|
280
296
|
}
|
|
281
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* Marks a step as currently running by writing a marker file.
|
|
300
|
+
* @param stepId - Step identifier
|
|
301
|
+
* @param meta - Metadata about the running step
|
|
302
|
+
*/
|
|
303
|
+
async markStepRunning(stepId, meta) {
|
|
304
|
+
const dir = join(this.root, 'running');
|
|
305
|
+
await mkdir(dir, { recursive: true });
|
|
306
|
+
await writeFile(join(dir, stepId), JSON.stringify(meta), 'utf8');
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Removes the running marker for a step.
|
|
310
|
+
* @param stepId - Step identifier
|
|
311
|
+
*/
|
|
312
|
+
async markStepDone(stepId) {
|
|
313
|
+
await rm(join(this.root, 'running', stepId), { force: true });
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Lists all steps currently marked as running.
|
|
317
|
+
* @returns Array of running step metadata
|
|
318
|
+
*/
|
|
319
|
+
async listRunningSteps() {
|
|
320
|
+
const dir = join(this.root, 'running');
|
|
321
|
+
try {
|
|
322
|
+
const entries = await readdir(dir);
|
|
323
|
+
const results = [];
|
|
324
|
+
for (const entry of entries) {
|
|
325
|
+
try {
|
|
326
|
+
const content = await readFile(join(dir, entry), 'utf8');
|
|
327
|
+
const meta = JSON.parse(content);
|
|
328
|
+
results.push({ stepId: entry, ...meta });
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// Ignore malformed entries
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return results;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Removes all running markers.
|
|
342
|
+
*/
|
|
343
|
+
async cleanupRunning() {
|
|
344
|
+
await rm(join(this.root, 'running'), { recursive: true, force: true });
|
|
345
|
+
}
|
|
282
346
|
/**
|
|
283
347
|
* Validates a run ID to prevent path traversal attacks.
|
|
284
348
|
* @param id - Run identifier to validate
|
|
@@ -287,10 +351,10 @@ export class Workspace {
|
|
|
287
351
|
*/
|
|
288
352
|
validateRunId(id) {
|
|
289
353
|
if (!/^[\w-]+$/.test(id)) {
|
|
290
|
-
throw new
|
|
354
|
+
throw new WorkspaceError('INVALID_RUN_ID', `Invalid run ID: ${id}. Must contain only alphanumeric characters, dashes, and underscores.`);
|
|
291
355
|
}
|
|
292
356
|
if (id.includes('..')) {
|
|
293
|
-
throw new
|
|
357
|
+
throw new WorkspaceError('INVALID_RUN_ID', `Invalid run ID: ${id}. Path traversal is not allowed.`);
|
|
294
358
|
}
|
|
295
359
|
}
|
|
296
360
|
/**
|
|
@@ -302,10 +366,10 @@ export class Workspace {
|
|
|
302
366
|
*/
|
|
303
367
|
validateCacheName(name) {
|
|
304
368
|
if (!/^[\w-]+$/.test(name)) {
|
|
305
|
-
throw new
|
|
369
|
+
throw new WorkspaceError('INVALID_CACHE_NAME', `Invalid cache name: ${name}. Must contain only alphanumeric characters, dashes, and underscores.`);
|
|
306
370
|
}
|
|
307
371
|
if (name.includes('..')) {
|
|
308
|
-
throw new
|
|
372
|
+
throw new WorkspaceError('INVALID_CACHE_NAME', `Invalid cache name: ${name}. Path traversal is not allowed.`);
|
|
309
373
|
}
|
|
310
374
|
}
|
|
311
375
|
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export class PipexError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message, options) {
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = 'PipexError';
|
|
7
|
+
}
|
|
8
|
+
get transient() {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// -- Docker errors -----------------------------------------------------------
|
|
13
|
+
export class DockerError extends PipexError {
|
|
14
|
+
constructor(code, message, options) {
|
|
15
|
+
super(code, message, options);
|
|
16
|
+
this.name = 'DockerError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class DockerNotAvailableError extends DockerError {
|
|
20
|
+
constructor(options) {
|
|
21
|
+
super('DOCKER_NOT_AVAILABLE', 'Docker CLI not found. Please install Docker.', options);
|
|
22
|
+
this.name = 'DockerNotAvailableError';
|
|
23
|
+
}
|
|
24
|
+
get transient() {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class ImagePullError extends DockerError {
|
|
29
|
+
constructor(image, options) {
|
|
30
|
+
super('IMAGE_PULL_FAILED', `Failed to pull image "${image}"`, options);
|
|
31
|
+
this.name = 'ImagePullError';
|
|
32
|
+
}
|
|
33
|
+
get transient() {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class ContainerTimeoutError extends DockerError {
|
|
38
|
+
constructor(timeoutSec, options) {
|
|
39
|
+
super('CONTAINER_TIMEOUT', `Container exceeded timeout of ${timeoutSec}s`, options);
|
|
40
|
+
this.name = 'ContainerTimeoutError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class ContainerCrashError extends DockerError {
|
|
44
|
+
stepId;
|
|
45
|
+
exitCode;
|
|
46
|
+
constructor(stepId, exitCode, options) {
|
|
47
|
+
super('CONTAINER_CRASH', `Step ${stepId} failed with exit code ${exitCode}`, options);
|
|
48
|
+
this.stepId = stepId;
|
|
49
|
+
this.exitCode = exitCode;
|
|
50
|
+
this.name = 'ContainerCrashError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class ContainerCleanupError extends DockerError {
|
|
54
|
+
constructor(options) {
|
|
55
|
+
super('CONTAINER_CLEANUP_FAILED', 'Failed to clean up container', options);
|
|
56
|
+
this.name = 'ContainerCleanupError';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// -- Workspace errors --------------------------------------------------------
|
|
60
|
+
export class WorkspaceError extends PipexError {
|
|
61
|
+
constructor(code, message, options) {
|
|
62
|
+
super(code, message, options);
|
|
63
|
+
this.name = 'WorkspaceError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export class ArtifactNotFoundError extends WorkspaceError {
|
|
67
|
+
constructor(message, options) {
|
|
68
|
+
super('ARTIFACT_NOT_FOUND', message, options);
|
|
69
|
+
this.name = 'ArtifactNotFoundError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export class StagingError extends WorkspaceError {
|
|
73
|
+
constructor(message, options) {
|
|
74
|
+
super('STAGING_FAILED', message, options);
|
|
75
|
+
this.name = 'StagingError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// -- Pipeline errors ---------------------------------------------------------
|
|
79
|
+
export class PipelineError extends PipexError {
|
|
80
|
+
constructor(code, message, options) {
|
|
81
|
+
super(code, message, options);
|
|
82
|
+
this.name = 'PipelineError';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export class ValidationError extends PipelineError {
|
|
86
|
+
constructor(message, options) {
|
|
87
|
+
super('VALIDATION_ERROR', message, options);
|
|
88
|
+
this.name = 'ValidationError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export class CyclicDependencyError extends PipelineError {
|
|
92
|
+
constructor(message, options) {
|
|
93
|
+
super('CYCLIC_DEPENDENCY', message, options);
|
|
94
|
+
this.name = 'CyclicDependencyError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export class StepNotFoundError extends PipelineError {
|
|
98
|
+
constructor(stepId, referencedStep, options) {
|
|
99
|
+
super('STEP_NOT_FOUND', `Step ${stepId}: input step '${referencedStep}' not found or not yet executed`, options);
|
|
100
|
+
this.name = 'StepNotFoundError';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// -- Bundle errors -----------------------------------------------------------
|
|
104
|
+
export class BundleError extends PipexError {
|
|
105
|
+
constructor(message, options) {
|
|
106
|
+
super('BUNDLE_ERROR', message, options);
|
|
107
|
+
this.name = 'BundleError';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// -- Kit errors --------------------------------------------------------------
|
|
111
|
+
export class KitError extends PipexError {
|
|
112
|
+
constructor(code, message, options) {
|
|
113
|
+
super(code, message, options);
|
|
114
|
+
this.name = 'KitError';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export class MissingParameterError extends KitError {
|
|
118
|
+
constructor(kitName, paramName, options) {
|
|
119
|
+
super('MISSING_PARAMETER', `Kit "${kitName}": "${paramName}" parameter is required`, options);
|
|
120
|
+
this.name = 'MissingParameterError';
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -38,3 +38,6 @@
|
|
|
38
38
|
*/
|
|
39
39
|
// Export engine layer for programmatic use
|
|
40
40
|
export { Workspace, ContainerExecutor, DockerCliExecutor } from './engine/index.js';
|
|
41
|
+
// Export core layer for pipeline orchestration
|
|
42
|
+
export { PipelineRunner, StepRunner, PipelineLoader, StateManager, ConsoleReporter, StreamReporter, CompositeReporter, InMemoryTransport, EventAggregator, buildGraph, topologicalLevels, subgraph, leafNodes, evaluateCondition, dirSize, formatSize, formatDuration } from './core/index.js';
|
|
43
|
+
export { PipexError, DockerError, DockerNotAvailableError, ImagePullError, ContainerTimeoutError, ContainerCrashError, ContainerCleanupError, WorkspaceError, ArtifactNotFoundError, StagingError, PipelineError, ValidationError, CyclicDependencyError, StepNotFoundError, KitError, MissingParameterError } from './errors.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import test from 'ava';
|
|
2
|
+
import { KitError } from '../../errors.js';
|
|
3
|
+
import { getKit } from '../index.js';
|
|
4
|
+
test('getKit returns node kit', t => {
|
|
5
|
+
const kit = getKit('node');
|
|
6
|
+
t.is(kit.name, 'node');
|
|
7
|
+
});
|
|
8
|
+
test('getKit returns python kit', t => {
|
|
9
|
+
const kit = getKit('python');
|
|
10
|
+
t.is(kit.name, 'python');
|
|
11
|
+
});
|
|
12
|
+
test('getKit returns shell kit', t => {
|
|
13
|
+
const kit = getKit('shell');
|
|
14
|
+
t.is(kit.name, 'shell');
|
|
15
|
+
});
|
|
16
|
+
test('getKit throws KitError on unknown kit with available list', t => {
|
|
17
|
+
const error = t.throws(() => getKit('unknown'));
|
|
18
|
+
t.true(error instanceof KitError);
|
|
19
|
+
t.truthy(error?.message.includes('Unknown kit'));
|
|
20
|
+
t.truthy(error?.message.includes('node'));
|
|
21
|
+
t.truthy(error?.message.includes('python'));
|
|
22
|
+
t.truthy(error?.message.includes('shell'));
|
|
23
|
+
});
|