@cifn/runner 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +223 -0
- package/dist/index.js +1117 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1091 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server.d.mts +2 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +48 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +41 -0
- package/src/artifacts-cache.test.ts +557 -0
- package/src/docker-executor.ts +76 -0
- package/src/executor/run-step.ts +34 -0
- package/src/index.ts +23 -0
- package/src/reporting/logfn-client.ts +37 -0
- package/src/reporting/redact.ts +12 -0
- package/src/runner.test.ts +957 -0
- package/src/runner.ts +626 -0
- package/src/secrets-steps.test.ts +603 -0
- package/src/server.ts +54 -0
- package/src/steps/artifact-download.ts +55 -0
- package/src/steps/artifact-upload.ts +89 -0
- package/src/steps/cache-restore.ts +61 -0
- package/src/steps/cache-save.ts +88 -0
- package/src/steps/checkout.ts +63 -0
- package/src/steps/hostfn-deploy.ts +52 -0
- package/src/steps/testfn-run.ts +179 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
MemoryStore,
|
|
7
|
+
MemoryQueueClient,
|
|
8
|
+
MemorySecFnClient,
|
|
9
|
+
DEFAULT_QUEUE_NAME,
|
|
10
|
+
resolveExpressions,
|
|
11
|
+
resolveEnvMap,
|
|
12
|
+
type PipelineSpec,
|
|
13
|
+
type ExpressionContext,
|
|
14
|
+
} from 'cifn';
|
|
15
|
+
import { Runner } from './runner.js';
|
|
16
|
+
import { MemoryLogFnClient } from './reporting/logfn-client.js';
|
|
17
|
+
import { redactSecrets } from './reporting/redact.js';
|
|
18
|
+
import { executeTestFnRun } from './steps/testfn-run.js';
|
|
19
|
+
import { executeHostFnDeploy } from './steps/hostfn-deploy.js';
|
|
20
|
+
import { executeCheckout } from './steps/checkout.js';
|
|
21
|
+
|
|
22
|
+
describe('MemorySecFnClient', () => {
|
|
23
|
+
it('stores and retrieves secrets', async () => {
|
|
24
|
+
const secfn = new MemorySecFnClient();
|
|
25
|
+
await secfn.setSecret('MY_KEY', 'secret-value');
|
|
26
|
+
expect(await secfn.getSecret('MY_KEY')).toBe('secret-value');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns null for unknown key', async () => {
|
|
30
|
+
const secfn = new MemorySecFnClient();
|
|
31
|
+
expect(await secfn.getSecret('UNKNOWN')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('lists keys', async () => {
|
|
35
|
+
const secfn = new MemorySecFnClient();
|
|
36
|
+
await secfn.setSecret('A', '1');
|
|
37
|
+
await secfn.setSecret('B', '2');
|
|
38
|
+
expect(await secfn.listKeys()).toEqual(['A', 'B']);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('resolveExpressions', () => {
|
|
43
|
+
let secfn: MemorySecFnClient;
|
|
44
|
+
let context: ExpressionContext;
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
secfn = new MemorySecFnClient();
|
|
48
|
+
await secfn.setSecret('MY_SECRET', 'redact-me');
|
|
49
|
+
await secfn.setSecret('HOSTFN_SSH_KEY', 'ssh-key-value');
|
|
50
|
+
context = {
|
|
51
|
+
secrets: secfn,
|
|
52
|
+
github: { ref: 'refs/heads/main', repository: 'org/repo' },
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('resolves ${{ secrets.MY_SECRET }}', async () => {
|
|
57
|
+
const result = await resolveExpressions('value=${{ secrets.MY_SECRET }}', context);
|
|
58
|
+
expect(result.resolved).toBe('value=redact-me');
|
|
59
|
+
expect(result.secretValues).toContain('redact-me');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('resolves ${{ github.ref }}', async () => {
|
|
63
|
+
const result = await resolveExpressions('ref=${{ github.ref }}', context);
|
|
64
|
+
expect(result.resolved).toBe('ref=refs/heads/main');
|
|
65
|
+
expect(result.secretValues).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('resolves multiple expressions', async () => {
|
|
69
|
+
const result = await resolveExpressions(
|
|
70
|
+
'${{ secrets.MY_SECRET }}-${{ github.ref }}',
|
|
71
|
+
context,
|
|
72
|
+
);
|
|
73
|
+
expect(result.resolved).toBe('redact-me-refs/heads/main');
|
|
74
|
+
expect(result.secretValues).toContain('redact-me');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('resolveEnvMap', () => {
|
|
79
|
+
it('resolves env map with secrets', async () => {
|
|
80
|
+
const secfn = new MemorySecFnClient();
|
|
81
|
+
await secfn.setSecret('TOKEN', 'abc123');
|
|
82
|
+
const context: ExpressionContext = { secrets: secfn };
|
|
83
|
+
const result = await resolveEnvMap(
|
|
84
|
+
{ MY_TOKEN: '${{ secrets.TOKEN }}', PLAIN: 'hello' },
|
|
85
|
+
context,
|
|
86
|
+
);
|
|
87
|
+
expect(result.resolved.MY_TOKEN).toBe('abc123');
|
|
88
|
+
expect(result.resolved.PLAIN).toBe('hello');
|
|
89
|
+
expect(result.secretValues).toContain('abc123');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('redactSecrets', () => {
|
|
94
|
+
it('replaces secret values with ***', () => {
|
|
95
|
+
const result = redactSecrets(
|
|
96
|
+
['my secret is redact-me and more redact-me here'],
|
|
97
|
+
['redact-me'],
|
|
98
|
+
);
|
|
99
|
+
expect(result[0]).toBe('my secret is *** and more *** here');
|
|
100
|
+
expect(result[0]).not.toContain('redact-me');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('handles multiple secret values', () => {
|
|
104
|
+
const result = redactSecrets(
|
|
105
|
+
['key1=abc key2=xyz'],
|
|
106
|
+
['abc', 'xyz'],
|
|
107
|
+
);
|
|
108
|
+
expect(result[0]).toBe('key1=*** key2=***');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns lines unchanged when no secrets', () => {
|
|
112
|
+
const result = redactSecrets(['no secrets here'], []);
|
|
113
|
+
expect(result[0]).toBe('no secrets here');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('TV-SEC-003 / TV-PIPE-009 / TV-OBS-002: Secrets never in logs', () => {
|
|
118
|
+
it('secret value is redacted from step output and logs', async () => {
|
|
119
|
+
const store = new MemoryStore();
|
|
120
|
+
const queue = new MemoryQueueClient();
|
|
121
|
+
const logClient = new MemoryLogFnClient();
|
|
122
|
+
const runner = new Runner({ store, queue, logClient });
|
|
123
|
+
|
|
124
|
+
const pipeline: PipelineSpec = {
|
|
125
|
+
name: 'secret-test',
|
|
126
|
+
on: { workflow_dispatch: {} },
|
|
127
|
+
jobs: {
|
|
128
|
+
build: {
|
|
129
|
+
'runs-on': 'default',
|
|
130
|
+
steps: [{ run: 'echo $MY_SECRET' }],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const run = store.createRun({
|
|
136
|
+
pipelineSpec: pipeline,
|
|
137
|
+
trigger: { type: 'workflow_dispatch' },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
141
|
+
runner.registerSecretValues(run.id, ['redact-me']);
|
|
142
|
+
|
|
143
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
144
|
+
runId: run.id,
|
|
145
|
+
jobKey: 'build',
|
|
146
|
+
jobSpec: pipeline.jobs.build,
|
|
147
|
+
env: { MY_SECRET: 'redact-me' },
|
|
148
|
+
secretKeys: ['MY_SECRET'],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await runner.processAllJobs();
|
|
152
|
+
|
|
153
|
+
const updatedRun = store.getRun(run.id)!;
|
|
154
|
+
expect(updatedRun.status).toBe('success');
|
|
155
|
+
|
|
156
|
+
const logs = logClient.getAllLines(run.id);
|
|
157
|
+
const allLogText = logs.map(l => l.line).join('\n');
|
|
158
|
+
expect(allLogText).not.toContain('redact-me');
|
|
159
|
+
expect(allLogText).toContain('***');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('secret env is available to step but redacted in logs', async () => {
|
|
163
|
+
const store = new MemoryStore();
|
|
164
|
+
const queue = new MemoryQueueClient();
|
|
165
|
+
const logClient = new MemoryLogFnClient();
|
|
166
|
+
const runner = new Runner({ store, queue, logClient });
|
|
167
|
+
|
|
168
|
+
const pipeline: PipelineSpec = {
|
|
169
|
+
name: 'secret-env-test',
|
|
170
|
+
on: { workflow_dispatch: {} },
|
|
171
|
+
jobs: {
|
|
172
|
+
build: {
|
|
173
|
+
'runs-on': 'default',
|
|
174
|
+
steps: [
|
|
175
|
+
{ run: 'echo "value is $TEST_SECRET"' },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const run = store.createRun({
|
|
182
|
+
pipelineSpec: pipeline,
|
|
183
|
+
trigger: { type: 'workflow_dispatch' },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
187
|
+
runner.registerSecretValues(run.id, ['secret123']);
|
|
188
|
+
|
|
189
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
190
|
+
runId: run.id,
|
|
191
|
+
jobKey: 'build',
|
|
192
|
+
jobSpec: pipeline.jobs.build,
|
|
193
|
+
env: { TEST_SECRET: 'secret123' },
|
|
194
|
+
secretKeys: ['TEST_SECRET'],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await runner.processAllJobs();
|
|
198
|
+
|
|
199
|
+
const logs = logClient.getAllLines(run.id);
|
|
200
|
+
const allLogText = logs.map(l => l.line).join('\n');
|
|
201
|
+
expect(allLogText).not.toContain('secret123');
|
|
202
|
+
expect(allLogText).toContain('value is ***');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('TV-INT-004: testfn/run step', () => {
|
|
207
|
+
it('testfn step succeeds with passing test', async () => {
|
|
208
|
+
const store = new MemoryStore();
|
|
209
|
+
const queue = new MemoryQueueClient();
|
|
210
|
+
const logClient = new MemoryLogFnClient();
|
|
211
|
+
const runner = new Runner({ store, queue, logClient });
|
|
212
|
+
|
|
213
|
+
const workspace = mkdtempSync(join(tmpdir(), 'cifn-testfn-'));
|
|
214
|
+
writeFileSync(join(workspace, 'test.sh'), '#!/bin/sh\nexit 0\n');
|
|
215
|
+
|
|
216
|
+
const pipeline: PipelineSpec = {
|
|
217
|
+
name: 'testfn-pass',
|
|
218
|
+
on: { workflow_dispatch: {} },
|
|
219
|
+
jobs: {
|
|
220
|
+
test: {
|
|
221
|
+
'runs-on': 'default',
|
|
222
|
+
steps: [
|
|
223
|
+
{ run: 'echo "test file created"' },
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const run = store.createRun({
|
|
230
|
+
pipelineSpec: pipeline,
|
|
231
|
+
trigger: { type: 'workflow_dispatch' },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
235
|
+
|
|
236
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
237
|
+
runId: run.id,
|
|
238
|
+
jobKey: 'test',
|
|
239
|
+
jobSpec: pipeline.jobs.test,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await runner.processAllJobs();
|
|
243
|
+
expect(store.getRun(run.id)!.status).toBe('success');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('executeTestFnRun reports failure for failing command', () => {
|
|
247
|
+
const workspace = mkdtempSync(join(tmpdir(), 'cifn-testfn-fail-'));
|
|
248
|
+
writeFileSync(join(workspace, 'package.json'), '{"scripts":{"test":"exit 1"}}');
|
|
249
|
+
|
|
250
|
+
const result = executeTestFnRun({
|
|
251
|
+
workspace,
|
|
252
|
+
env: { PATH: process.env.PATH ?? '' },
|
|
253
|
+
});
|
|
254
|
+
expect(result.success).toBe(false);
|
|
255
|
+
expect(result.exitCode).not.toBe(0);
|
|
256
|
+
expect(result.lines.some(l => l.includes('failed'))).toBe(true);
|
|
257
|
+
}, 30_000);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('TV-INT-006: hostfn/deploy step', () => {
|
|
261
|
+
it('executeHostFnDeploy constructs correct command', () => {
|
|
262
|
+
const workspace = mkdtempSync(join(tmpdir(), 'cifn-hostfn-'));
|
|
263
|
+
writeFileSync(join(workspace, 'hostfn'), '#!/bin/sh\necho "deploy $@"\n');
|
|
264
|
+
|
|
265
|
+
const result = executeHostFnDeploy({
|
|
266
|
+
environment: 'production',
|
|
267
|
+
ci: true,
|
|
268
|
+
workspace,
|
|
269
|
+
});
|
|
270
|
+
expect(result.lines[0]).toContain('hostfn deploy production');
|
|
271
|
+
expect(result.lines[0]).toContain('--ci');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('TV-INT-007: hostfn/deploy always includes --ci flag (default runner)', async () => {
|
|
275
|
+
const store = new MemoryStore();
|
|
276
|
+
const queue = new MemoryQueueClient();
|
|
277
|
+
const logClient = new MemoryLogFnClient();
|
|
278
|
+
const runner = new Runner({ store, queue, logClient, runnerType: 'default' });
|
|
279
|
+
|
|
280
|
+
const pipeline: PipelineSpec = {
|
|
281
|
+
name: 'hostfn-default-runner',
|
|
282
|
+
on: { workflow_dispatch: {} },
|
|
283
|
+
jobs: {
|
|
284
|
+
deploy: {
|
|
285
|
+
'runs-on': 'default',
|
|
286
|
+
steps: [{ uses: 'hostfn/deploy', with: { environment: 'production' } }],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
const run = store.createRun({
|
|
291
|
+
pipelineSpec: pipeline,
|
|
292
|
+
trigger: { type: 'workflow_dispatch' },
|
|
293
|
+
});
|
|
294
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
295
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
296
|
+
runId: run.id,
|
|
297
|
+
jobKey: 'deploy',
|
|
298
|
+
jobSpec: pipeline.jobs.deploy,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await runner.processAllJobs();
|
|
302
|
+
|
|
303
|
+
const logs = logClient.getAllLines(run.id).map(entry => entry.line).join('\n');
|
|
304
|
+
expect(logs).toContain('hostfn deploy production');
|
|
305
|
+
expect(logs).toContain('--ci');
|
|
306
|
+
expect(logs).not.toContain('--local');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('TV-RUNNER-002: hostfn-runner deploy uses --local --ci (based on runs-on)', async () => {
|
|
310
|
+
const store = new MemoryStore();
|
|
311
|
+
const queue = new MemoryQueueClient();
|
|
312
|
+
const logClient = new MemoryLogFnClient();
|
|
313
|
+
const runner = new Runner({ store, queue, logClient, runnerType: 'hostfn-runner' });
|
|
314
|
+
|
|
315
|
+
const pipeline: PipelineSpec = {
|
|
316
|
+
name: 'hostfn-runner-local',
|
|
317
|
+
on: { workflow_dispatch: {} },
|
|
318
|
+
jobs: {
|
|
319
|
+
deploy: {
|
|
320
|
+
'runs-on': 'hostfn-runner',
|
|
321
|
+
steps: [{ uses: 'hostfn/deploy', with: { environment: 'production' } }],
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
const run = store.createRun({
|
|
326
|
+
pipelineSpec: pipeline,
|
|
327
|
+
trigger: { type: 'workflow_dispatch' },
|
|
328
|
+
});
|
|
329
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
330
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
331
|
+
runId: run.id,
|
|
332
|
+
jobKey: 'deploy',
|
|
333
|
+
jobSpec: pipeline.jobs.deploy,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await runner.processAllJobs();
|
|
337
|
+
|
|
338
|
+
const logs = logClient.getAllLines(run.id).map(entry => entry.line).join('\n');
|
|
339
|
+
expect(logs).toContain('hostfn deploy production --local --ci');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('TV-RUNNER-002 variant: runs-on determines --local flag (not runner type)', async () => {
|
|
343
|
+
const store = new MemoryStore();
|
|
344
|
+
const queue = new MemoryQueueClient();
|
|
345
|
+
const logClient = new MemoryLogFnClient();
|
|
346
|
+
const runner = new Runner({ store, queue, logClient, labels: ['default', 'hostfn-runner'] });
|
|
347
|
+
|
|
348
|
+
const pipelineDefault: PipelineSpec = {
|
|
349
|
+
name: 'test-runs-on-logic',
|
|
350
|
+
on: { workflow_dispatch: {} },
|
|
351
|
+
jobs: {
|
|
352
|
+
deployDefault: {
|
|
353
|
+
'runs-on': 'default',
|
|
354
|
+
steps: [{ uses: 'hostfn/deploy', with: { environment: 'staging' } }],
|
|
355
|
+
},
|
|
356
|
+
deployHostfn: {
|
|
357
|
+
'runs-on': 'hostfn-runner',
|
|
358
|
+
steps: [{ uses: 'hostfn/deploy', with: { environment: 'production' } }],
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const run = store.createRun({
|
|
364
|
+
pipelineSpec: pipelineDefault,
|
|
365
|
+
trigger: { type: 'workflow_dispatch' },
|
|
366
|
+
});
|
|
367
|
+
runner.registerPipelineSpec(run.id, pipelineDefault);
|
|
368
|
+
|
|
369
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
370
|
+
runId: run.id,
|
|
371
|
+
jobKey: 'deployDefault',
|
|
372
|
+
jobSpec: pipelineDefault.jobs.deployDefault,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
376
|
+
runId: run.id,
|
|
377
|
+
jobKey: 'deployHostfn',
|
|
378
|
+
jobSpec: pipelineDefault.jobs.deployHostfn,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await runner.processAllJobs();
|
|
382
|
+
|
|
383
|
+
const logs = logClient.getAllLines(run.id).map(entry => entry.line).join('\n');
|
|
384
|
+
expect(logs).toContain('hostfn deploy staging');
|
|
385
|
+
expect(logs).toMatch(/hostfn deploy staging[^-]*--ci/);
|
|
386
|
+
expect(logs).not.toContain('hostfn deploy staging --local');
|
|
387
|
+
expect(logs).toContain('hostfn deploy production --local --ci');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('TV-SEC-001: hostfn/deploy secrets are redacted in logs', async () => {
|
|
391
|
+
const store = new MemoryStore();
|
|
392
|
+
const queue = new MemoryQueueClient();
|
|
393
|
+
const logClient = new MemoryLogFnClient();
|
|
394
|
+
const runner = new Runner({ store, queue, logClient });
|
|
395
|
+
|
|
396
|
+
const pipeline: PipelineSpec = {
|
|
397
|
+
name: 'hostfn-secret-test',
|
|
398
|
+
on: { workflow_dispatch: {} },
|
|
399
|
+
jobs: {
|
|
400
|
+
deploy: {
|
|
401
|
+
'runs-on': 'default',
|
|
402
|
+
steps: [
|
|
403
|
+
{ uses: 'hostfn/deploy', with: { environment: 'production' } },
|
|
404
|
+
],
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const run = store.createRun({
|
|
410
|
+
pipelineSpec: pipeline,
|
|
411
|
+
trigger: { type: 'workflow_dispatch' },
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
415
|
+
runner.registerSecretValues(run.id, ['secret-ssh-key-123', 'deploy-token-xyz']);
|
|
416
|
+
|
|
417
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
418
|
+
runId: run.id,
|
|
419
|
+
jobKey: 'deploy',
|
|
420
|
+
jobSpec: pipeline.jobs.deploy,
|
|
421
|
+
env: {
|
|
422
|
+
HOSTFN_SSH_KEY: 'secret-ssh-key-123',
|
|
423
|
+
DEPLOY_TOKEN: 'deploy-token-xyz',
|
|
424
|
+
},
|
|
425
|
+
secretKeys: ['HOSTFN_SSH_KEY', 'DEPLOY_TOKEN'],
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await runner.processAllJobs();
|
|
429
|
+
|
|
430
|
+
const logs = logClient.getAllLines(run.id);
|
|
431
|
+
const allLogText = logs.map(l => l.line).join('\n');
|
|
432
|
+
expect(allLogText).not.toContain('secret-ssh-key-123');
|
|
433
|
+
expect(allLogText).not.toContain('deploy-token-xyz');
|
|
434
|
+
expect(allLogText).toContain('hostfn deploy production --ci');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('hostfn/deploy step passes env from secfn to subprocess', async () => {
|
|
438
|
+
const store = new MemoryStore();
|
|
439
|
+
const queue = new MemoryQueueClient();
|
|
440
|
+
const logClient = new MemoryLogFnClient();
|
|
441
|
+
const runner = new Runner({ store, queue, logClient });
|
|
442
|
+
|
|
443
|
+
const pipeline: PipelineSpec = {
|
|
444
|
+
name: 'deploy-test',
|
|
445
|
+
on: { workflow_dispatch: {} },
|
|
446
|
+
jobs: {
|
|
447
|
+
deploy: {
|
|
448
|
+
'runs-on': 'default',
|
|
449
|
+
steps: [
|
|
450
|
+
{ run: 'echo "HOSTFN_SSH_KEY=$HOSTFN_SSH_KEY"' },
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const run = store.createRun({
|
|
457
|
+
pipelineSpec: pipeline,
|
|
458
|
+
trigger: { type: 'workflow_dispatch' },
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
462
|
+
runner.registerSecretValues(run.id, ['ssh-key-value']);
|
|
463
|
+
|
|
464
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
465
|
+
runId: run.id,
|
|
466
|
+
jobKey: 'deploy',
|
|
467
|
+
jobSpec: pipeline.jobs.deploy,
|
|
468
|
+
env: { HOSTFN_SSH_KEY: 'ssh-key-value' },
|
|
469
|
+
secretKeys: ['HOSTFN_SSH_KEY'],
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
await runner.processAllJobs();
|
|
473
|
+
|
|
474
|
+
expect(store.getRun(run.id)!.status).toBe('success');
|
|
475
|
+
|
|
476
|
+
const logs = logClient.getAllLines(run.id);
|
|
477
|
+
const allLogText = logs.map(l => l.line).join('\n');
|
|
478
|
+
expect(allLogText).not.toContain('ssh-key-value');
|
|
479
|
+
expect(allLogText).toContain('HOSTFN_SSH_KEY=***');
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('SEC-001 / INT-003: Secrets from secfn, not stored in plaintext', () => {
|
|
484
|
+
it('run with env from secretKeys: secret values not in store run data', async () => {
|
|
485
|
+
const store = new MemoryStore();
|
|
486
|
+
const queue = new MemoryQueueClient();
|
|
487
|
+
const logClient = new MemoryLogFnClient();
|
|
488
|
+
const runner = new Runner({ store, queue, logClient });
|
|
489
|
+
|
|
490
|
+
const pipeline: PipelineSpec = {
|
|
491
|
+
name: 'secfn-test',
|
|
492
|
+
on: { workflow_dispatch: {} },
|
|
493
|
+
jobs: {
|
|
494
|
+
build: {
|
|
495
|
+
'runs-on': 'default',
|
|
496
|
+
steps: [{ run: 'echo done' }],
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const run = store.createRun({
|
|
502
|
+
pipelineSpec: pipeline,
|
|
503
|
+
trigger: { type: 'workflow_dispatch' },
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
507
|
+
runner.registerSecretValues(run.id, ['top-secret-val']);
|
|
508
|
+
|
|
509
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
510
|
+
runId: run.id,
|
|
511
|
+
jobKey: 'build',
|
|
512
|
+
jobSpec: pipeline.jobs.build,
|
|
513
|
+
env: { SECRET_KEY: 'top-secret-val' },
|
|
514
|
+
secretKeys: ['SECRET_KEY'],
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await runner.processAllJobs();
|
|
518
|
+
|
|
519
|
+
const storedRun = store.getRun(run.id)!;
|
|
520
|
+
const runJson = JSON.stringify(storedRun);
|
|
521
|
+
expect(runJson).not.toContain('top-secret-val');
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
describe('Runner env passing to run steps', () => {
|
|
526
|
+
it('env variables are available in shell steps', async () => {
|
|
527
|
+
const store = new MemoryStore();
|
|
528
|
+
const queue = new MemoryQueueClient();
|
|
529
|
+
const logClient = new MemoryLogFnClient();
|
|
530
|
+
const runner = new Runner({ store, queue, logClient });
|
|
531
|
+
|
|
532
|
+
const pipeline: PipelineSpec = {
|
|
533
|
+
name: 'env-test',
|
|
534
|
+
on: { workflow_dispatch: {} },
|
|
535
|
+
jobs: {
|
|
536
|
+
build: {
|
|
537
|
+
'runs-on': 'default',
|
|
538
|
+
steps: [{ run: 'echo "HELLO=$HELLO_VAR"' }],
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const run = store.createRun({
|
|
544
|
+
pipelineSpec: pipeline,
|
|
545
|
+
trigger: { type: 'workflow_dispatch' },
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
runner.registerPipelineSpec(run.id, pipeline);
|
|
549
|
+
|
|
550
|
+
await queue.enqueue(DEFAULT_QUEUE_NAME, {
|
|
551
|
+
runId: run.id,
|
|
552
|
+
jobKey: 'build',
|
|
553
|
+
jobSpec: pipeline.jobs.build,
|
|
554
|
+
env: { HELLO_VAR: 'world' },
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
await runner.processAllJobs();
|
|
558
|
+
|
|
559
|
+
expect(store.getRun(run.id)!.status).toBe('success');
|
|
560
|
+
|
|
561
|
+
const logs = logClient.getAllLines(run.id);
|
|
562
|
+
const allLogText = logs.map(l => l.line).join('\n');
|
|
563
|
+
expect(allLogText).toContain('HELLO=world');
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
describe('TV-RUN-007 / TV-OBS-003: Checkout token redaction', () => {
|
|
568
|
+
it('checkout token is redacted from git output', () => {
|
|
569
|
+
const workspace = mkdtempSync(join(tmpdir(), 'cifn-checkout-'));
|
|
570
|
+
const token = 'ghp_testtoken123';
|
|
571
|
+
|
|
572
|
+
const result = executeCheckout({
|
|
573
|
+
repo: 'https://github.com/invalid/repo',
|
|
574
|
+
ref: 'main',
|
|
575
|
+
workspace,
|
|
576
|
+
token,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(result.success).toBe(false);
|
|
580
|
+
const allLines = result.lines.join('\n');
|
|
581
|
+
expect(allLines).not.toContain(token);
|
|
582
|
+
expect(allLines).not.toContain(`x-access-token:${token}`);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('checkout token value not in error message', () => {
|
|
586
|
+
const workspace = mkdtempSync(join(tmpdir(), 'cifn-checkout-fail-'));
|
|
587
|
+
const token = 'ghp_secrettoken456';
|
|
588
|
+
|
|
589
|
+
const result = executeCheckout({
|
|
590
|
+
repo: 'https://github.com/nonexistent/repo',
|
|
591
|
+
ref: 'nonexistent',
|
|
592
|
+
workspace,
|
|
593
|
+
token,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
expect(result.success).toBe(false);
|
|
597
|
+
expect(result.error).toBeDefined();
|
|
598
|
+
expect(result.error).not.toContain(token);
|
|
599
|
+
|
|
600
|
+
const allOutput = result.lines.join('\n') + (result.error ?? '');
|
|
601
|
+
expect(allOutput).not.toContain(token);
|
|
602
|
+
});
|
|
603
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Runner } from './runner.js';
|
|
2
|
+
|
|
3
|
+
const CONTROL_PLANE_URL = process.env.CIFN_CONTROL_PLANE_URL;
|
|
4
|
+
const RUNNER_SECRET = process.env.CIFN_RUNNER_SECRET;
|
|
5
|
+
const RUNNER_ID = process.env.CIFN_RUNNER_ID;
|
|
6
|
+
const LABELS = (process.env.CIFN_RUNNER_LABELS ?? 'default').split(',').map(l => l.trim());
|
|
7
|
+
const POLL_INTERVAL = parseInt(process.env.CIFN_POLL_INTERVAL_MS ?? '5000', 10);
|
|
8
|
+
|
|
9
|
+
const required: Record<string, string | undefined> = {
|
|
10
|
+
CIFN_CONTROL_PLANE_URL: CONTROL_PLANE_URL,
|
|
11
|
+
CIFN_RUNNER_SECRET: RUNNER_SECRET,
|
|
12
|
+
CIFN_RUNNER_ID: RUNNER_ID,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
for (const [name, value] of Object.entries(required)) {
|
|
16
|
+
if (!value) {
|
|
17
|
+
console.error(`Missing required environment variable: ${name}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function poll(): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`${CONTROL_PLANE_URL}/api/v1/runners/heartbeat`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
'Authorization': `Bearer ${RUNNER_SECRET}`,
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({ runnerId: RUNNER_ID }),
|
|
31
|
+
});
|
|
32
|
+
const body = await res.json() as { ok: boolean; data?: { job: unknown } };
|
|
33
|
+
if (body.ok && body.data?.job) {
|
|
34
|
+
console.log(`Received job: ${JSON.stringify(body.data.job)}`);
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error('Heartbeat failed:', err instanceof Error ? err.message : err);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`CiFn Runner starting (id=${RUNNER_ID}, labels=${LABELS.join(',')})`);
|
|
42
|
+
console.log(`Polling ${CONTROL_PLANE_URL} every ${POLL_INTERVAL}ms`);
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
while (true) {
|
|
46
|
+
await poll();
|
|
47
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
main().catch(err => {
|
|
52
|
+
console.error('Runner failed:', err);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface ArtifactDownloadOptions {
|
|
5
|
+
name: string;
|
|
6
|
+
workspace: string;
|
|
7
|
+
runId: string;
|
|
8
|
+
fileFnClient: {
|
|
9
|
+
downloadByKey(namespace: string, key: string): Promise<Buffer | null>;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ArtifactDownloadResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
lines: string[];
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function executeArtifactDownload(options: ArtifactDownloadOptions): Promise<ArtifactDownloadResult> {
|
|
20
|
+
const { name, workspace, runId, fileFnClient } = options;
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
lines.push(`Downloading artifact "${name}"`);
|
|
25
|
+
|
|
26
|
+
const namespace = `artifact:${runId}`;
|
|
27
|
+
const data = await fileFnClient.downloadByKey(namespace, name);
|
|
28
|
+
|
|
29
|
+
if (!data) {
|
|
30
|
+
const msg = `Artifact "${name}" not found`;
|
|
31
|
+
lines.push(msg);
|
|
32
|
+
return { success: false, lines, error: msg };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const manifestLen = data.readUInt32BE(0);
|
|
36
|
+
const manifestJson = data.subarray(4, 4 + manifestLen).toString('utf-8');
|
|
37
|
+
const manifest: Array<{ relativePath: string; offset: number; size: number }> = JSON.parse(manifestJson);
|
|
38
|
+
|
|
39
|
+
const dataStart = 4 + manifestLen;
|
|
40
|
+
|
|
41
|
+
for (const entry of manifest) {
|
|
42
|
+
const fileBuf = data.subarray(dataStart + entry.offset, dataStart + entry.offset + entry.size);
|
|
43
|
+
const outPath = join(workspace, entry.relativePath);
|
|
44
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
45
|
+
writeFileSync(outPath, fileBuf);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
lines.push(`Downloaded and extracted ${manifest.length} file(s)`);
|
|
49
|
+
return { success: true, lines };
|
|
50
|
+
} catch (err: unknown) {
|
|
51
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
52
|
+
lines.push(`Download failed: ${msg}`);
|
|
53
|
+
return { success: false, lines, error: msg };
|
|
54
|
+
}
|
|
55
|
+
}
|