start-command 0.32.1 → 0.34.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/CHANGELOG.md +27 -0
- package/README.md +3 -0
- package/eslint.config.mjs +8 -0
- package/package.json +7 -3
- package/src/bin/cli.js +32 -137
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/detached-finalize.js +281 -0
- package/src/lib/docker-cleanup.js +240 -39
- package/src/lib/docker-post-mortem.js +289 -0
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +211 -0
- package/src/lib/execution-resume.js +455 -0
- package/src/lib/execution-store.js +93 -6
- package/src/lib/exit-reason.js +264 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +66 -1
- package/src/lib/isolation.js +28 -15
- package/src/lib/query-commands.js +202 -0
- package/src/lib/screen-isolation.js +2 -1
- package/src/lib/session-probe.js +121 -0
- package/src/lib/shell-utils.js +164 -10
- package/src/lib/status-formatter.js +233 -69
- package/src/lib/usage.js +13 -1
- package/src/lib/user-manager.js +18 -1
- package/test/args-parser-attach-resume.js +159 -0
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/check-file-size.js +91 -0
- package/test/ci-workflow-invariants.js +191 -0
- package/test/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +57 -2
- package/test/exit-reason.js +209 -0
- package/test/failure-handler.js +107 -0
- package/test/load-command-stream.mjs +83 -0
- package/test/regression-162.js +320 -0
- package/test/regression-164.js +308 -0
- package/test/regression-165.js +310 -0
- package/test/regression-170.js +321 -0
- package/test/regression-171.js +562 -0
- package/test/regression-84.js +8 -3
- package/test/release-name.mjs +57 -0
- package/test/session-probe.js +150 -0
- package/test/user-manager.js +23 -0
package/src/lib/args-parser.js
CHANGED
|
@@ -28,11 +28,15 @@
|
|
|
28
28
|
* --use-command-stream Use command-stream library for command execution (experimental)
|
|
29
29
|
* --verbose Enable verbose/debug output (sets START_VERBOSE=1)
|
|
30
30
|
* --status <uuid> Show status of a previous command execution by UUID
|
|
31
|
-
* --list
|
|
31
|
+
* --list [--running] List all (or only running) tracked command executions
|
|
32
32
|
* --upload-log <uuid-or-session> Upload the stored log for a tracked execution
|
|
33
33
|
* --output-format <format> Output format for status/list (links-notation, json, text)
|
|
34
34
|
* --stop <uuid-or-session-name> Ask a detached execution to stop gracefully
|
|
35
35
|
* --terminate <uuid-or-session-name> Terminate a detached execution immediately
|
|
36
|
+
* --attach <uuid-or-session-name> Attach to a running detached session terminal
|
|
37
|
+
* --read-only Follow --attach output without forwarding stdin
|
|
38
|
+
* --resume <uuid-or-session> [-- cmd] Resume a stored execution (optionally with a new command)
|
|
39
|
+
* --resume-all Re-attach or reconcile every execution still marked running
|
|
36
40
|
* --cleanup Clean up stale "executing" records (processes that crashed or were killed)
|
|
37
41
|
* --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
|
|
38
42
|
*/
|
|
@@ -40,6 +44,13 @@
|
|
|
40
44
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
41
45
|
const dockerNetworkOptions = require('./docker-network-options');
|
|
42
46
|
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
47
|
+
const { buildCommandString } = require('./shell-utils');
|
|
48
|
+
const {
|
|
49
|
+
VALID_OUTPUT_FORMATS,
|
|
50
|
+
createQueryOptionDefaults,
|
|
51
|
+
parseQueryOption,
|
|
52
|
+
validateQueryOptions,
|
|
53
|
+
} = require('./args-parser-queries');
|
|
43
54
|
|
|
44
55
|
// Debug mode from environment
|
|
45
56
|
const DEBUG =
|
|
@@ -60,15 +71,6 @@ const VALID_SHELLS = ['auto', 'bash', 'zsh', 'sh'];
|
|
|
60
71
|
*/
|
|
61
72
|
const MAX_ISOLATION_DEPTH = 7;
|
|
62
73
|
|
|
63
|
-
/**
|
|
64
|
-
* Valid output formats for --status
|
|
65
|
-
*/
|
|
66
|
-
const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
|
|
67
|
-
|
|
68
|
-
function hasValue(value) {
|
|
69
|
-
return value !== null && value !== undefined;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
74
|
/**
|
|
73
75
|
* UUID v4 regex pattern for validation
|
|
74
76
|
*/
|
|
@@ -89,18 +91,25 @@ function isValidUUID(str) {
|
|
|
89
91
|
* @returns {string} A new UUID v4 string
|
|
90
92
|
*/
|
|
91
93
|
function generateUUID() {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const crypto = require('crypto');
|
|
94
|
+
const crypto = require('crypto');
|
|
95
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
95
96
|
return crypto.randomUUID();
|
|
96
|
-
} catch {
|
|
97
|
-
// Fallback for environments without crypto.randomUUID
|
|
98
|
-
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
99
|
-
const r = (Math.random() * 16) | 0;
|
|
100
|
-
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
101
|
-
return v.toString(16);
|
|
102
|
-
});
|
|
103
97
|
}
|
|
98
|
+
|
|
99
|
+
// Fallback for runtimes without crypto.randomUUID (Node < 14.17). Still a
|
|
100
|
+
// CSPRNG: session ids name isolation sessions and users, so a predictable
|
|
101
|
+
// generator is a security bug, not a cosmetic one (CodeQL js/insecure-randomness).
|
|
102
|
+
const bytes = crypto.randomBytes(16);
|
|
103
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
|
104
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
|
105
|
+
const hex = bytes.toString('hex');
|
|
106
|
+
return [
|
|
107
|
+
hex.slice(0, 8),
|
|
108
|
+
hex.slice(8, 12),
|
|
109
|
+
hex.slice(12, 16),
|
|
110
|
+
hex.slice(16, 20),
|
|
111
|
+
hex.slice(20),
|
|
112
|
+
].join('-');
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/**
|
|
@@ -164,6 +173,8 @@ function parseEndpointValue(value, options) {
|
|
|
164
173
|
* Parse command line arguments into wrapper options and command
|
|
165
174
|
* @param {string[]} args - Array of command line arguments
|
|
166
175
|
* @returns {{wrapperOptions: object, command: string, rawCommand: string[]}}
|
|
176
|
+
* `command` re-quotes each argv element so the inner shell sees the argument
|
|
177
|
+
* boundaries the user typed (issue #164); `rawCommand` keeps the raw argv.
|
|
167
178
|
*/
|
|
168
179
|
function parseArgs(args) {
|
|
169
180
|
const wrapperOptions = {
|
|
@@ -195,14 +206,7 @@ function parseArgs(args) {
|
|
|
195
206
|
keepContainerOnFail: false, // Keep docker container filesystem when command fails or OOM-kills
|
|
196
207
|
shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
|
|
197
208
|
useCommandStream: false, // Use command-stream library for command execution
|
|
198
|
-
|
|
199
|
-
list: false, // List all tracked execution records
|
|
200
|
-
uploadLog: null, // UUID/session name whose stored log should be uploaded
|
|
201
|
-
outputFormat: null, // Output format for status/list (links-notation, json, text)
|
|
202
|
-
stop: null, // UUID/session name to stop gracefully
|
|
203
|
-
terminate: null, // UUID/session name to terminate immediately
|
|
204
|
-
cleanup: false, // Clean up stale "executing" records
|
|
205
|
-
cleanupDryRun: false, // Show what would be cleaned without actually cleaning
|
|
209
|
+
...createQueryOptionDefaults(),
|
|
206
210
|
};
|
|
207
211
|
|
|
208
212
|
let commandArgs = [];
|
|
@@ -241,7 +245,7 @@ function parseArgs(args) {
|
|
|
241
245
|
|
|
242
246
|
return {
|
|
243
247
|
wrapperOptions,
|
|
244
|
-
command: commandArgs
|
|
248
|
+
command: buildCommandString(commandArgs),
|
|
245
249
|
rawCommand: commandArgs,
|
|
246
250
|
};
|
|
247
251
|
}
|
|
@@ -529,125 +533,10 @@ function parseOption(args, index, options) {
|
|
|
529
533
|
return 1;
|
|
530
534
|
}
|
|
531
535
|
|
|
532
|
-
// --status
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return 2;
|
|
537
|
-
} else {
|
|
538
|
-
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// --status=<value>
|
|
543
|
-
if (arg.startsWith('--status=')) {
|
|
544
|
-
const value = arg.slice('--status='.length);
|
|
545
|
-
if (!value) {
|
|
546
|
-
throw new Error(
|
|
547
|
-
`Option --status requires a UUID or session name argument`
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
options.status = value;
|
|
551
|
-
return 1;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// --upload-log <uuid-or-session-name>
|
|
555
|
-
if (arg === '--upload-log') {
|
|
556
|
-
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
557
|
-
options.uploadLog = args[index + 1];
|
|
558
|
-
return 2;
|
|
559
|
-
} else {
|
|
560
|
-
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// --upload-log=<value>
|
|
565
|
-
if (arg.startsWith('--upload-log=')) {
|
|
566
|
-
const value = arg.slice('--upload-log='.length);
|
|
567
|
-
if (!value) {
|
|
568
|
-
throw new Error(
|
|
569
|
-
`Option --upload-log requires a UUID or session name argument`
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
options.uploadLog = value;
|
|
573
|
-
return 1;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// --stop <uuid-or-session-name>
|
|
577
|
-
if (arg === '--stop') {
|
|
578
|
-
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
579
|
-
options.stop = args[index + 1];
|
|
580
|
-
return 2;
|
|
581
|
-
} else {
|
|
582
|
-
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// --stop=<value>
|
|
587
|
-
if (arg.startsWith('--stop=')) {
|
|
588
|
-
const value = arg.slice('--stop='.length);
|
|
589
|
-
if (!value) {
|
|
590
|
-
throw new Error(`Option --stop requires a UUID or session name argument`);
|
|
591
|
-
}
|
|
592
|
-
options.stop = value;
|
|
593
|
-
return 1;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// --terminate <uuid-or-session-name>
|
|
597
|
-
if (arg === '--terminate') {
|
|
598
|
-
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
599
|
-
options.terminate = args[index + 1];
|
|
600
|
-
return 2;
|
|
601
|
-
} else {
|
|
602
|
-
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// --terminate=<value>
|
|
607
|
-
if (arg.startsWith('--terminate=')) {
|
|
608
|
-
const value = arg.slice('--terminate='.length);
|
|
609
|
-
if (!value) {
|
|
610
|
-
throw new Error(
|
|
611
|
-
`Option --terminate requires a UUID or session name argument`
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
options.terminate = value;
|
|
615
|
-
return 1;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// --list
|
|
619
|
-
if (arg === '--list') {
|
|
620
|
-
options.list = true;
|
|
621
|
-
return 1;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// --output-format <format>
|
|
625
|
-
if (arg === '--output-format') {
|
|
626
|
-
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
627
|
-
options.outputFormat = args[index + 1].toLowerCase();
|
|
628
|
-
return 2;
|
|
629
|
-
} else {
|
|
630
|
-
throw new Error(`Option ${arg} requires a format argument`);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// --output-format=<value>
|
|
635
|
-
if (arg.startsWith('--output-format=')) {
|
|
636
|
-
options.outputFormat = arg.split('=')[1].toLowerCase();
|
|
637
|
-
return 1;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// --cleanup
|
|
641
|
-
if (arg === '--cleanup') {
|
|
642
|
-
options.cleanup = true;
|
|
643
|
-
return 1;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// --cleanup-dry-run
|
|
647
|
-
if (arg === '--cleanup-dry-run') {
|
|
648
|
-
options.cleanup = true;
|
|
649
|
-
options.cleanupDryRun = true;
|
|
650
|
-
return 1;
|
|
536
|
+
// Query/control options (--status, --list, --attach, --resume, ...)
|
|
537
|
+
const queryConsumed = parseQueryOption(args, index, options);
|
|
538
|
+
if (queryConsumed > 0) {
|
|
539
|
+
return queryConsumed;
|
|
651
540
|
}
|
|
652
541
|
|
|
653
542
|
// Not a recognized wrapper option
|
|
@@ -889,37 +778,7 @@ function validateOptions(options) {
|
|
|
889
778
|
throw new Error('--keep-user option is only valid with --isolated-user');
|
|
890
779
|
}
|
|
891
780
|
|
|
892
|
-
|
|
893
|
-
if (hasValue(options.outputFormat)) {
|
|
894
|
-
if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
|
|
895
|
-
throw new Error(
|
|
896
|
-
`Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Query/control modes are mutually exclusive
|
|
902
|
-
const queryModes = [
|
|
903
|
-
hasValue(options.status),
|
|
904
|
-
options.list,
|
|
905
|
-
hasValue(options.uploadLog),
|
|
906
|
-
hasValue(options.stop),
|
|
907
|
-
hasValue(options.terminate),
|
|
908
|
-
options.cleanup,
|
|
909
|
-
].filter(Boolean).length;
|
|
910
|
-
|
|
911
|
-
if (queryModes > 1) {
|
|
912
|
-
throw new Error(
|
|
913
|
-
'Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation'
|
|
914
|
-
);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// Output format is only valid with read-only query modes
|
|
918
|
-
if (options.outputFormat && !options.status && !options.list) {
|
|
919
|
-
throw new Error(
|
|
920
|
-
'--output-format option is only valid with --status or --list'
|
|
921
|
-
);
|
|
922
|
-
}
|
|
781
|
+
validateQueryOptions(options);
|
|
923
782
|
|
|
924
783
|
// Validate shell option
|
|
925
784
|
if (options.shell !== null && options.shell !== undefined) {
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached completion finalizer (issue #170)
|
|
3
|
+
*
|
|
4
|
+
* A detached Docker session outlives the CLI process that started it: the
|
|
5
|
+
* foreground `start` invocation returns as soon as the container is up, so
|
|
6
|
+
* nobody is left to write the terminal state back into the execution store.
|
|
7
|
+
* The record therefore stayed `executing` forever, with `exitCode: null` and
|
|
8
|
+
* `endTime: null`, and every `--status` query had to re-derive the outcome from
|
|
9
|
+
* scratch — fabricating `endTime` with `new Date()` in the process.
|
|
10
|
+
*
|
|
11
|
+
* The detached completion watcher (see `docker-cleanup.js`) now re-invokes this
|
|
12
|
+
* module once the container is gone, handing over the facts it already read out
|
|
13
|
+
* of a single `docker inspect`. This module is deliberately tiny and
|
|
14
|
+
* dependency-light: it is executed as a short-lived child process on a host
|
|
15
|
+
* whose CLI invocation is long gone, so it must never throw and never block.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
SHELL_VARS,
|
|
22
|
+
describeExitCode,
|
|
23
|
+
normalizeDockerTimestamp,
|
|
24
|
+
} = require('./docker-post-mortem');
|
|
25
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
26
|
+
|
|
27
|
+
/** Provenance markers for `endTime` (issue #170.2). */
|
|
28
|
+
const END_TIME_SOURCE = {
|
|
29
|
+
/** `docker inspect .State.FinishedAt` — the container's own clock. */
|
|
30
|
+
DOCKER_FINISHED_AT: 'docker-finished-at',
|
|
31
|
+
/** The anchored `Finished:` line `start` itself wrote into the log. */
|
|
32
|
+
LOG_FOOTER: 'log-footer',
|
|
33
|
+
/** No real finish time exists: this is when the end was *observed*. */
|
|
34
|
+
OBSERVED_AT: 'observed-at',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Persist the terminal state of a detached execution.
|
|
39
|
+
*
|
|
40
|
+
* Purely additive with respect to the observation-vs-verdict discipline of
|
|
41
|
+
* issues #148/#151/#162: `oomKilled` and `exitReason` are recorded as facts
|
|
42
|
+
* next to the exit code, never as a substitute for it.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} options - Finalization input
|
|
45
|
+
* @param {Object} options.store - An `ExecutionStore`
|
|
46
|
+
* @param {string} options.executionId - UUID (or session name) of the record
|
|
47
|
+
* @param {number|string|null} [options.exitCode] - `.State.ExitCode`
|
|
48
|
+
* @param {boolean|string|null} [options.oomKilled] - `.State.OOMKilled`
|
|
49
|
+
* @param {string|null} [options.startedAt] - `.State.StartedAt`
|
|
50
|
+
* @param {string|null} [options.finishedAt] - `.State.FinishedAt`
|
|
51
|
+
* @param {string|null} [options.containerError] - `.State.Error`
|
|
52
|
+
* @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
|
|
53
|
+
*/
|
|
54
|
+
function finalizeDetachedExecution(options = {}) {
|
|
55
|
+
const { store, executionId } = options;
|
|
56
|
+
if (!store || !executionId) {
|
|
57
|
+
return { updated: false, reason: 'missing-arguments' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let record;
|
|
61
|
+
try {
|
|
62
|
+
record = store.get(executionId);
|
|
63
|
+
} catch {
|
|
64
|
+
return { updated: false, reason: 'store-unreadable' };
|
|
65
|
+
}
|
|
66
|
+
if (!record) {
|
|
67
|
+
return { updated: false, reason: 'record-not-found' };
|
|
68
|
+
}
|
|
69
|
+
if (record.status === 'executed' && record.endTime) {
|
|
70
|
+
// Already finalized (e.g. a `--status` query got there first, or the
|
|
71
|
+
// watcher ran twice after a resume). Nothing to correct.
|
|
72
|
+
return { updated: false, reason: 'already-final', record };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const described = describeExitCode(options.exitCode);
|
|
76
|
+
record.status = 'executed';
|
|
77
|
+
if (described.code !== null) {
|
|
78
|
+
record.exitCode = described.code;
|
|
79
|
+
} else if (record.exitCode === null || record.exitCode === undefined) {
|
|
80
|
+
record.exitCode = -1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const finishedAt = normalizeDockerTimestamp(options.finishedAt);
|
|
84
|
+
if (finishedAt) {
|
|
85
|
+
record.endTime = new Date(finishedAt).toISOString();
|
|
86
|
+
record.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
|
|
87
|
+
} else {
|
|
88
|
+
// Docker has no finish time for this container (it never started, or it
|
|
89
|
+
// was removed before we could look). Record *when we noticed*, and say so,
|
|
90
|
+
// rather than passing observation time off as a finish time (issue #170.2).
|
|
91
|
+
record.endTime = new Date().toISOString();
|
|
92
|
+
record.endTimeSource = END_TIME_SOURCE.OBSERVED_AT;
|
|
93
|
+
record.observedAt = record.endTime;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const startedAt = normalizeDockerTimestamp(options.startedAt);
|
|
97
|
+
if (startedAt) {
|
|
98
|
+
record.containerStartedAt = startedAt;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const oomKilled = normalizeBoolean(options.oomKilled);
|
|
102
|
+
if (oomKilled !== null) {
|
|
103
|
+
record.oomKilled = oomKilled;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const exitReason = resolveReason(record);
|
|
107
|
+
if (exitReason) {
|
|
108
|
+
record.exitReason = exitReason;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const containerError = normalizeContainerError(options.containerError);
|
|
112
|
+
if (containerError) {
|
|
113
|
+
record.options = { ...(record.options || {}), containerError };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
store.save(record);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { updated: false, reason: `save-failed: ${err.message}`, record };
|
|
120
|
+
}
|
|
121
|
+
return { updated: true, record };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Derive the `exitReason` hint from the same evidence `--status` would use.
|
|
126
|
+
* Kept in a helper so a failure to read the log never aborts finalization.
|
|
127
|
+
* @param {Object} record - Execution record being finalized
|
|
128
|
+
* @returns {string|null} Reason hint, or null
|
|
129
|
+
*/
|
|
130
|
+
function resolveReason(record) {
|
|
131
|
+
try {
|
|
132
|
+
const { resolveExitReason } = require('./exit-reason');
|
|
133
|
+
const {
|
|
134
|
+
FATAL_MARKER_TAIL_BYTES,
|
|
135
|
+
readLogTail,
|
|
136
|
+
} = require('./isolation-log-utils');
|
|
137
|
+
const logTail = record.logPath
|
|
138
|
+
? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
|
|
139
|
+
: null;
|
|
140
|
+
return resolveExitReason({
|
|
141
|
+
exitCode: record.exitCode,
|
|
142
|
+
logTail,
|
|
143
|
+
oomKilled: record.oomKilled,
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {boolean|string|null|undefined} value - Raw shell-provided flag
|
|
152
|
+
* @returns {boolean|null} Parsed flag, or null when it carries no information
|
|
153
|
+
*/
|
|
154
|
+
function normalizeBoolean(value) {
|
|
155
|
+
if (typeof value === 'boolean') {
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
if (value === 'true') {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (value === 'false') {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string|null|undefined} value - Raw `.State.Error`
|
|
169
|
+
* @returns {string|null} Error text, or null when docker reported none
|
|
170
|
+
*/
|
|
171
|
+
function normalizeContainerError(value) {
|
|
172
|
+
if (typeof value !== 'string') {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const trimmed = value.trim();
|
|
176
|
+
if (trimmed === '' || trimmed === '<no value>' || trimmed === '(none)') {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
return trimmed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Reconcile an in-memory record with one the detached watcher already
|
|
184
|
+
* finalized.
|
|
185
|
+
*
|
|
186
|
+
* A container that exits almost immediately can be finalized by the watcher
|
|
187
|
+
* before the foreground `start` invocation writes its own last update. Saving
|
|
188
|
+
* the stale in-memory copy would resurrect `status: executing` and throw the
|
|
189
|
+
* terminal facts away, so the stored record wins and only the fields the
|
|
190
|
+
* foreground process learned (its `options`, e.g. `containerId`) are merged in.
|
|
191
|
+
*
|
|
192
|
+
* @param {Object} store - An `ExecutionStore`
|
|
193
|
+
* @param {Object} record - The in-memory record about to be saved
|
|
194
|
+
* @returns {Object} The record to save
|
|
195
|
+
*/
|
|
196
|
+
function reconcileFinalizedRecord(store, record) {
|
|
197
|
+
try {
|
|
198
|
+
const stored = store.get(record.uuid);
|
|
199
|
+
if (!stored || stored.status !== 'executed' || !stored.endTime) {
|
|
200
|
+
return record;
|
|
201
|
+
}
|
|
202
|
+
stored.options = { ...(stored.options || {}), ...(record.options || {}) };
|
|
203
|
+
return stored;
|
|
204
|
+
} catch {
|
|
205
|
+
return record;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Shell fragment that hands the inspected docker facts to this module.
|
|
211
|
+
*
|
|
212
|
+
* Runs with the same runtime that started the session, inherits
|
|
213
|
+
* `START_APP_FOLDER`/`START_DISABLE_TRACKING` from the detached watcher, and is
|
|
214
|
+
* suffixed with `|| true` so a finalization failure can never abort the
|
|
215
|
+
* watcher's remaining cleanup work.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} executionId - UUID of the record to finalize
|
|
218
|
+
* @returns {string} POSIX shell fragment
|
|
219
|
+
*/
|
|
220
|
+
function buildDetachedFinalizeSnippet(executionId) {
|
|
221
|
+
const v = SHELL_VARS;
|
|
222
|
+
return (
|
|
223
|
+
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
224
|
+
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
225
|
+
`"$${v.started}" "$${v.finished}" "$${v.error}" ` +
|
|
226
|
+
`>/dev/null 2>&1 || true`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Entry point used by the detached watcher:
|
|
232
|
+
* <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error>
|
|
233
|
+
* Always exits 0 — a bookkeeping failure must never turn into a visible error
|
|
234
|
+
* in a log the user is reading for the command's own output.
|
|
235
|
+
* @param {string[]} argv - Positional arguments
|
|
236
|
+
* @returns {void}
|
|
237
|
+
*/
|
|
238
|
+
function main(argv) {
|
|
239
|
+
const [
|
|
240
|
+
executionId,
|
|
241
|
+
exitCode,
|
|
242
|
+
oomKilled,
|
|
243
|
+
startedAt,
|
|
244
|
+
finishedAt,
|
|
245
|
+
containerError,
|
|
246
|
+
] = argv;
|
|
247
|
+
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const { ExecutionStore } = require('./execution-store');
|
|
252
|
+
const store = new ExecutionStore(
|
|
253
|
+
process.env.START_APP_FOLDER
|
|
254
|
+
? { appFolder: path.resolve(process.env.START_APP_FOLDER) }
|
|
255
|
+
: {}
|
|
256
|
+
);
|
|
257
|
+
finalizeDetachedExecution({
|
|
258
|
+
store,
|
|
259
|
+
executionId,
|
|
260
|
+
exitCode,
|
|
261
|
+
oomKilled,
|
|
262
|
+
startedAt,
|
|
263
|
+
finishedAt,
|
|
264
|
+
containerError,
|
|
265
|
+
});
|
|
266
|
+
} catch {
|
|
267
|
+
// Deliberately silent: see the exit-0 contract above.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (require.main === module) {
|
|
272
|
+
main(process.argv.slice(2));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = {
|
|
276
|
+
END_TIME_SOURCE,
|
|
277
|
+
buildDetachedFinalizeSnippet,
|
|
278
|
+
finalizeDetachedExecution,
|
|
279
|
+
main,
|
|
280
|
+
reconcileFinalizedRecord,
|
|
281
|
+
};
|