@kici-dev/agent 0.4.0 → 0.6.0
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/checkout/clone-job-repos.d.ts +53 -0
- package/dist/checkout/credential-helper-bin.d.ts +28 -0
- package/dist/checkout/credential-helper-host.d.ts +48 -0
- package/dist/checkout/credential-helper.d.ts +44 -0
- package/dist/checkout/git-clone.d.ts +26 -0
- package/dist/checkout/grant-table.d.ts +30 -0
- package/dist/checkout/job-git-credentials.d.ts +49 -0
- package/dist/checkout/write-elevation.d.ts +40 -0
- package/dist/config.d.ts +38 -0
- package/dist/container-ts-loader-hook.js +2047 -1814
- package/dist/execution/between-jobs-controller.d.ts +50 -0
- package/dist/execution/between-jobs-reset.d.ts +25 -0
- package/dist/execution/cleanup-rerun.d.ts +21 -0
- package/dist/execution/download.d.ts +27 -2
- package/dist/execution/dynamic-job-serializer.d.ts +6 -2
- package/dist/execution/generator-context.d.ts +54 -0
- package/dist/execution/global-eval-runner.d.ts +92 -0
- package/dist/execution/global-workflow-env.d.ts +57 -0
- package/dist/execution/image-build/build-engine.d.ts +75 -0
- package/dist/execution/image-build/build-step.d.ts +57 -0
- package/dist/execution/image-build/resolve-build-spec.d.ts +41 -0
- package/dist/execution/image-build/runtime-facts.d.ts +31 -0
- package/dist/execution/init-runner.d.ts +60 -3
- package/dist/execution/job-runner.d.ts +146 -0
- package/dist/execution/sandbox/bare-metal-sandbox.d.ts +25 -1
- package/dist/execution/sandbox/container-sandbox.d.ts +97 -2
- package/dist/execution/sandbox/fork-runner.d.ts +55 -1
- package/dist/execution/sandbox/image-preflight.d.ts +38 -0
- package/dist/execution/sandbox/ipc-protocol.d.ts +96 -2
- package/dist/execution/sandbox/kici-runtime.d.ts +48 -0
- package/dist/execution/sandbox/step-loop.d.ts +17 -1
- package/dist/execution/sandbox/types.d.ts +55 -1
- package/dist/execution/sandbox/workflow-runner.d.ts +75 -4
- package/dist/execution/workflow-loader.d.ts +8 -1
- package/dist/idle-shutdown.d.ts +24 -0
- package/dist/index.js +221 -80
- package/dist/metrics/prometheus.d.ts +36 -10
- package/dist/server.js +3197 -462
- package/dist/workflow-runner-bundle.js +42207 -41104
- package/dist/workflow-runner.js +594 -196
- package/dist/ws/orchestrator-client.d.ts +95 -3
- package/package.json +10 -10
- package/sbom.spdx.json +464 -454
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
1
|
+
import type { $ as Shell } from 'zx';
|
|
2
|
+
import type { Workflow, RepoInfo } from '@kici-dev/sdk';
|
|
3
|
+
import { type ChangedFilesStatus, type MatrixValues } from '@kici-dev/engine';
|
|
3
4
|
/**
|
|
4
5
|
* Result of evaluating dynamic fields on a job.
|
|
5
6
|
* Only fields that were flagged as dynamic and successfully resolved are set.
|
|
@@ -14,7 +15,56 @@ export interface InitResult {
|
|
|
14
15
|
* The orchestrator re-materializes these into N execution jobs at dispatch.
|
|
15
16
|
*/
|
|
16
17
|
matrixValues?: MatrixValues[];
|
|
18
|
+
/**
|
|
19
|
+
* Verdict of the workflow-level `filter`, set only when the init job was asked
|
|
20
|
+
* to evaluate one (`flags.hasFilter`). `false` means the workflow does not
|
|
21
|
+
* apply and its job must not be dispatched.
|
|
22
|
+
*
|
|
23
|
+
* Optional on purpose: an agent that predates the filter never sets it, so the
|
|
24
|
+
* orchestrator reads absence as "no verdict was reported", never as "suppress".
|
|
25
|
+
*/
|
|
26
|
+
filterPassed?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Everything a workflow-level `filter` needs beyond the workflow module and the
|
|
30
|
+
* event. Supplied by the caller because none of it is derivable here: the source
|
|
31
|
+
* tree lives wherever the init job materialized it, and the diff is ground truth
|
|
32
|
+
* from that same clone.
|
|
33
|
+
*/
|
|
34
|
+
export interface FilterEvalInput {
|
|
35
|
+
/** The repo whose event triggered this evaluation. */
|
|
36
|
+
sourceRepo: RepoInfo;
|
|
37
|
+
/** The repo that registered the workflow — identical to `sourceRepo` here. */
|
|
38
|
+
workflowRepo: RepoInfo;
|
|
39
|
+
changedFiles: string[];
|
|
40
|
+
changedFilesStatus: ChangedFilesStatus;
|
|
41
|
+
env?: Record<string, string | undefined>;
|
|
42
|
+
/** zx shell handed to the filter. Defaults to the ambient `$`. */
|
|
43
|
+
$?: typeof Shell;
|
|
17
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Run a workflow's `filter` and report whether the workflow applies.
|
|
47
|
+
*
|
|
48
|
+
* Shared by both agent-side evaluation sites for a same-repo workflow: the init
|
|
49
|
+
* job that gates each static job's dispatch, and the dynamic-eval job that gates
|
|
50
|
+
* whether a generator runs at all. Both must reach the same verdict from the same
|
|
51
|
+
* inputs, so neither builds the context itself.
|
|
52
|
+
*
|
|
53
|
+
* The context is built through `createFilterContext` rather than as an object
|
|
54
|
+
* literal: the factory installs `changedFiles` as a throwing getter, so a filter
|
|
55
|
+
* that reads the diff on an event that has none fails loudly instead of seeing an
|
|
56
|
+
* empty list. A `false` verdict dispatches none of the workflow's own jobs, so a
|
|
57
|
+
* silently-empty diff would suppress it on a mistake. On this same-repo path the
|
|
58
|
+
* verdict is at least recoverable — the run row exists, carrying the `__init__*`
|
|
59
|
+
* jobs, and this evaluation's own step log records the verdict; it is the
|
|
60
|
+
* organization-wide path, which runs elsewhere, that leaves nothing behind.
|
|
61
|
+
*
|
|
62
|
+
* A throwing filter propagates: the evaluating job fails, which surfaces as a
|
|
63
|
+
* failed run. "Could not decide" is never treated as "do not run" — that would
|
|
64
|
+
* be a false green, the same reasoning `buildJobRuleCompletion` applies to a rule
|
|
65
|
+
* whose `check()` threw.
|
|
66
|
+
*/
|
|
67
|
+
export declare function evaluateWorkflowFilter(workflow: Workflow, event: Record<string, unknown>, input: FilterEvalInput | undefined, timeoutMs: number): Promise<boolean>;
|
|
18
68
|
/**
|
|
19
69
|
* Evaluate dynamic fields (context, env, concurrencyGroup) on a job.
|
|
20
70
|
*
|
|
@@ -26,16 +76,23 @@ export interface InitResult {
|
|
|
26
76
|
* -: If a dynamic function returns undefined/null, the field is left undefined.
|
|
27
77
|
* -: Each dynamic function call is wrapped in a timeout (default 60s).
|
|
28
78
|
*
|
|
79
|
+
* A workflow-level `filter` is evaluated FIRST when `flags.hasFilter` is set. A
|
|
80
|
+
* `false` verdict returns immediately: no job of that workflow will be
|
|
81
|
+
* dispatched, so evaluating this one's dynamic fields would run customer code
|
|
82
|
+
* whose result nothing can consume.
|
|
83
|
+
*
|
|
29
84
|
* @param workflow - The extracted Workflow object
|
|
30
85
|
* @param jobName - Name of the job whose dynamic fields to evaluate
|
|
31
86
|
* @param event - Normalized event envelope — same shape every dynamic-function call site receives.
|
|
32
87
|
* @param flags - Which fields are dynamic and need evaluation
|
|
33
88
|
* @param timeoutMs - Timeout per dynamic function call (default 60_000ms)
|
|
89
|
+
* @param filterInput - Source tree + diff the workflow's `filter` reads. Required when `flags.hasFilter`.
|
|
34
90
|
*/
|
|
35
91
|
export declare function evaluateDynamicFields(workflow: Workflow, jobName: string, event: Record<string, unknown>, flags: {
|
|
36
92
|
dynamicContext: boolean;
|
|
37
93
|
dynamicEnv: boolean;
|
|
38
94
|
dynamicConcurrencyGroup: boolean;
|
|
39
95
|
dynamicMatrix?: boolean;
|
|
40
|
-
|
|
96
|
+
hasFilter?: boolean;
|
|
97
|
+
}, timeoutMs?: number, filterInput?: FilterEvalInput): Promise<InitResult>;
|
|
41
98
|
//# sourceMappingURL=init-runner.d.ts.map
|
|
@@ -1,7 +1,70 @@
|
|
|
1
1
|
import type { AgentToOrchestratorMessage, JobDispatch } from '@kici-dev/engine';
|
|
2
|
+
import type { LogStream } from '@kici-dev/engine';
|
|
2
3
|
import type { AppConfig } from '../config.js';
|
|
4
|
+
import { type FilterEvalInput } from './init-runner.js';
|
|
3
5
|
import { buildNeedsContext } from '@kici-dev/sdk';
|
|
6
|
+
import type { $ as Shell } from 'zx';
|
|
4
7
|
import type { CacheRequestIpc, CacheResponseIpc, ProvenanceRequestIpc, ProvenanceResponseIpc, ArtifactRequestIpc, ArtifactResponseIpc, StepApprovalRequestIpc, StepApprovalResolvedIpc } from './sandbox/index.js';
|
|
8
|
+
import { BetweenJobsController } from './between-jobs-controller.js';
|
|
9
|
+
/**
|
|
10
|
+
* Materialize the source tree a non-global workflow's `filter` reads through
|
|
11
|
+
* `ctx.sourceRepo.path`.
|
|
12
|
+
*
|
|
13
|
+
* An init or dynamic-eval job normally restores only `.kici/` from the cached
|
|
14
|
+
* source tarball — enough to import the workflow module, but a directory with no
|
|
15
|
+
* repo in it. A filter that reads a file or shells out against that path would
|
|
16
|
+
* get a confidently wrong answer, and `changedFiles` could not be computed at
|
|
17
|
+
* all, so a filter-bearing job clones the source repo into a sibling directory.
|
|
18
|
+
*
|
|
19
|
+
* When no tarball was attached the job already cloned the whole repo into
|
|
20
|
+
* `workDir`, and that clone is reused rather than duplicated — including the
|
|
21
|
+
* local working-tree case, where there is no repo url and `workDir` IS the tree.
|
|
22
|
+
*
|
|
23
|
+
* A tarball with no repo url is the one combination that cannot be honoured:
|
|
24
|
+
* `workDir` holds `.kici/` alone and there is nothing to clone from. Returning it
|
|
25
|
+
* would hand the filter a directory in which every path test answers "absent" —
|
|
26
|
+
* the exact silent lie this function exists to prevent — so it throws instead.
|
|
27
|
+
*/
|
|
28
|
+
export declare function ensureFilterSourceDir(dispatch: JobDispatch, workDir: string): Promise<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Build the context a non-global workflow's `filter` is evaluated against.
|
|
31
|
+
*
|
|
32
|
+
* `sourceRepo` and `workflowRepo` are the same repo — that is what "non-global"
|
|
33
|
+
* means — so both carry the same identifier, path, ref, and sha. The zx shell is
|
|
34
|
+
* rooted at the source tree and streams into the evaluating step's log, matching
|
|
35
|
+
* what the global eval round hands its own filters.
|
|
36
|
+
*
|
|
37
|
+
* They are two distinct objects all the same. Being the same repo is a fact
|
|
38
|
+
* about their VALUES, not a licence to hand the author one object under two
|
|
39
|
+
* names: a filter that mutated `ctx.sourceRepo` would silently see
|
|
40
|
+
* `ctx.workflowRepo` change with it, which happens on no other path.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildInitFilterInput(dispatch: JobDispatch, event: Record<string, unknown>, workDir: string, emit: (line: string, stream: LogStream) => void): Promise<FilterEvalInput>;
|
|
43
|
+
/**
|
|
44
|
+
* Build the per-invocation zx `$` a global eval round hands to filters and
|
|
45
|
+
* generators, so a `await $\`…\`` inside one is visible in the eval step's log.
|
|
46
|
+
*
|
|
47
|
+
* **`env` is the LIVE `process.env` reference, never a spread.** A spread is a
|
|
48
|
+
* snapshot taken when the shell is built, which is before the round applies the
|
|
49
|
+
* seven `KICI_*` keys — so a filter that shells out (`$\`printenv
|
|
50
|
+
* KICI_SOURCE_REPO_PATH\``, or any subprocess inheriting env) would see nothing
|
|
51
|
+
* here while the sandbox re-evaluation's ambient `$` resolves `process.env`
|
|
52
|
+
* after `setupGlobalWorkflowEnv` has run and does see them. That is the same
|
|
53
|
+
* two-worlds determinism failure the cwd choice below exists to prevent, one
|
|
54
|
+
* layer down. Passing the live reference reproduces the ambient `$`'s own
|
|
55
|
+
* behaviour, which is what the sandbox uses.
|
|
56
|
+
*
|
|
57
|
+
* `verbose: true` + `makeStreamingZxLog` honors a per-call `quiet: true`, so a
|
|
58
|
+
* decrypted secret never leaks into the log.
|
|
59
|
+
*
|
|
60
|
+
* `emit` is a callback rather than the `LogStreamer` itself so the caller can
|
|
61
|
+
* route it through its own closed-guard: `LogStreamer.destroy()` sets no closed
|
|
62
|
+
* flag and `addLine` buffers unconditionally, so a subprocess line arriving
|
|
63
|
+
* after the step was reported would otherwise emit a `log.chunk` for a terminal
|
|
64
|
+
* step. That is the likeliest path for it — an orphaned candidate is usually
|
|
65
|
+
* orphaned *because* it is waiting on a subprocess.
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildEvalShell(cwd: string, emit: (line: string, stream: LogStream) => void): Promise<typeof Shell>;
|
|
5
68
|
/**
|
|
6
69
|
* Dependencies injected into JobRunner.
|
|
7
70
|
*/
|
|
@@ -10,10 +73,17 @@ export interface JobRunnerDeps {
|
|
|
10
73
|
send: (msg: AgentToOrchestratorMessage) => void;
|
|
11
74
|
/** Agent config */
|
|
12
75
|
config: AppConfig;
|
|
76
|
+
/**
|
|
77
|
+
* Supervisor-owned between-jobs phase, run after every job to reap the
|
|
78
|
+
* process tree, re-run declared cleanup out-of-band, delete the workdir, and
|
|
79
|
+
* run the operator reset command. Optional so unit harnesses can omit it.
|
|
80
|
+
*/
|
|
81
|
+
betweenJobsController?: BetweenJobsController;
|
|
13
82
|
/** Request a pre-signed S3 upload URL from the orchestrator via WS request-response. */
|
|
14
83
|
requestUploadUrl: (jobId: string, cacheType: 'source' | 'deps', key: {
|
|
15
84
|
contentHash?: string;
|
|
16
85
|
lockfileHash?: string;
|
|
86
|
+
depsHash?: string;
|
|
17
87
|
platform: string;
|
|
18
88
|
arch: string;
|
|
19
89
|
}) => Promise<string>;
|
|
@@ -182,6 +252,8 @@ export declare class JobRunner {
|
|
|
182
252
|
private readonly _sendRunEvent;
|
|
183
253
|
private readonly _sendConcurrencyReport;
|
|
184
254
|
private readonly _sendApiRequest?;
|
|
255
|
+
/** Live git credentials per job, so the sandbox can reach the grant table. */
|
|
256
|
+
private readonly jobGitCredentials;
|
|
185
257
|
private readonly _requestUserCache?;
|
|
186
258
|
private readonly _relayProvenance?;
|
|
187
259
|
private readonly _requestUserArtifact?;
|
|
@@ -190,6 +262,15 @@ export declare class JobRunner {
|
|
|
190
262
|
readonly activeJobs: Map<string, ActiveJob>;
|
|
191
263
|
/** Active sandbox for the current job (used for abort). */
|
|
192
264
|
private activeSandbox;
|
|
265
|
+
/** Supervisor-owned between-jobs phase (reap / cleanup re-run / reset). */
|
|
266
|
+
private readonly betweenJobsController?;
|
|
267
|
+
/**
|
|
268
|
+
* Facts about the just-finished standard job, captured before sandbox
|
|
269
|
+
* teardown so the between-jobs controller (run in `execute`'s `.finally`) can
|
|
270
|
+
* reach them. Null for special job types (init / build / dynamic), which run
|
|
271
|
+
* no sandbox — the controller then only deletes the workdir and resets.
|
|
272
|
+
*/
|
|
273
|
+
private betweenJobsFacts;
|
|
193
274
|
constructor(deps: JobRunnerDeps);
|
|
194
275
|
/**
|
|
195
276
|
* Execute a dispatched job through its full lifecycle.
|
|
@@ -198,6 +279,23 @@ export declare class JobRunner {
|
|
|
198
279
|
* reports status, and cleans up.
|
|
199
280
|
*/
|
|
200
281
|
execute(dispatch: JobDispatch): Promise<void>;
|
|
282
|
+
/**
|
|
283
|
+
* Stand up per-job git credentials, or `undefined` when the agent has no
|
|
284
|
+
* orchestrator relay (unit harnesses, offline local runs).
|
|
285
|
+
*
|
|
286
|
+
* A failure here must not fail the job: without a helper, git falls back to
|
|
287
|
+
* its own mechanisms exactly as it did before this existed. The job simply
|
|
288
|
+
* cannot push.
|
|
289
|
+
*/
|
|
290
|
+
private startGitCredentials;
|
|
291
|
+
/**
|
|
292
|
+
* Run the supervisor-owned between-jobs phase after a job finishes. Delegates
|
|
293
|
+
* to the injected `BetweenJobsController` (out-of-band cleanup → reap →
|
|
294
|
+
* workdir delete → operator reset) with facts captured before sandbox
|
|
295
|
+
* teardown. When no controller is wired (unit harnesses) it just deletes the
|
|
296
|
+
* workdir, preserving the historical behavior. Never throws.
|
|
297
|
+
*/
|
|
298
|
+
private runBetweenJobsPhase;
|
|
201
299
|
/**
|
|
202
300
|
* Cancel a running job by signaling its abort controller
|
|
203
301
|
* and aborting the active sandbox.
|
|
@@ -276,6 +374,18 @@ export declare class JobRunner {
|
|
|
276
374
|
* uncommitted changes).
|
|
277
375
|
*/
|
|
278
376
|
private cloneAndApplyOverlay;
|
|
377
|
+
/**
|
|
378
|
+
* Materialize the init job's workflow source into `workDir`. A test run ships
|
|
379
|
+
* its full working tree as an encrypted overlay tarball (`fullRepo`) rather
|
|
380
|
+
* than a git repo, so skip the clone and let the overlay populate the
|
|
381
|
+
* workspace — the same handling the normal execution-job path uses. Otherwise
|
|
382
|
+
* restore from the cached tarball if present, else clone. In every case, apply
|
|
383
|
+
* an attached overlay tarball afterward (test runs with uncommitted changes;
|
|
384
|
+
* for a fullRepo run this is what actually populates the workspace, so the
|
|
385
|
+
* init job resolves a dynamic context against the real source tree instead of
|
|
386
|
+
* an empty directory).
|
|
387
|
+
*/
|
|
388
|
+
private materializeInitJobSource;
|
|
279
389
|
/**
|
|
280
390
|
* Phase 2 of build: install dependencies locally if needed for the build,
|
|
281
391
|
* and (when the orchestrator has flagged the dep cache as stale) pack
|
|
@@ -288,6 +398,30 @@ export declare class JobRunner {
|
|
|
288
398
|
* it to the source cache.
|
|
289
399
|
*/
|
|
290
400
|
private packAndUploadSource;
|
|
401
|
+
/**
|
|
402
|
+
* Clone both repos for a global eval round and materialize the workflow
|
|
403
|
+
* repo's dependencies, mirroring the sandbox's own dual-clone: the workflow
|
|
404
|
+
* repo under `<workDir>/workflow`, the source repo under `<workDir>/source`.
|
|
405
|
+
*
|
|
406
|
+
* `.kici/` lives in the WORKFLOW repo for a global workflow, so deps and the
|
|
407
|
+
* scratch-dir git exclude both apply to that checkout, never the source one.
|
|
408
|
+
*/
|
|
409
|
+
private checkoutForGlobalEvalRound;
|
|
410
|
+
/**
|
|
411
|
+
* Handle a pre-run global eval round.
|
|
412
|
+
*
|
|
413
|
+
* The round runs once per (event × workflow repo) BEFORE any run row exists:
|
|
414
|
+
* it checks out the workflow repo and the source repo, then runs each
|
|
415
|
+
* candidate global workflow's `filter` and — for a survivor — its
|
|
416
|
+
* `DynamicJobFn`s, so the orchestrator learns which workflows apply to this
|
|
417
|
+
* source repo and which jobs each one generates.
|
|
418
|
+
*
|
|
419
|
+
* A candidate that fails is reported indeterminate inside the result, not as
|
|
420
|
+
* a job failure: the round carries several unrelated org-wide workflows, and
|
|
421
|
+
* one broken filter must not suppress the rest. The job itself fails only
|
|
422
|
+
* when the checkout or the round machinery breaks.
|
|
423
|
+
*/
|
|
424
|
+
private handleGlobalEvalRound;
|
|
291
425
|
/**
|
|
292
426
|
* Handle an init-only job.
|
|
293
427
|
*
|
|
@@ -309,6 +443,18 @@ export declare class JobRunner {
|
|
|
309
443
|
* and sends the result back to the orchestrator.
|
|
310
444
|
*/
|
|
311
445
|
private handleDynamicJobFn;
|
|
446
|
+
/**
|
|
447
|
+
* Build the job's container image when it declared a Dockerfile, and return
|
|
448
|
+
* the tag the sandbox must run.
|
|
449
|
+
*
|
|
450
|
+
* Returns `undefined` for every other job — one with no container, or one
|
|
451
|
+
* naming a finalized image — which is the common case and costs nothing.
|
|
452
|
+
*
|
|
453
|
+
* Extracted from `setupSandboxForExecution` so that function stays inside the
|
|
454
|
+
* 200-line ceiling, and so the build's streamer lifecycle is visibly bounded
|
|
455
|
+
* by one `finally`.
|
|
456
|
+
*/
|
|
457
|
+
private buildJobImageIfDeclared;
|
|
312
458
|
/**
|
|
313
459
|
* Create the appropriate sandbox backend based on execution mode.
|
|
314
460
|
*/
|
|
@@ -31,6 +31,11 @@ interface BareMetalSandboxOptions {
|
|
|
31
31
|
sandboxNetwork?: 'isolated' | 'host';
|
|
32
32
|
/** Pre-sanitized environment variables (system allowlist + user env). */
|
|
33
33
|
env: Record<string, string>;
|
|
34
|
+
/**
|
|
35
|
+
* When true (and bwrap is off), spawn the runner in its own process group so
|
|
36
|
+
* the between-jobs phase can reap a backgrounded daemon. Default true.
|
|
37
|
+
*/
|
|
38
|
+
orphanCleanup?: boolean;
|
|
34
39
|
}
|
|
35
40
|
/**
|
|
36
41
|
* Bare-metal execution sandbox implementation.
|
|
@@ -43,8 +48,10 @@ export declare class BareMetalSandbox implements ExecutionSandbox {
|
|
|
43
48
|
private readonly useBwrap;
|
|
44
49
|
private readonly sandboxNetwork;
|
|
45
50
|
private readonly env;
|
|
51
|
+
private readonly orphanCleanup;
|
|
46
52
|
private runner;
|
|
47
53
|
private workDir;
|
|
54
|
+
private lastOptions;
|
|
48
55
|
constructor(options: BareMetalSandboxOptions);
|
|
49
56
|
/**
|
|
50
57
|
* Validate that the runner path exists and bwrap is available (if needed).
|
|
@@ -61,9 +68,26 @@ export declare class BareMetalSandbox implements ExecutionSandbox {
|
|
|
61
68
|
*/
|
|
62
69
|
abort(): Promise<void>;
|
|
63
70
|
/**
|
|
64
|
-
* Clean up the child process if still running.
|
|
71
|
+
* Clean up the child process if still running. The handle reference is kept
|
|
72
|
+
* (not nulled) so the between-jobs phase can still read `completionHooksRan` /
|
|
73
|
+
* `declaresCleanup` and reap the process group after teardown — the group
|
|
74
|
+
* survives the single child's death. The next `executeJob` overwrites it.
|
|
65
75
|
*/
|
|
66
76
|
teardown(): Promise<void>;
|
|
77
|
+
/** Whether the runner signalled its completion hooks ran. */
|
|
78
|
+
get completionHooksRan(): boolean;
|
|
79
|
+
/** Whether the job declared an onFailure / cleanup hook. */
|
|
80
|
+
get declaresCleanup(): boolean;
|
|
81
|
+
/** Reap the finished job's process group. */
|
|
82
|
+
reap(): Promise<number>;
|
|
83
|
+
/**
|
|
84
|
+
* Re-run the finished job's declared cleanup / onFailure hooks against the
|
|
85
|
+
* preserved workdir, in a fresh cleanup-only child. Reuses the last job's
|
|
86
|
+
* dispatch + orchestrator relays but suppresses step/log callbacks (the
|
|
87
|
+
* original job already reported and its log streamers are gone). Rejects when
|
|
88
|
+
* the cleanup-only child fails so the caller can time it out.
|
|
89
|
+
*/
|
|
90
|
+
runCleanupOnly(workDir: string, signal: AbortSignal): Promise<void>;
|
|
67
91
|
}
|
|
68
92
|
export {};
|
|
69
93
|
//# sourceMappingURL=bare-metal-sandbox.d.ts.map
|
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
* - Agent-internal credentials (KICI_*, KICI_DATABASE_URL, etc.) NEVER enter the container
|
|
13
13
|
* - IPC uses demuxed Docker stream with JSON-line parsing on stdout
|
|
14
14
|
*
|
|
15
|
-
* The container image
|
|
16
|
-
*
|
|
15
|
+
* The container image does NOT need Node.js or git. KiCI provisions its own
|
|
16
|
+
* runtime — a pinned, official glibc-2.17 Node plus the runner bundle, mounted
|
|
17
|
+
* read-only at /opt/kici — and launches the runner with THAT node. The image
|
|
18
|
+
* needs only a glibc and a shell, which the preflight asserts before the
|
|
19
|
+
* container is created.
|
|
17
20
|
*/
|
|
18
21
|
import Docker from 'dockerode';
|
|
19
22
|
import type { ExecutionSandbox, SandboxSetupOptions, JobExecutionOptions, JobExecutionResult } from './types.js';
|
|
@@ -27,6 +30,51 @@ interface ContainerSandboxOptions {
|
|
|
27
30
|
runnerPath: string;
|
|
28
31
|
/** Mount target inside container (default: /opt/kici/workflow-runner.js). */
|
|
29
32
|
runnerMountPath?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Pre-provisioned KiCI Node tree (the directory whose `bin/node` is the
|
|
35
|
+
* injected runtime), bind-mounted read-only at `/opt/kici/node`. A HOST
|
|
36
|
+
* path, or the name of a volume already holding the tree.
|
|
37
|
+
*
|
|
38
|
+
* When supplied, the runner launches on THAT node and the image needs no
|
|
39
|
+
* Node of its own. Wins over `runtimeImage` — a caller that already has the
|
|
40
|
+
* tree should not pay a materialization to get the same one.
|
|
41
|
+
*/
|
|
42
|
+
runtimeNodePath?: string;
|
|
43
|
+
/**
|
|
44
|
+
* KiCI agent image carrying `/opt/kici`, from which the Node tree is
|
|
45
|
+
* materialized into a named volume during setup.
|
|
46
|
+
*
|
|
47
|
+
* This is how an agent nesting a job container gets a runtime: a bind mount
|
|
48
|
+
* needs a HOST path, and the agent may itself be containerized, so it cannot
|
|
49
|
+
* assume `/opt/kici` exists on the host filesystem. Copying the tree out of
|
|
50
|
+
* the image into a volume once, then mounting that volume, works either way.
|
|
51
|
+
*
|
|
52
|
+
* Absent (and no `runtimeNodePath`) means no injection: the runner falls
|
|
53
|
+
* back to the image's own `node`, which is the historical contract and still
|
|
54
|
+
* correct for an image that ships one. Both modes are correct; the fallback
|
|
55
|
+
* is not a workaround for a missing mount but the mode a caller that has no
|
|
56
|
+
* runtime to inject is in.
|
|
57
|
+
*/
|
|
58
|
+
runtimeImage?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Tag this sandbox's image was BUILT under, when the job declared a
|
|
61
|
+
* Dockerfile rather than naming an image.
|
|
62
|
+
*
|
|
63
|
+
* Present only for a built image, and removed at teardown — a tag per run
|
|
64
|
+
* would otherwise accumulate on the host forever. Removing the tag leaves the
|
|
65
|
+
* layer cache untouched, which is what makes the next build fast, so this
|
|
66
|
+
* costs nothing but the name.
|
|
67
|
+
*/
|
|
68
|
+
buildTag?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Registry credentials for pulling `image`, already resolved by the
|
|
71
|
+
* orchestrator. Absent means an anonymous pull.
|
|
72
|
+
*/
|
|
73
|
+
registryAuth?: {
|
|
74
|
+
username: string;
|
|
75
|
+
password: string;
|
|
76
|
+
serveraddress: string;
|
|
77
|
+
};
|
|
30
78
|
/**
|
|
31
79
|
* Path to the pure-JS container loader-hook bundle on the HOST (bind-mounted
|
|
32
80
|
* read-only). Defaults to `container-ts-loader-hook.js` next to `runnerPath`.
|
|
@@ -51,6 +99,17 @@ export declare class ContainerSandbox implements ExecutionSandbox {
|
|
|
51
99
|
private readonly docker;
|
|
52
100
|
private readonly image;
|
|
53
101
|
private readonly runnerPath;
|
|
102
|
+
private readonly runtimeNodePath;
|
|
103
|
+
private readonly runtimeImage;
|
|
104
|
+
private readonly buildTag;
|
|
105
|
+
/**
|
|
106
|
+
* The runtime actually injected into this job's container — the configured
|
|
107
|
+
* path, or the volume materialized during setup. Resolved once in setup()
|
|
108
|
+
* because materialization needs the container runtime, and read by both the
|
|
109
|
+
* bind list and the runner launch.
|
|
110
|
+
*/
|
|
111
|
+
private resolvedRuntimeNode;
|
|
112
|
+
private readonly registryAuth;
|
|
54
113
|
private readonly runnerMountPath;
|
|
55
114
|
/** Host path to the pure-JS container loader-hook bundle (bind-mounted :ro). */
|
|
56
115
|
private readonly hookHostPath;
|
|
@@ -81,7 +140,35 @@ export declare class ContainerSandbox implements ExecutionSandbox {
|
|
|
81
140
|
* private images pre-pulled with registry auth stay working) skip the pull.
|
|
82
141
|
*/
|
|
83
142
|
private ensureImagePresent;
|
|
143
|
+
/**
|
|
144
|
+
* Resolve the Node tree to inject into the job container, materializing it
|
|
145
|
+
* when only an image was configured.
|
|
146
|
+
*
|
|
147
|
+
* A configured path wins: a caller that already provisioned the tree should
|
|
148
|
+
* not pay a copy to arrive at the same one. Returning `undefined` is a real
|
|
149
|
+
* outcome, not a failure — an agent with no runtime source runs the job on
|
|
150
|
+
* the image's own `node`, which is what a `node:*` image was always doing.
|
|
151
|
+
*
|
|
152
|
+
* A materialization that FAILS is not softened into that outcome. Continuing
|
|
153
|
+
* would start the job against an image the operator never claimed ships Node,
|
|
154
|
+
* and the resulting "node: not found" says nothing about the runtime that was
|
|
155
|
+
* supposed to be there.
|
|
156
|
+
*/
|
|
157
|
+
private resolveRuntimeNode;
|
|
84
158
|
setup(options: SandboxSetupOptions): Promise<void>;
|
|
159
|
+
/**
|
|
160
|
+
* Populate the container's `/workspace` volume from the host working tree.
|
|
161
|
+
*
|
|
162
|
+
* `/workspace` is a container-owned anonymous volume rather than a host bind,
|
|
163
|
+
* which is what dissolves the host-uid vs container-uid conflict once
|
|
164
|
+
* `CapDrop: ['ALL']` removes CAP_DAC_OVERRIDE — so the tree is streamed in as
|
|
165
|
+
* a tar rather than mounted.
|
|
166
|
+
*
|
|
167
|
+
* A failure here is fatal on purpose. Swallowing it would start the job
|
|
168
|
+
* against an EMPTY workspace, which surfaces as a baffling "file not found"
|
|
169
|
+
* in whichever step happens to touch the repo first.
|
|
170
|
+
*/
|
|
171
|
+
private copyWorkspaceIn;
|
|
85
172
|
/**
|
|
86
173
|
* Build the container's read-only bind list.
|
|
87
174
|
*
|
|
@@ -133,6 +220,14 @@ export declare class ContainerSandbox implements ExecutionSandbox {
|
|
|
133
220
|
*/
|
|
134
221
|
private buildExecutionResult;
|
|
135
222
|
abort(): Promise<void>;
|
|
223
|
+
/**
|
|
224
|
+
* Drop the tag a `container.dockerfile` build produced.
|
|
225
|
+
*
|
|
226
|
+
* Best-effort: teardown must not fail a job that already finished. The LAYER
|
|
227
|
+
* cache — the thing that makes the next build fast — is not a tag and is
|
|
228
|
+
* untouched by this.
|
|
229
|
+
*/
|
|
230
|
+
private reclaimBuiltImage;
|
|
136
231
|
teardown(): Promise<void>;
|
|
137
232
|
/**
|
|
138
233
|
* Send the execute request to the workflow runner via the exec's stdin.
|
|
@@ -9,6 +9,21 @@ import { type ChildProcess } from 'node:child_process';
|
|
|
9
9
|
import type { JobDispatch } from '@kici-dev/engine';
|
|
10
10
|
import type { JobExecutionOptions, JobExecutionResult } from './types.js';
|
|
11
11
|
import type { JobExecutionRequest } from './ipc-protocol.js';
|
|
12
|
+
/**
|
|
13
|
+
* Best-effort SIGKILL/SIGTERM of an entire process group led by `pid`.
|
|
14
|
+
* `process.kill(-pid, signal)` targets the group whose leader is `pid` (the
|
|
15
|
+
* child was spawned `detached`, so its pid is its group id). Returns 1 when the
|
|
16
|
+
* group was signalled, 0 when it was already gone (ESRCH) or the pid is invalid.
|
|
17
|
+
* We cannot cheaply count members, so the caller treats a non-zero return as
|
|
18
|
+
* "reap attempted".
|
|
19
|
+
*/
|
|
20
|
+
export declare function killProcessGroup(pid: number | undefined, signal: NodeJS.Signals): number;
|
|
21
|
+
/**
|
|
22
|
+
* SIGTERM the job's process group, wait a short grace, then SIGKILL. No-op
|
|
23
|
+
* (returns 0) when the child was not spawned detached or has no pid. Returns the
|
|
24
|
+
* count of reap attempts that signalled a live group (0, 1, or 2).
|
|
25
|
+
*/
|
|
26
|
+
export declare function reapGroup(pid: number | undefined, detached: boolean, killFn?: (p: number | undefined, s: NodeJS.Signals) => number, sleepMs?: number): Promise<number>;
|
|
12
27
|
/** Options for creating a fork-based runner. */
|
|
13
28
|
interface ForkRunnerOptions {
|
|
14
29
|
/** Absolute path to the compiled workflow-runner.js. */
|
|
@@ -39,6 +54,18 @@ interface ForkRunnerOptions {
|
|
|
39
54
|
* Defaults to 30_000 (30 seconds).
|
|
40
55
|
*/
|
|
41
56
|
maxGracePeriodMs?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Spawn the runner child `detached` so it leads its own process group. Set by
|
|
59
|
+
* the bare-metal backend when orphan cleanup is on and bwrap is off, so the
|
|
60
|
+
* between-jobs phase can reap a backgrounded daemon that reparented to init.
|
|
61
|
+
* Ignored under bwrap (its PID namespace already contains the tree).
|
|
62
|
+
*/
|
|
63
|
+
detachProcessGroup?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Between-jobs out-of-band cleanup re-run: the runner reuses the preserved
|
|
66
|
+
* workdir and runs only the job's declared cleanup / onFailure hooks.
|
|
67
|
+
*/
|
|
68
|
+
cleanupOnly?: boolean;
|
|
42
69
|
}
|
|
43
70
|
/** State of a running fork-based child process. */
|
|
44
71
|
export interface ForkRunnerHandle {
|
|
@@ -59,13 +86,40 @@ export interface ForkRunnerHandle {
|
|
|
59
86
|
* Capped by the agent's maxGracePeriodMs.
|
|
60
87
|
*/
|
|
61
88
|
cancel: (force: boolean, gracePeriodMs?: number) => void;
|
|
89
|
+
/**
|
|
90
|
+
* Whether this runner leads its own detached process group (bare-metal,
|
|
91
|
+
* non-bwrap, orphan cleanup on). Drives whether `reap()` does anything.
|
|
92
|
+
*/
|
|
93
|
+
detached: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* True once the runner emitted `completion-hooks-done` (its onSuccess /
|
|
96
|
+
* onFailure / cleanup hooks ran). Stays false when the child exits before
|
|
97
|
+
* signalling, which is the between-jobs phase's cue to re-run declared
|
|
98
|
+
* cleanup out-of-band.
|
|
99
|
+
*/
|
|
100
|
+
completionHooksRan: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* True once the runner reported (via `hooks-declared`) that the job declares
|
|
103
|
+
* an `onFailure` / `cleanup` hook. Recorded early so it survives a later hard
|
|
104
|
+
* kill; gates whether the out-of-band cleanup re-run is attempted.
|
|
105
|
+
*/
|
|
106
|
+
declaresCleanup: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* SIGTERM → grace → SIGKILL the runner's process group, reaping any daemon it
|
|
109
|
+
* backgrounded. No-op (resolves 0) when the runner is not detached; returns
|
|
110
|
+
* the number of reap attempts that signalled a live group.
|
|
111
|
+
*/
|
|
112
|
+
reap: () => Promise<number>;
|
|
62
113
|
}
|
|
63
114
|
/**
|
|
64
115
|
* Build a JobExecutionRequest from a JobDispatch.
|
|
65
116
|
*
|
|
66
117
|
* Maps orchestrator dispatch fields to the subset needed by the workflow runner.
|
|
67
118
|
*/
|
|
68
|
-
export declare function buildRequest(dispatch: JobDispatch, workDir: string
|
|
119
|
+
export declare function buildRequest(dispatch: JobDispatch, workDir: string, extra?: {
|
|
120
|
+
cleanupOnly?: boolean;
|
|
121
|
+
credentialHelperPath?: string;
|
|
122
|
+
}): JobExecutionRequest;
|
|
69
123
|
/**
|
|
70
124
|
* Build bubblewrap (bwrap) arguments for namespace isolation.
|
|
71
125
|
*
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail a container job on an unusable image BEFORE the job starts.
|
|
3
|
+
*
|
|
4
|
+
* KiCI injects its own runtime (a pinned official glibc-2.17 Node) into the
|
|
5
|
+
* customer's image, so the image needs only a glibc and a shell. When it has
|
|
6
|
+
* neither, the container runtime's own error is close to useless — a musl image
|
|
7
|
+
* reports
|
|
8
|
+
*
|
|
9
|
+
* exec container process (missing dynamic library?) `/opt/kici/node/bin/node`:
|
|
10
|
+
* No such file or directory
|
|
11
|
+
*
|
|
12
|
+
* which names a file that plainly exists and says nothing about musl. The
|
|
13
|
+
* preflight turns that into a sentence an author can act on, and it fires
|
|
14
|
+
* before any step runs rather than partway through a job.
|
|
15
|
+
*
|
|
16
|
+
* glibc-only is the deliberate scope of this version; a musl runtime variant is
|
|
17
|
+
* a documented follow-up.
|
|
18
|
+
*/
|
|
19
|
+
import type Docker from 'dockerode';
|
|
20
|
+
/** What an image's rootfs says about whether we can run our runtime in it. */
|
|
21
|
+
export type ImageLibc = 'glibc' | 'musl' | 'static' | 'no-shell';
|
|
22
|
+
/** Every path the preflight stats in the image. */
|
|
23
|
+
export declare const PROBE_PATHS: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Classify an image from the subset of {@link PROBE_PATHS} that exist in it.
|
|
26
|
+
*
|
|
27
|
+
* Pure, so the decision table is testable without a container runtime.
|
|
28
|
+
*/
|
|
29
|
+
export declare function classifyImageLibc(presentPaths: readonly string[]): ImageLibc;
|
|
30
|
+
/**
|
|
31
|
+
* Throw unless `image` can host the injected runtime.
|
|
32
|
+
*
|
|
33
|
+
* Stats the probe paths through a created-but-never-started container, so a
|
|
34
|
+
* shell-less or musl image is diagnosed without executing anything in it —
|
|
35
|
+
* running a probe command would fail for the very reason we are testing for.
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertImageRunnable(docker: Docker, image: string): Promise<void>;
|
|
38
|
+
//# sourceMappingURL=image-preflight.d.ts.map
|