start-command 0.37.8 → 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 +10 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/bin/cli.js +4 -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-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
|
@@ -8,6 +8,30 @@ const GiB = 1024 ** 3;
|
|
|
8
8
|
const SNAPSHOT_RESERVE_BYTES = 10 * GiB;
|
|
9
9
|
const SNAPSHOT_IMAGE_LABEL = 'start-command.snapshot-image';
|
|
10
10
|
|
|
11
|
+
/** sysexits.h EX_TEMPFAIL: the resume can succeed when retried later. */
|
|
12
|
+
const EX_TEMPFAIL = 75;
|
|
13
|
+
|
|
14
|
+
function tempfail(message) {
|
|
15
|
+
const error = new Error(message);
|
|
16
|
+
error.exitCode = EX_TEMPFAIL;
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatSeconds(seconds) {
|
|
21
|
+
return `${Number(seconds.toFixed(3))}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function notifier(options) {
|
|
25
|
+
return options.notify || ((line) => process.stderr.write(`${line}\n`));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Take the host-wide snapshot lock. With `waitSeconds` above 0 a busy lock is
|
|
30
|
+
* waited for (issue #224); the wait is announced once with the holder's name.
|
|
31
|
+
* @param {object} [options] - {lockPath, holder, waitSeconds, notify}
|
|
32
|
+
* @returns {LockManager} The held lock
|
|
33
|
+
* @throws {Error} With `exitCode` 75 when the lock stays busy
|
|
34
|
+
*/
|
|
11
35
|
function acquireSnapshotLock(options = {}) {
|
|
12
36
|
const lockPath =
|
|
13
37
|
options.lockPath ||
|
|
@@ -17,6 +41,9 @@ function acquireSnapshotLock(options = {}) {
|
|
|
17
41
|
'start-command-docker-snapshot.lock'
|
|
18
42
|
);
|
|
19
43
|
const lock = new LockManager(lockPath);
|
|
44
|
+
if (options.holder) {
|
|
45
|
+
lock.metadata = { holder: options.holder };
|
|
46
|
+
}
|
|
20
47
|
// The ordinary store lock is held only for a brief write. A snapshot can
|
|
21
48
|
// legitimately take hours, so never reclaim this lock merely for its age.
|
|
22
49
|
lock.isLockStale = (data) => {
|
|
@@ -35,12 +62,28 @@ function acquireSnapshotLock(options = {}) {
|
|
|
35
62
|
return error.code === 'ESRCH';
|
|
36
63
|
}
|
|
37
64
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
const waitMs = Math.max(0, (options.waitSeconds || 0) * 1000);
|
|
66
|
+
if (lock.acquire(Math.min(1000, waitMs || 1000))) {
|
|
67
|
+
return lock;
|
|
68
|
+
}
|
|
69
|
+
const holder = lock.readLockFile()?.holder;
|
|
70
|
+
const busy = holder
|
|
71
|
+
? `A Docker snapshot resume of ${holder} is in progress on this host.`
|
|
72
|
+
: 'A Docker snapshot resume is in progress on this host.';
|
|
73
|
+
if (waitMs <= 1000) {
|
|
74
|
+
throw tempfail(
|
|
75
|
+
`${busy} Retry after it finishes, or wait for it with --snapshot-wait <seconds>.`
|
|
41
76
|
);
|
|
42
77
|
}
|
|
43
|
-
|
|
78
|
+
notifier(options)(
|
|
79
|
+
`[Resume] Waiting up to ${formatSeconds(waitMs / 1000)} for the snapshot of ${holder || 'another container'} to finish before snapshotting ${options.holder || 'this container'}.`
|
|
80
|
+
);
|
|
81
|
+
if (lock.acquire(waitMs - 1000)) {
|
|
82
|
+
return lock;
|
|
83
|
+
}
|
|
84
|
+
throw tempfail(
|
|
85
|
+
`${busy} Still busy after waiting ${formatSeconds(waitMs / 1000)}; retry after it finishes.`
|
|
86
|
+
);
|
|
44
87
|
}
|
|
45
88
|
|
|
46
89
|
function containerdRoot(config = '') {
|
|
@@ -73,8 +116,10 @@ function readFreeBytes(root) {
|
|
|
73
116
|
return Number(stats.bavail * stats.bsize);
|
|
74
117
|
}
|
|
75
118
|
|
|
76
|
-
function
|
|
77
|
-
|
|
119
|
+
function measureWritableLayer(containerName, runner, docker, options) {
|
|
120
|
+
if (options.sizeHint !== null && options.sizeHint !== undefined) {
|
|
121
|
+
return { size: options.sizeHint, source: 'size hint' };
|
|
122
|
+
}
|
|
78
123
|
const sizeResult = runner(docker, [
|
|
79
124
|
'inspect',
|
|
80
125
|
'--size',
|
|
@@ -93,6 +138,10 @@ function preflightSnapshot(containerName, runner, options = {}) {
|
|
|
93
138
|
'Cannot determine the writable layer size; refusing Docker snapshot resume.'
|
|
94
139
|
);
|
|
95
140
|
}
|
|
141
|
+
return { size, source: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function imageStoreRoots(runner, docker, options) {
|
|
96
145
|
const infoResult = runner(docker, ['info', '--format', '{{json .}}']);
|
|
97
146
|
let info;
|
|
98
147
|
try {
|
|
@@ -113,9 +162,12 @@ function preflightSnapshot(containerName, runner, options = {}) {
|
|
|
113
162
|
) {
|
|
114
163
|
roots.push(options.containerdRoot || defaultContainerdRoot());
|
|
115
164
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
165
|
+
return [...new Set(roots)];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @returns {?object} The first root without enough space, or null */
|
|
169
|
+
function findShortRoot(roots, required, free) {
|
|
170
|
+
for (const root of roots) {
|
|
119
171
|
let available;
|
|
120
172
|
try {
|
|
121
173
|
available = free(root);
|
|
@@ -126,16 +178,75 @@ function preflightSnapshot(containerName, runner, options = {}) {
|
|
|
126
178
|
);
|
|
127
179
|
}
|
|
128
180
|
if (!Number.isFinite(available) || available < required) {
|
|
129
|
-
|
|
130
|
-
|
|
181
|
+
return { root, available };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sleepSync(ms) {
|
|
188
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Refuse a snapshot that could fill the disk. The writable layer is measured
|
|
193
|
+
* once (or taken from `sizeHint`); with `diskWaitSeconds` the free space is
|
|
194
|
+
* re-checked every `diskPollSeconds` until it suffices (issue #224).
|
|
195
|
+
* @param {object} [options] - {sizeHint, diskWaitSeconds, diskPollSeconds,
|
|
196
|
+
* reserveBytes, freeBytes, containerdRoot, notify, sleep, now}
|
|
197
|
+
* @throws {Error} With `exitCode` 75 when the disk stays short
|
|
198
|
+
*/
|
|
199
|
+
function preflightSnapshot(containerName, runner, options = {}) {
|
|
200
|
+
const docker = require('./docker-cleanup').getDockerCommand();
|
|
201
|
+
const { size, source } = measureWritableLayer(
|
|
202
|
+
containerName,
|
|
203
|
+
runner,
|
|
204
|
+
docker,
|
|
205
|
+
options
|
|
206
|
+
);
|
|
207
|
+
const roots = imageStoreRoots(runner, docker, options);
|
|
208
|
+
const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
|
|
209
|
+
const free = options.freeBytes || readFreeBytes;
|
|
210
|
+
const waitMs = Math.max(0, (options.diskWaitSeconds || 0) * 1000);
|
|
211
|
+
const pollMs = Math.max(
|
|
212
|
+
1,
|
|
213
|
+
(options.diskPollSeconds ??
|
|
214
|
+
Number(process.env.START_SNAPSHOT_DISK_POLL || 10)) * 1000
|
|
215
|
+
);
|
|
216
|
+
const now = options.now || Date.now;
|
|
217
|
+
const sleep = options.sleep || sleepSync;
|
|
218
|
+
const started = now();
|
|
219
|
+
let waited = false;
|
|
220
|
+
for (;;) {
|
|
221
|
+
const short = findShortRoot(roots, required, free);
|
|
222
|
+
if (!short) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
const detail = `Insufficient disk at ${short.root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(short.available / GiB).toFixed(2)} GiB.`;
|
|
226
|
+
const left = waitMs - (now() - started);
|
|
227
|
+
if (left <= 0) {
|
|
228
|
+
throw tempfail(
|
|
229
|
+
waited
|
|
230
|
+
? `${detail} Still short after waiting ${formatSeconds(waitMs / 1000)}; Docker snapshot resume refused.`
|
|
231
|
+
: `${detail} Docker snapshot resume refused.`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (!waited) {
|
|
235
|
+
notifier(options)(
|
|
236
|
+
`[Resume] Waiting up to ${formatSeconds(waitMs / 1000)} for disk: ${detail}`
|
|
131
237
|
);
|
|
238
|
+
waited = true;
|
|
132
239
|
}
|
|
240
|
+
sleep(Math.min(pollMs, left));
|
|
133
241
|
}
|
|
242
|
+
const measured = source
|
|
243
|
+
? `${(size / GiB).toFixed(2)} GiB (${source})`
|
|
244
|
+
: `${(size / GiB).toFixed(2)} GiB`;
|
|
134
245
|
return {
|
|
135
246
|
size,
|
|
136
247
|
required,
|
|
137
248
|
roots,
|
|
138
|
-
message: `[Resume] snapshotting ${
|
|
249
|
+
message: `[Resume] snapshotting ${measured}; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
|
|
139
250
|
};
|
|
140
251
|
}
|
|
141
252
|
|
|
@@ -155,6 +266,7 @@ function snapshotImageCleanupSnippet(
|
|
|
155
266
|
}
|
|
156
267
|
|
|
157
268
|
module.exports = {
|
|
269
|
+
EX_TEMPFAIL,
|
|
158
270
|
acquireSnapshotLock,
|
|
159
271
|
containerdRoot,
|
|
160
272
|
preflightSnapshot,
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$ --cleanup <uuid-or-session-name>` (issue #224): remove the Docker
|
|
3
|
+
* containers of one execution and the snapshot images they were derived from.
|
|
4
|
+
*
|
|
5
|
+
* For callers that remove containers themselves: start-command's completion
|
|
6
|
+
* watcher removes a snapshot image only when it removes the container. Nothing
|
|
7
|
+
* here is forced, so Docker keeps any container that is still running and any
|
|
8
|
+
* image that something else still uses; those are reported as retained.
|
|
9
|
+
*
|
|
10
|
+
* The containers are the current one and every earlier container of a
|
|
11
|
+
* `--resume` chain (`sessionNameHistory`). The images are the
|
|
12
|
+
* `start-command-resume/*` images named by the `start-command.snapshot-image`
|
|
13
|
+
* label of those containers, by the record, and by the `-resume-N` names of
|
|
14
|
+
* the chain, removed newest first.
|
|
15
|
+
*/
|
|
16
|
+
const { getDockerCommand } = require('./docker-cleanup');
|
|
17
|
+
const { runCommand } = require('./execution-control');
|
|
18
|
+
const { SNAPSHOT_IMAGE_LABEL } = require('./docker-snapshot-safety');
|
|
19
|
+
|
|
20
|
+
const SNAPSHOT_IMAGE_PATTERN = /^start-command-resume\/[a-z0-9._-]+:\d+$/;
|
|
21
|
+
const NOT_FOUND_PATTERN = /no such (container|image|object)/i;
|
|
22
|
+
|
|
23
|
+
/** Docker image name for snapshot attempt `attempt` of `sessionName`. */
|
|
24
|
+
function snapshotImageName(sessionName, attempt) {
|
|
25
|
+
const { buildSnapshotImageName } = require('./execution-resume');
|
|
26
|
+
return buildSnapshotImageName(sessionName, attempt);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Snapshot images implied by a `<name>-resume-<N>` chain, newest first. */
|
|
30
|
+
function chainImages(containerName) {
|
|
31
|
+
const images = [];
|
|
32
|
+
let current = String(containerName || '');
|
|
33
|
+
for (
|
|
34
|
+
let match = /^(.+)-resume-(\d+)$/.exec(current);
|
|
35
|
+
match;
|
|
36
|
+
match = /^(.+)-resume-(\d+)$/.exec(current)
|
|
37
|
+
) {
|
|
38
|
+
images.push(snapshotImageName(match[1], match[2]));
|
|
39
|
+
current = match[1];
|
|
40
|
+
}
|
|
41
|
+
return images;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function attemptOf(image) {
|
|
45
|
+
return Number(/:(\d+)$/.exec(image)?.[1] || 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function errorText(result) {
|
|
49
|
+
return (
|
|
50
|
+
String(result?.stderr || '').trim() ||
|
|
51
|
+
result?.error ||
|
|
52
|
+
`exit code ${result?.status}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The containers and images `--cleanup <execution>` would remove.
|
|
58
|
+
* @param {object} record - Execution record
|
|
59
|
+
* @param {Function} runner - Sync command runner
|
|
60
|
+
* @returns {{containers: string[], images: string[]}}
|
|
61
|
+
*/
|
|
62
|
+
function planExecutionCleanup(record, runner) {
|
|
63
|
+
const options = record.options || {};
|
|
64
|
+
const current = options.sessionName || options.containerId || null;
|
|
65
|
+
const containers = [
|
|
66
|
+
...new Set(
|
|
67
|
+
[current, ...[...(options.sessionNameHistory || [])].reverse()].filter(
|
|
68
|
+
Boolean
|
|
69
|
+
)
|
|
70
|
+
),
|
|
71
|
+
];
|
|
72
|
+
const images = [options.snapshotImage];
|
|
73
|
+
for (const container of containers) {
|
|
74
|
+
const labelled = runner(getDockerCommand(), [
|
|
75
|
+
'inspect',
|
|
76
|
+
'-f',
|
|
77
|
+
`{{index .Config.Labels "${SNAPSHOT_IMAGE_LABEL}"}}`,
|
|
78
|
+
container,
|
|
79
|
+
]);
|
|
80
|
+
if (labelled.success) {
|
|
81
|
+
images.push(String(labelled.stdout || '').trim());
|
|
82
|
+
}
|
|
83
|
+
images.push(...chainImages(container));
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
containers,
|
|
87
|
+
images: [...new Set(images)]
|
|
88
|
+
.filter((image) => SNAPSHOT_IMAGE_PATTERN.test(image || ''))
|
|
89
|
+
.sort((a, b) => attemptOf(b) - attemptOf(a)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Remove (or, with `dryRun`, list) one object without force.
|
|
95
|
+
* @returns {{name: string, kind: string, state: string, detail?: string}}
|
|
96
|
+
* `state` is removed, absent, retained or present (dry run)
|
|
97
|
+
*/
|
|
98
|
+
function removeObject(kind, name, runner, dryRun) {
|
|
99
|
+
const docker = getDockerCommand();
|
|
100
|
+
const args = dryRun
|
|
101
|
+
? kind === 'image'
|
|
102
|
+
? ['image', 'inspect', '--format', '{{.Id}}', name]
|
|
103
|
+
: ['container', 'inspect', '--format', '{{.Id}}', name]
|
|
104
|
+
: kind === 'image'
|
|
105
|
+
? ['rmi', name]
|
|
106
|
+
: ['rm', name];
|
|
107
|
+
const result = runner(docker, args);
|
|
108
|
+
if (result.success) {
|
|
109
|
+
return { kind, name, state: dryRun ? 'present' : 'removed' };
|
|
110
|
+
}
|
|
111
|
+
const detail = errorText(result);
|
|
112
|
+
if (!result.error && NOT_FOUND_PATTERN.test(detail)) {
|
|
113
|
+
return { kind, name, state: 'absent' };
|
|
114
|
+
}
|
|
115
|
+
return { kind, name, state: 'retained', detail };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LABELS = {
|
|
119
|
+
removed: 'Removed',
|
|
120
|
+
absent: 'Already gone:',
|
|
121
|
+
retained: 'Retained',
|
|
122
|
+
present: 'Would remove',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function formatOutcome(outcome) {
|
|
126
|
+
const line = `${LABELS[outcome.state]} ${outcome.kind} ${outcome.name}`;
|
|
127
|
+
return outcome.detail ? `${line} (${outcome.detail})` : line;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove one execution's containers, then its snapshot images.
|
|
132
|
+
* @param {?object} store - Execution store
|
|
133
|
+
* @param {string} identifier - UUID or session name
|
|
134
|
+
* @param {object} [deps] - {dryRun, runner}
|
|
135
|
+
* @returns {{success: boolean, output?: string, error?: string}}
|
|
136
|
+
*/
|
|
137
|
+
function cleanupExecution(store, identifier, deps = {}) {
|
|
138
|
+
if (!store) {
|
|
139
|
+
return { success: false, error: 'Execution tracking is disabled.' };
|
|
140
|
+
}
|
|
141
|
+
const record = store.get(identifier);
|
|
142
|
+
if (!record) {
|
|
143
|
+
return {
|
|
144
|
+
success: false,
|
|
145
|
+
error: `No execution found with UUID or session name: ${identifier}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (record.options?.isolated !== 'docker') {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
error: `--cleanup <execution> only removes Docker containers and images; ${identifier} is not a Docker execution.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const runner = deps.runner || runCommand;
|
|
155
|
+
const dryRun = deps.dryRun === true;
|
|
156
|
+
const plan = planExecutionCleanup(record, runner);
|
|
157
|
+
const outcomes = plan.containers.map((name) =>
|
|
158
|
+
removeObject('container', name, runner, dryRun)
|
|
159
|
+
);
|
|
160
|
+
// Images go only after their containers; Docker refuses a used image.
|
|
161
|
+
for (const image of plan.images) {
|
|
162
|
+
outcomes.push(removeObject('image', image, runner, dryRun));
|
|
163
|
+
}
|
|
164
|
+
const current = outcomes[0];
|
|
165
|
+
if (
|
|
166
|
+
!dryRun &&
|
|
167
|
+
current &&
|
|
168
|
+
['removed', 'absent'].includes(current.state) &&
|
|
169
|
+
typeof store.markContainerRemoved === 'function'
|
|
170
|
+
) {
|
|
171
|
+
store.markContainerRemoved(record.uuid, new Date().toISOString());
|
|
172
|
+
}
|
|
173
|
+
const retained = outcomes.filter((outcome) => outcome.state === 'retained');
|
|
174
|
+
const lines = outcomes.length
|
|
175
|
+
? outcomes.map(formatOutcome)
|
|
176
|
+
: ['No containers or snapshot images recorded for this execution.'];
|
|
177
|
+
const output = lines.join('\n');
|
|
178
|
+
if (retained.length > 0) {
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
output,
|
|
182
|
+
error: `${retained.length} Docker object(s) retained; nothing was forced.`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { success: true, output };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
SNAPSHOT_IMAGE_PATTERN,
|
|
190
|
+
chainImages,
|
|
191
|
+
cleanupExecution,
|
|
192
|
+
planExecutionCleanup,
|
|
193
|
+
};
|
|
@@ -17,10 +17,11 @@ const ControlAction = {
|
|
|
17
17
|
TERMINATE: 'terminate',
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
function runCommand(command, args) {
|
|
20
|
+
function runCommand(command, args, extra = {}) {
|
|
21
21
|
const options = {
|
|
22
22
|
encoding: 'utf8',
|
|
23
23
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
24
|
+
...(extra.timeout ? { timeout: extra.timeout } : {}),
|
|
24
25
|
};
|
|
25
26
|
const result = spawnSync(
|
|
26
27
|
command,
|
|
@@ -458,6 +458,8 @@ function recoverKilledExecution(params = {}) {
|
|
|
458
458
|
record.observedAt = undefined;
|
|
459
459
|
record.staleDetectedAt = undefined;
|
|
460
460
|
record.containerStartedAt = undefined;
|
|
461
|
+
record.containerState = undefined;
|
|
462
|
+
record.containerRemovedObservedAt = undefined;
|
|
461
463
|
const previousData = globalThis.structuredClone(store.get(executionId));
|
|
462
464
|
const previous = record.constructor.fromObject
|
|
463
465
|
? record.constructor.fromObject(previousData)
|
|
@@ -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
|
|