start-command 0.37.7 → 0.37.9
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 +16 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/bin/cli.js +15 -0
- package/src/lib/args-parser-queries.js +45 -8
- package/src/lib/args-parser.js +10 -1
- package/src/lib/container-removal.js +163 -0
- package/src/lib/detached-finalize.js +29 -2
- package/src/lib/docker-cleanup.js +7 -4
- package/src/lib/docker-post-mortem.js +2 -0
- package/src/lib/docker-snapshot-resume.js +222 -0
- package/src/lib/docker-snapshot-safety.js +124 -12
- package/src/lib/execution-attempt.js +2 -0
- package/src/lib/execution-cleanup.js +193 -0
- package/src/lib/execution-control.js +2 -1
- package/src/lib/execution-recovery.js +2 -0
- package/src/lib/execution-resume.js +60 -6
- package/src/lib/execution-store.js +52 -0
- package/src/lib/isolation.js +4 -0
- package/src/lib/query-commands.js +16 -3
- package/src/lib/snapshot-resume-options.js +174 -0
- package/src/lib/status-formatter.js +59 -34
- package/src/lib/status-links-notation.js +97 -0
- package/src/lib/store-lock.js +3 -0
- package/src/lib/usage.js +7 -0
- package/test/regression-222.js +61 -0
- package/test/regression-224.js +807 -0
- package/test/regression-225.js +415 -0
- package/test/regression-226.js +81 -0
- package/test/shared-golden.js +20 -0
|
@@ -54,6 +54,7 @@ const {
|
|
|
54
54
|
preflightSnapshot,
|
|
55
55
|
SNAPSHOT_IMAGE_LABEL,
|
|
56
56
|
} = require('./docker-snapshot-safety');
|
|
57
|
+
const snapshotResume = require('./docker-snapshot-resume');
|
|
57
58
|
const {
|
|
58
59
|
ResumeAllAction,
|
|
59
60
|
resumeAllExecutions,
|
|
@@ -441,8 +442,10 @@ function activeSessionName(plan) {
|
|
|
441
442
|
* Resume a tracked execution by UUID or session name.
|
|
442
443
|
* @param {?object} store - Execution store
|
|
443
444
|
* @param {string} identifier - UUID or session name
|
|
444
|
-
* @param {object} deps - {command, outputFormat, probe, runner,
|
|
445
|
-
*
|
|
445
|
+
* @param {object} deps - {command, outputFormat, probe, runner, runAsync,
|
|
446
|
+
* startWatcher, runIsolated, removeOriginal, removeOriginalAfter,
|
|
447
|
+
* snapshotOptions, notify}
|
|
448
|
+
* @returns {Promise<{success: boolean, output?: string, error?: string, exitCode?: number}>}
|
|
446
449
|
*/
|
|
447
450
|
async function resumeExecution(store, identifier, deps = {}) {
|
|
448
451
|
if (!store) {
|
|
@@ -605,16 +608,34 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
605
608
|
}
|
|
606
609
|
containerId = launchResult.containerId || null;
|
|
607
610
|
} else {
|
|
611
|
+
const notify = deps.notify || ((line) => process.stderr.write(`${line}\n`));
|
|
612
|
+
const snapshotOptions = { notify, ...deps.snapshotOptions };
|
|
613
|
+
const progress = {
|
|
614
|
+
sessionName: plan.sessionName,
|
|
615
|
+
logPath: record.logPath,
|
|
616
|
+
notify,
|
|
617
|
+
};
|
|
608
618
|
let snapshotLock;
|
|
609
619
|
let snapshotCreated = false;
|
|
610
620
|
let derivedCreated = false;
|
|
611
621
|
try {
|
|
612
622
|
if (plan.mode === ResumeMode.DOCKER_SNAPSHOT) {
|
|
613
|
-
snapshotLock = acquireSnapshotLock(
|
|
623
|
+
snapshotLock = acquireSnapshotLock({
|
|
624
|
+
...snapshotOptions,
|
|
625
|
+
holder: plan.sessionName,
|
|
626
|
+
});
|
|
627
|
+
if (snapshotOptions.prepare) {
|
|
628
|
+
snapshotResume.runSnapshotPrepare(
|
|
629
|
+
plan.sessionName,
|
|
630
|
+
snapshotOptions.prepare,
|
|
631
|
+
runner,
|
|
632
|
+
progress
|
|
633
|
+
);
|
|
634
|
+
}
|
|
614
635
|
const preflight = preflightSnapshot(
|
|
615
636
|
plan.sessionName,
|
|
616
637
|
runner,
|
|
617
|
-
|
|
638
|
+
snapshotOptions
|
|
618
639
|
);
|
|
619
640
|
plan.message += `\n${preflight.message}`;
|
|
620
641
|
if (record.logPath) {
|
|
@@ -639,7 +660,18 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
639
660
|
for (const step of plan.steps) {
|
|
640
661
|
let result;
|
|
641
662
|
try {
|
|
642
|
-
result =
|
|
663
|
+
result =
|
|
664
|
+
step.args[0] === 'commit'
|
|
665
|
+
? await snapshotResume.commitWithProgress(step, {
|
|
666
|
+
...progress,
|
|
667
|
+
runAsync:
|
|
668
|
+
deps.runAsync ||
|
|
669
|
+
(deps.runner
|
|
670
|
+
? (command, args) =>
|
|
671
|
+
Promise.resolve().then(() => runner(command, args))
|
|
672
|
+
: snapshotResume.runCommandAsync),
|
|
673
|
+
})
|
|
674
|
+
: runner(step.command, step.args);
|
|
643
675
|
} catch (error) {
|
|
644
676
|
result = { success: false, error: error.message };
|
|
645
677
|
}
|
|
@@ -661,7 +693,11 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
661
693
|
containerId = (result.stdout || '').trim() || containerId;
|
|
662
694
|
}
|
|
663
695
|
}
|
|
664
|
-
if (
|
|
696
|
+
if (
|
|
697
|
+
plan.mode === ResumeMode.DOCKER_SNAPSHOT &&
|
|
698
|
+
deps.removeOriginal &&
|
|
699
|
+
!(deps.removeOriginalAfter > 0)
|
|
700
|
+
) {
|
|
665
701
|
const running = runner(getDockerCommand(), [
|
|
666
702
|
'inspect',
|
|
667
703
|
'-f',
|
|
@@ -704,6 +740,7 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
704
740
|
return {
|
|
705
741
|
success: false,
|
|
706
742
|
error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${error.message}`,
|
|
743
|
+
...(error.exitCode ? { exitCode: error.exitCode } : {}),
|
|
707
744
|
};
|
|
708
745
|
} finally {
|
|
709
746
|
snapshotLock?.release();
|
|
@@ -793,6 +830,23 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
793
830
|
};
|
|
794
831
|
}
|
|
795
832
|
|
|
833
|
+
if (
|
|
834
|
+
plan.mode === ResumeMode.DOCKER_SNAPSHOT &&
|
|
835
|
+
deps.removeOriginal &&
|
|
836
|
+
deps.removeOriginalAfter > 0
|
|
837
|
+
) {
|
|
838
|
+
plan.message += `\n${await snapshotResume.removeOriginalAfter(
|
|
839
|
+
plan,
|
|
840
|
+
deps.removeOriginalAfter,
|
|
841
|
+
runner,
|
|
842
|
+
{
|
|
843
|
+
logPath: record.logPath,
|
|
844
|
+
notify: deps.notify || ((line) => process.stderr.write(`${line}\n`)),
|
|
845
|
+
sleep: deps.sleep,
|
|
846
|
+
}
|
|
847
|
+
)}`;
|
|
848
|
+
}
|
|
849
|
+
|
|
796
850
|
return {
|
|
797
851
|
success: true,
|
|
798
852
|
output: formatResumeResult(
|
|
@@ -101,6 +101,14 @@ class ExecutionRecord {
|
|
|
101
101
|
options.containerStartedAt !== undefined
|
|
102
102
|
? options.containerStartedAt
|
|
103
103
|
: undefined;
|
|
104
|
+
// `removed` once the container is known to be gone (issue #225), with
|
|
105
|
+
// when that was observed; `options.containerId` is kept as history.
|
|
106
|
+
this.containerState =
|
|
107
|
+
options.containerState !== undefined ? options.containerState : undefined;
|
|
108
|
+
this.containerRemovedObservedAt =
|
|
109
|
+
options.containerRemovedObservedAt !== undefined
|
|
110
|
+
? options.containerRemovedObservedAt
|
|
111
|
+
: undefined;
|
|
104
112
|
this.oomKilled =
|
|
105
113
|
options.oomKilled !== undefined ? options.oomKilled : undefined;
|
|
106
114
|
// Query-time hint explaining an opaque exit code (issue #162). Derived from
|
|
@@ -179,6 +187,15 @@ class ExecutionRecord {
|
|
|
179
187
|
) {
|
|
180
188
|
obj.containerStartedAt = this.containerStartedAt;
|
|
181
189
|
}
|
|
190
|
+
if (this.containerState !== undefined && this.containerState !== null) {
|
|
191
|
+
obj.containerState = this.containerState;
|
|
192
|
+
}
|
|
193
|
+
if (
|
|
194
|
+
this.containerRemovedObservedAt !== undefined &&
|
|
195
|
+
this.containerRemovedObservedAt !== null
|
|
196
|
+
) {
|
|
197
|
+
obj.containerRemovedObservedAt = this.containerRemovedObservedAt;
|
|
198
|
+
}
|
|
182
199
|
if (this.oomKilled !== undefined && this.oomKilled !== null) {
|
|
183
200
|
obj.oomKilled = this.oomKilled;
|
|
184
201
|
}
|
|
@@ -489,6 +506,41 @@ class ExecutionStore {
|
|
|
489
506
|
}
|
|
490
507
|
}
|
|
491
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Record that a finished execution's container no longer exists
|
|
511
|
+
* (issue #225). Applied under the database lock to the stored record, and
|
|
512
|
+
* only while it is still finished, so a concurrent resume is never undone.
|
|
513
|
+
* @param {string} uuid - Execution UUID
|
|
514
|
+
* @param {string} observedAt - When the removal was observed
|
|
515
|
+
* @returns {ExecutionRecord|null} The updated record, or null
|
|
516
|
+
*/
|
|
517
|
+
markContainerRemoved(uuid, observedAt) {
|
|
518
|
+
const lock = new LockManager(this.lockFilePath);
|
|
519
|
+
if (!lock.acquire()) {
|
|
520
|
+
throw new Error('Failed to acquire lock for container state');
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
const records = this.readLinoRecords();
|
|
524
|
+
const current = records.find((record) => record.uuid === uuid);
|
|
525
|
+
if (
|
|
526
|
+
!current ||
|
|
527
|
+
current.status !== ExecutionStatus.EXECUTED ||
|
|
528
|
+
current.containerState === 'removed'
|
|
529
|
+
) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
current.containerState = 'removed';
|
|
533
|
+
current.containerRemovedObservedAt = observedAt;
|
|
534
|
+
this.writeLinoRecords(records);
|
|
535
|
+
if (this.useLinks) {
|
|
536
|
+
this.writeLinksRecord(current);
|
|
537
|
+
}
|
|
538
|
+
return current;
|
|
539
|
+
} finally {
|
|
540
|
+
lock.release();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
492
544
|
/**
|
|
493
545
|
* Get an execution record by UUID or session name
|
|
494
546
|
* First tries exact UUID match, then the current session name, then any
|
package/src/lib/isolation.js
CHANGED
|
@@ -813,8 +813,10 @@ function runInDocker(command, options = {}) {
|
|
|
813
813
|
!containerExistedBeforeLaunch &&
|
|
814
814
|
readDockerContainerStatus(containerName) === 'created';
|
|
815
815
|
|
|
816
|
+
let containerRemoved = false;
|
|
816
817
|
if (launchFailed) {
|
|
817
818
|
if (removeDockerContainer(containerName, options.logPath)) {
|
|
819
|
+
containerRemoved = true;
|
|
818
820
|
message += `\nContainer removed after launch failure.`;
|
|
819
821
|
} else {
|
|
820
822
|
message += `\nWarning: failed to remove container after launch failure.`;
|
|
@@ -823,6 +825,7 @@ function runInDocker(command, options = {}) {
|
|
|
823
825
|
shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
|
|
824
826
|
) {
|
|
825
827
|
if (removeDockerContainer(containerName, options.logPath)) {
|
|
828
|
+
containerRemoved = true;
|
|
826
829
|
message += `\nContainer removed after completion.${postMortem(true)}`;
|
|
827
830
|
} else {
|
|
828
831
|
message += `\nWarning: failed to remove container automatically.`;
|
|
@@ -848,6 +851,7 @@ function runInDocker(command, options = {}) {
|
|
|
848
851
|
diagnostics: diagnosticFacts,
|
|
849
852
|
message,
|
|
850
853
|
exitCode,
|
|
854
|
+
containerRemoved,
|
|
851
855
|
});
|
|
852
856
|
});
|
|
853
857
|
|
|
@@ -13,6 +13,8 @@ const { ControlAction, controlExecution } = require('./execution-control');
|
|
|
13
13
|
const { attachExecution } = require('./execution-attach');
|
|
14
14
|
const { resumeAllExecutions, resumeExecution } = require('./execution-resume');
|
|
15
15
|
const { uploadExecutionLog } = require('./log-uploader');
|
|
16
|
+
const { snapshotResumeSettings } = require('./snapshot-resume-options');
|
|
17
|
+
const { cleanupExecution } = require('./execution-cleanup');
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Print a handler result, returning the process exit code.
|
|
@@ -68,6 +70,8 @@ async function handleResumeQuery(
|
|
|
68
70
|
outputFormat,
|
|
69
71
|
resourceOptions,
|
|
70
72
|
removeOriginal: resourceOptions.removeOriginal === true,
|
|
73
|
+
removeOriginalAfter: resourceOptions.removeOriginalAfter ?? 0,
|
|
74
|
+
snapshotOptions: snapshotResumeSettings(resourceOptions),
|
|
71
75
|
})
|
|
72
76
|
);
|
|
73
77
|
}
|
|
@@ -78,12 +82,21 @@ function handleResumeAllQuery(store, outputFormat) {
|
|
|
78
82
|
|
|
79
83
|
/**
|
|
80
84
|
* Handle --cleanup / --cleanup-dry-run.
|
|
81
|
-
* Cleans up stale "executing" records (processes that crashed or were killed)
|
|
85
|
+
* Cleans up stale "executing" records (processes that crashed or were killed),
|
|
86
|
+
* or with a target the Docker containers and snapshot images of one execution.
|
|
82
87
|
* @param {?object} store - Execution store
|
|
83
88
|
* @param {boolean} dryRun - If true, just report what would be cleaned
|
|
89
|
+
* @param {?string} [target] - UUID or session name of one execution
|
|
84
90
|
* @returns {number} Exit code
|
|
85
91
|
*/
|
|
86
|
-
function handleCleanup(store, dryRun) {
|
|
92
|
+
function handleCleanup(store, dryRun, target = null) {
|
|
93
|
+
if (hasValue(target)) {
|
|
94
|
+
const result = cleanupExecution(store, target, { dryRun });
|
|
95
|
+
if (!result.success && result.output) {
|
|
96
|
+
console.log(result.output);
|
|
97
|
+
}
|
|
98
|
+
return reportResult(result);
|
|
99
|
+
}
|
|
87
100
|
if (!store) {
|
|
88
101
|
console.error('Error: Execution tracking is disabled.');
|
|
89
102
|
return 1;
|
|
@@ -193,7 +206,7 @@ async function dispatchQueryCommand(options, context = {}) {
|
|
|
193
206
|
return handleResumeAllQuery(store, outputFormat);
|
|
194
207
|
}
|
|
195
208
|
if (options.cleanup) {
|
|
196
|
-
return handleCleanup(store, options.cleanupDryRun);
|
|
209
|
+
return handleCleanup(store, options.cleanupDryRun, options.cleanupTarget);
|
|
197
210
|
}
|
|
198
211
|
|
|
199
212
|
return null;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the guarded Docker snapshot resume (issue #224):
|
|
3
|
+
* --snapshot-wait <seconds> wait for the host snapshot lock
|
|
4
|
+
* --snapshot-disk-wait <seconds> wait for free disk instead of refusing
|
|
5
|
+
* --remove-original-after <seconds> remove the original only if the
|
|
6
|
+
* replacement still runs after the delay
|
|
7
|
+
* --snapshot-size-hint <bytes> skip the `docker inspect --size` walk
|
|
8
|
+
* --snapshot-prepare <command> `docker exec` a command in the original
|
|
9
|
+
* before it is committed
|
|
10
|
+
*
|
|
11
|
+
* They only apply to `--resume`. The two waits also read
|
|
12
|
+
* START_SNAPSHOT_LOCK_WAIT and START_SNAPSHOT_DISK_WAIT when the flags are
|
|
13
|
+
* absent, so a supervisor can set them once for every resume it runs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FIELDS = {
|
|
17
|
+
'--snapshot-wait': 'snapshotWait',
|
|
18
|
+
'--snapshot-disk-wait': 'snapshotDiskWait',
|
|
19
|
+
'--remove-original-after': 'removeOriginalAfter',
|
|
20
|
+
'--snapshot-size-hint': 'snapshotSizeHint',
|
|
21
|
+
'--snapshot-prepare': 'snapshotPrepare',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ENV_DEFAULTS = {
|
|
25
|
+
snapshotWait: ['START_SNAPSHOT_LOCK_WAIT', '--snapshot-wait'],
|
|
26
|
+
snapshotDiskWait: ['START_SNAPSHOT_DISK_WAIT', '--snapshot-disk-wait'],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const UNITS = {
|
|
30
|
+
'': 1,
|
|
31
|
+
b: 1,
|
|
32
|
+
k: 1024,
|
|
33
|
+
m: 1024 ** 2,
|
|
34
|
+
g: 1024 ** 3,
|
|
35
|
+
t: 1024 ** 4,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function createSnapshotOptionDefaults() {
|
|
39
|
+
return {
|
|
40
|
+
snapshotWait: null,
|
|
41
|
+
snapshotDiskWait: null,
|
|
42
|
+
removeOriginalAfter: null,
|
|
43
|
+
snapshotSizeHint: null,
|
|
44
|
+
snapshotPrepare: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a non-negative number of seconds; fractions are allowed.
|
|
50
|
+
* @param {string} value - Raw value
|
|
51
|
+
* @param {string} name - Option or variable name for the error message
|
|
52
|
+
* @returns {number} Seconds
|
|
53
|
+
*/
|
|
54
|
+
function parseSeconds(value, name) {
|
|
55
|
+
const text = String(value).trim();
|
|
56
|
+
const seconds = Number(text);
|
|
57
|
+
if (!/^\d+(?:\.\d+)?$/.test(text) || !Number.isFinite(seconds)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Invalid ${name} value: "${value}". Expected a non-negative number of seconds.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return seconds;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse a size in bytes; k, m, g and t suffixes are 1024-based.
|
|
67
|
+
* @param {string} value - Raw value
|
|
68
|
+
* @returns {number} Bytes
|
|
69
|
+
*/
|
|
70
|
+
function parseSizeHint(value) {
|
|
71
|
+
const match = /^(\d+)([bkmgt])?$/.exec(String(value).trim().toLowerCase());
|
|
72
|
+
const bytes = match ? Number(match[1]) * UNITS[match[2] || ''] : NaN;
|
|
73
|
+
if (!Number.isSafeInteger(bytes)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Invalid --snapshot-size-hint value: "${value}". Expected a number of bytes (k, m, g, t suffixes allowed).`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseValue(flag, value) {
|
|
82
|
+
if (flag === '--snapshot-size-hint') {
|
|
83
|
+
return parseSizeHint(value);
|
|
84
|
+
}
|
|
85
|
+
if (flag === '--snapshot-prepare') {
|
|
86
|
+
if (String(value).trim() === '') {
|
|
87
|
+
throw new Error('--snapshot-prepare requires a non-empty command');
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
return parseSeconds(value, flag);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse one snapshot option.
|
|
96
|
+
* @returns {number} Arguments consumed, 0 when `args[index]` is not one
|
|
97
|
+
*/
|
|
98
|
+
function parse(args, index, options) {
|
|
99
|
+
const arg = args[index];
|
|
100
|
+
const equals = arg.indexOf('=');
|
|
101
|
+
const flag = equals === -1 ? arg : arg.slice(0, equals);
|
|
102
|
+
const field = FIELDS[flag];
|
|
103
|
+
if (!field) {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
if (equals !== -1) {
|
|
107
|
+
options[field] = parseValue(flag, arg.slice(equals + 1));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const value = args[index + 1];
|
|
111
|
+
// A prepare command may itself start with a dash only through `=`.
|
|
112
|
+
if (value === undefined || value.startsWith('-')) {
|
|
113
|
+
const kind = {
|
|
114
|
+
'--snapshot-size-hint': 'bytes',
|
|
115
|
+
'--snapshot-prepare': 'command',
|
|
116
|
+
}[flag];
|
|
117
|
+
throw new Error(`Option ${flag} requires a ${kind || 'seconds'} argument`);
|
|
118
|
+
}
|
|
119
|
+
options[field] = parseValue(flag, value);
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function hasValue(value) {
|
|
124
|
+
return value !== null && value !== undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reject snapshot options outside `--resume` and fill the waits from the
|
|
129
|
+
* environment.
|
|
130
|
+
* @param {object} options - Parsed wrapper options
|
|
131
|
+
* @param {object} [env] - Environment
|
|
132
|
+
*/
|
|
133
|
+
function validate(options, env = process.env) {
|
|
134
|
+
if (!hasValue(options.resume)) {
|
|
135
|
+
for (const [flag, field] of Object.entries(FIELDS)) {
|
|
136
|
+
if (hasValue(options[field])) {
|
|
137
|
+
throw new Error(`${flag} is only valid with --resume`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (const [field, [name, flag]] of Object.entries(ENV_DEFAULTS)) {
|
|
143
|
+
if (!hasValue(options[field]) && hasValue(env[name]) && env[name] !== '') {
|
|
144
|
+
options[field] = parseSeconds(env[name], `${name} (${flag})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (hasValue(options.removeOriginalAfter)) {
|
|
148
|
+
options.removeOriginal = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The `snapshotOptions` `resumeExecution` reads from the wrapper options.
|
|
154
|
+
* @param {object} options - Parsed wrapper options
|
|
155
|
+
* @returns {object} {waitSeconds, diskWaitSeconds, sizeHint, prepare}
|
|
156
|
+
*/
|
|
157
|
+
function snapshotResumeSettings(options = {}) {
|
|
158
|
+
return {
|
|
159
|
+
waitSeconds: options.snapshotWait ?? 0,
|
|
160
|
+
diskWaitSeconds: options.snapshotDiskWait ?? 0,
|
|
161
|
+
sizeHint: options.snapshotSizeHint ?? null,
|
|
162
|
+
prepare: options.snapshotPrepare ?? null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
FIELDS,
|
|
168
|
+
createSnapshotOptionDefaults,
|
|
169
|
+
parse,
|
|
170
|
+
parseSeconds,
|
|
171
|
+
parseSizeHint,
|
|
172
|
+
snapshotResumeSettings,
|
|
173
|
+
validate,
|
|
174
|
+
};
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* - Text: Human-readable text format
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
const { execSync
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
const { escapeForLinksNotation } = require('./output-blocks');
|
|
11
12
|
const {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} = require('./
|
|
13
|
+
appendLinksValue,
|
|
14
|
+
formatTextOptionValue,
|
|
15
|
+
} = require('./status-links-notation');
|
|
15
16
|
const { collectProcessIds } = require('./execution-control');
|
|
16
17
|
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
17
18
|
const {
|
|
@@ -27,6 +28,11 @@ const {
|
|
|
27
28
|
const { normalizeDockerTimestamp } = require('./docker-post-mortem');
|
|
28
29
|
const { END_TIME_SOURCE } = require('./detached-finalize');
|
|
29
30
|
const { formatCgroupMemory } = require('./cgroup-memory');
|
|
31
|
+
const {
|
|
32
|
+
attachContainerRemoval,
|
|
33
|
+
dockerInspect,
|
|
34
|
+
persistContainerRemoval,
|
|
35
|
+
} = require('./container-removal');
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* Inspect the live state of a detached docker container by name.
|
|
@@ -43,32 +49,31 @@ const { formatCgroupMemory } = require('./cgroup-memory');
|
|
|
43
49
|
* instead of fabricating a terminal `-1` result.
|
|
44
50
|
*
|
|
45
51
|
* @param {string} sessionName - Container name
|
|
52
|
+
* @param {Object|null} [probe] - Receives `sessionNotFound` (issue #225)
|
|
46
53
|
* @returns {{running: boolean, exitCode: number|null, oomKilled: boolean|null}|null} State,
|
|
47
54
|
* or null when the container cannot be inspected (not found yet, removed, or docker error)
|
|
48
55
|
*/
|
|
49
|
-
function inspectDockerState(sessionName) {
|
|
50
|
-
const
|
|
56
|
+
function inspectDockerState(sessionName, probe = null) {
|
|
57
|
+
const { stdout, notFound } = dockerInspect(
|
|
51
58
|
getDockerCommand(),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
'
|
|
55
|
-
|
|
56
|
-
'{{.State.StartedAt}} {{.State.FinishedAt}}',
|
|
57
|
-
sessionName,
|
|
58
|
-
],
|
|
59
|
-
getDockerSpawnOptions({
|
|
60
|
-
encoding: 'utf8',
|
|
61
|
-
env: process.env,
|
|
62
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
63
|
-
})
|
|
59
|
+
dockerProbeSpawnOptions(),
|
|
60
|
+
'{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
|
|
61
|
+
'{{.State.StartedAt}} {{.State.FinishedAt}}',
|
|
62
|
+
sessionName
|
|
64
63
|
);
|
|
65
|
-
if (
|
|
64
|
+
if (probe) {
|
|
65
|
+
// "No such container" is a fact, unlike a docker that cannot be reached
|
|
66
|
+
// (issue #225); it still reads as unknown liveness here (issue #136).
|
|
67
|
+
probe.sessionNotFound = notFound;
|
|
68
|
+
}
|
|
69
|
+
if (stdout === null) {
|
|
66
70
|
return null;
|
|
67
71
|
}
|
|
68
72
|
// Parsed positionally and defensively: an older docker (or a stubbed one)
|
|
69
73
|
// may answer with fewer fields than the template asks for.
|
|
70
|
-
const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] =
|
|
71
|
-
|
|
74
|
+
const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] = stdout
|
|
75
|
+
.trim()
|
|
76
|
+
.split(/\s+/);
|
|
72
77
|
const exitCode = Number.parseInt(exitRaw, 10);
|
|
73
78
|
return {
|
|
74
79
|
running: runningRaw === 'true',
|
|
@@ -89,12 +94,20 @@ function isDetachedDockerRecord(record) {
|
|
|
89
94
|
);
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
function
|
|
97
|
+
function dockerProbeSpawnOptions() {
|
|
98
|
+
return getDockerSpawnOptions({
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
env: process.env,
|
|
101
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readDockerState(record, probe = null) {
|
|
93
106
|
const opts = record.options || {};
|
|
94
107
|
if (opts.isolated !== 'docker' || !opts.sessionName) {
|
|
95
108
|
return null;
|
|
96
109
|
}
|
|
97
|
-
const state = inspectDockerState(opts.sessionName);
|
|
110
|
+
const state = inspectDockerState(opts.sessionName, probe);
|
|
98
111
|
if (
|
|
99
112
|
record.attempt &&
|
|
100
113
|
state?.startedAt &&
|
|
@@ -325,14 +338,14 @@ function applyEndTime(enriched, dockerState, footerFinishedAt) {
|
|
|
325
338
|
enriched.observedAt = enriched.endTime;
|
|
326
339
|
}
|
|
327
340
|
|
|
328
|
-
function resolveDetachedStatus(record, logTail) {
|
|
341
|
+
function resolveDetachedStatus(record, logTail, probe = null) {
|
|
329
342
|
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
330
343
|
return record;
|
|
331
344
|
}
|
|
332
345
|
const footer = parseFooterFromTail(logTail);
|
|
333
346
|
const footerExit = footer.exitCode;
|
|
334
347
|
const dockerState = isDetachedDockerRecord(record)
|
|
335
|
-
? readDockerState(record)
|
|
348
|
+
? readDockerState(record, probe)
|
|
336
349
|
: null;
|
|
337
350
|
const alive = isDetachedDockerRecord(record)
|
|
338
351
|
? dockerState === null
|
|
@@ -550,7 +563,14 @@ function enrichDetachedStatus(record) {
|
|
|
550
563
|
const logTail = record
|
|
551
564
|
? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
|
|
552
565
|
: null;
|
|
553
|
-
const
|
|
566
|
+
const probe = {
|
|
567
|
+
dockerCommand: getDockerCommand(),
|
|
568
|
+
spawnOptions: dockerProbeSpawnOptions(),
|
|
569
|
+
};
|
|
570
|
+
const resolved = attachContainerRemoval(
|
|
571
|
+
resolveDetachedStatus(record, logTail, probe),
|
|
572
|
+
probe
|
|
573
|
+
);
|
|
554
574
|
const enriched = attachMemoryExhaustion(
|
|
555
575
|
attachExitReason(resolved, logTail),
|
|
556
576
|
logTail
|
|
@@ -671,22 +691,21 @@ function formatRecordAsLinksNotation(record) {
|
|
|
671
691
|
for (const [key, value] of Object.entries(obj)) {
|
|
672
692
|
if (value !== null && value !== undefined) {
|
|
673
693
|
if (key === 'options' && typeof value === 'object') {
|
|
674
|
-
//
|
|
694
|
+
// Nested option values (launchOwner, exitEvidence, resolvedLimits…)
|
|
695
|
+
// are rendered as nested Links notation, never "[object Object]"
|
|
696
|
+
// (issue #226).
|
|
675
697
|
const optionEntries = Object.entries(value).filter(
|
|
676
698
|
([, v]) => v !== null && v !== undefined
|
|
677
699
|
);
|
|
678
700
|
if (optionEntries.length > 0) {
|
|
679
701
|
lines.push(' options');
|
|
680
702
|
for (const [optKey, optValue] of optionEntries) {
|
|
681
|
-
|
|
682
|
-
lines.push(` ${optKey} ${formattedOptValue}`);
|
|
703
|
+
appendLinksValue(lines, optKey, optValue, 4);
|
|
683
704
|
}
|
|
684
705
|
}
|
|
685
706
|
} else if (typeof value === 'object') {
|
|
686
|
-
//
|
|
687
|
-
lines
|
|
688
|
-
const nested = formatAsNestedLinksNotation(value, 2, 2);
|
|
689
|
-
lines.push(nested);
|
|
707
|
+
// Other objects use the same nested layout as the Rust formatter.
|
|
708
|
+
appendLinksValue(lines, key, value, 2);
|
|
690
709
|
} else {
|
|
691
710
|
const formattedValue = escapeForLinksNotation(value);
|
|
692
711
|
lines.push(` ${key} ${formattedValue}`);
|
|
@@ -751,6 +770,11 @@ function formatRecordAsText(record) {
|
|
|
751
770
|
if (obj.staleDetectedAt) {
|
|
752
771
|
lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
|
|
753
772
|
}
|
|
773
|
+
if (obj.containerState) {
|
|
774
|
+
// The container no longer exists; `containerId` is history (issue #225).
|
|
775
|
+
lines.push(`Container State: ${obj.containerState}`);
|
|
776
|
+
lines.push(`Container Removed: ${obj.containerRemovedObservedAt || 'N/A'}`);
|
|
777
|
+
}
|
|
754
778
|
lines.push(`Log Path: ${obj.logPath}`);
|
|
755
779
|
if (obj.attempt) {
|
|
756
780
|
lines.push(
|
|
@@ -770,7 +794,7 @@ function formatRecordAsText(record) {
|
|
|
770
794
|
if (optionEntries.length > 0) {
|
|
771
795
|
lines.push(`Options:`);
|
|
772
796
|
for (const [key, value] of optionEntries) {
|
|
773
|
-
lines.push(` ${key}: ${value}`);
|
|
797
|
+
lines.push(` ${key}: ${formatTextOptionValue(value)}`);
|
|
774
798
|
}
|
|
775
799
|
}
|
|
776
800
|
|
|
@@ -897,6 +921,7 @@ function queryStatus(store, identifier, outputFormat) {
|
|
|
897
921
|
try {
|
|
898
922
|
// Enrich detached execution status with live session check
|
|
899
923
|
const enrichedRecord = enrichDetachedStatus(record);
|
|
924
|
+
persistContainerRemoval(store, record, enrichedRecord);
|
|
900
925
|
const withProcessIds = attachProcessIds(enrichedRecord);
|
|
901
926
|
// Attach currentTime so callers can see how long an executing command has been running
|
|
902
927
|
const withCurrentTime = attachCurrentTime(withProcessIds);
|