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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steps around the `docker commit` of a snapshot resume (issue #224):
|
|
3
|
+
*
|
|
4
|
+
* - `--snapshot-prepare <command>` runs in the stopped original before the
|
|
5
|
+
* commit: `docker start`, `docker exec ... sh -c <command>`, and always a
|
|
6
|
+
* `docker kill` afterwards. Starting the original runs its own command
|
|
7
|
+
* again, so this is only for containers whose start is known to be safe.
|
|
8
|
+
* - Progress lines while `docker commit` runs, which prints nothing itself.
|
|
9
|
+
* - `--remove-original-after <seconds>`: remove the stopped original only if
|
|
10
|
+
* the replacement is still running after the delay.
|
|
11
|
+
*/
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
const { setInterval, clearInterval, setTimeout } = globalThis;
|
|
14
|
+
const { appendLogFile } = require('./isolation-log-utils');
|
|
15
|
+
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PROGRESS_INTERVAL_SECONDS = 60;
|
|
18
|
+
/** Upper bound for one `--snapshot-prepare`; the original is killed after. */
|
|
19
|
+
const DEFAULT_PREPARE_TIMEOUT_SECONDS = 600;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format a duration the way the progress lines print it: "45s", "12 min".
|
|
23
|
+
* @param {number} seconds - Elapsed seconds
|
|
24
|
+
* @returns {string} Human-readable duration
|
|
25
|
+
*/
|
|
26
|
+
function formatElapsed(seconds) {
|
|
27
|
+
const whole = Math.max(0, Math.floor(seconds));
|
|
28
|
+
if (whole < 60) {
|
|
29
|
+
return `${whole}s`;
|
|
30
|
+
}
|
|
31
|
+
const minutes = Math.floor(whole / 60);
|
|
32
|
+
const rest = whole % 60;
|
|
33
|
+
return rest ? `${minutes} min ${rest}s` : `${minutes} min`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function positiveSeconds(value, fallback) {
|
|
37
|
+
const seconds = Number(value);
|
|
38
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function progressIntervalSeconds(env = process.env) {
|
|
42
|
+
return positiveSeconds(
|
|
43
|
+
env.START_SNAPSHOT_PROGRESS_INTERVAL,
|
|
44
|
+
DEFAULT_PROGRESS_INTERVAL_SECONDS
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Report one line to the execution log and through `notify`. */
|
|
49
|
+
function report(context, line) {
|
|
50
|
+
if (context.logPath) {
|
|
51
|
+
appendLogFile(context.logPath, `${line}\n`);
|
|
52
|
+
}
|
|
53
|
+
context.notify(line);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run a command without blocking the event loop.
|
|
58
|
+
* @returns {Promise<{success, stdout, stderr, status, error}>}
|
|
59
|
+
*/
|
|
60
|
+
function runCommandAsync(command, args) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const options = { stdio: ['ignore', 'pipe', 'pipe'] };
|
|
63
|
+
const child = spawn(
|
|
64
|
+
command,
|
|
65
|
+
args,
|
|
66
|
+
command === getDockerCommand() ? getDockerSpawnOptions(options) : options
|
|
67
|
+
);
|
|
68
|
+
let stdout = '';
|
|
69
|
+
let stderr = '';
|
|
70
|
+
child.stdout.on('data', (chunk) => {
|
|
71
|
+
stdout += chunk;
|
|
72
|
+
});
|
|
73
|
+
child.stderr.on('data', (chunk) => {
|
|
74
|
+
stderr += chunk;
|
|
75
|
+
});
|
|
76
|
+
child.on('error', (error) => {
|
|
77
|
+
resolve({
|
|
78
|
+
success: false,
|
|
79
|
+
stdout,
|
|
80
|
+
stderr,
|
|
81
|
+
status: null,
|
|
82
|
+
error: error.message,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
child.on('close', (status) => {
|
|
86
|
+
resolve({ success: status === 0, stdout, stderr, status, error: null });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run `docker commit` and report the elapsed time every interval.
|
|
93
|
+
* @param {{command: string, args: string[]}} step - The commit step
|
|
94
|
+
* @param {object} context - {sessionName, logPath, notify, runAsync,
|
|
95
|
+
* intervalSeconds}
|
|
96
|
+
* @returns {Promise<object>} The runner result
|
|
97
|
+
*/
|
|
98
|
+
async function commitWithProgress(step, context) {
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
const intervalSeconds = context.intervalSeconds ?? progressIntervalSeconds();
|
|
101
|
+
const timer = setInterval(() => {
|
|
102
|
+
const elapsed = formatElapsed((Date.now() - started) / 1000);
|
|
103
|
+
report(
|
|
104
|
+
context,
|
|
105
|
+
`[Resume] snapshotting ${context.sessionName}: ${elapsed} elapsed (docker commit reports no progress)`
|
|
106
|
+
);
|
|
107
|
+
}, intervalSeconds * 1000);
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await context.runAsync(step.command, step.args);
|
|
111
|
+
} finally {
|
|
112
|
+
clearInterval(timer);
|
|
113
|
+
}
|
|
114
|
+
if (result && result.success) {
|
|
115
|
+
report(
|
|
116
|
+
context,
|
|
117
|
+
`[Resume] snapshot of ${context.sessionName} committed after ${formatElapsed((Date.now() - started) / 1000)}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function detail(result, fallback) {
|
|
124
|
+
return (
|
|
125
|
+
String(result?.stderr || '').trim() ||
|
|
126
|
+
result?.error ||
|
|
127
|
+
(result?.status !== undefined && result?.status !== null
|
|
128
|
+
? `exit code ${result.status}`
|
|
129
|
+
: fallback)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Run `--snapshot-prepare` in the stopped original. A failure is reported and
|
|
135
|
+
* the snapshot continues with the files as they are.
|
|
136
|
+
* @param {string} sessionName - Original container
|
|
137
|
+
* @param {string} prepare - Shell command run with `sh -c`
|
|
138
|
+
* @param {Function} runner - Sync command runner
|
|
139
|
+
* @param {object} context - {logPath, notify}
|
|
140
|
+
* @returns {{success: boolean, message: string}}
|
|
141
|
+
*/
|
|
142
|
+
function runSnapshotPrepare(sessionName, prepare, runner, context) {
|
|
143
|
+
const docker = getDockerCommand();
|
|
144
|
+
const started = runner(docker, ['start', sessionName]);
|
|
145
|
+
if (!started.success) {
|
|
146
|
+
const message = `[Resume] snapshot prepare skipped: could not start ${sessionName}: ${detail(started, 'docker start failed')}`;
|
|
147
|
+
report(context, message);
|
|
148
|
+
return { success: false, message };
|
|
149
|
+
}
|
|
150
|
+
let result;
|
|
151
|
+
try {
|
|
152
|
+
const timeout =
|
|
153
|
+
positiveSeconds(
|
|
154
|
+
process.env.START_SNAPSHOT_PREPARE_TIMEOUT,
|
|
155
|
+
DEFAULT_PREPARE_TIMEOUT_SECONDS
|
|
156
|
+
) * 1000;
|
|
157
|
+
result = runner(docker, ['exec', sessionName, 'sh', '-c', prepare], {
|
|
158
|
+
timeout,
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
result = { success: false, error: error.message };
|
|
162
|
+
} finally {
|
|
163
|
+
// Never commit a running original, and never leave it running.
|
|
164
|
+
runner(docker, ['kill', sessionName]);
|
|
165
|
+
runner(docker, ['wait', sessionName]);
|
|
166
|
+
}
|
|
167
|
+
const success = Boolean(result.success);
|
|
168
|
+
const message = success
|
|
169
|
+
? `[Resume] snapshot prepare finished in ${sessionName}`
|
|
170
|
+
: `[Resume] snapshot prepare failed in ${sessionName} (${detail(result, 'docker exec failed')}); snapshotting the files as they are`;
|
|
171
|
+
report(context, message);
|
|
172
|
+
return { success, message };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sleep(ms) {
|
|
176
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Remove the stopped original once the replacement has survived the delay.
|
|
181
|
+
* @param {object} plan - Resume plan with sessionName and newSessionName
|
|
182
|
+
* @param {number} delaySeconds - Startup window
|
|
183
|
+
* @param {Function} runner - Sync command runner
|
|
184
|
+
* @param {object} context - {logPath, notify, sleep}
|
|
185
|
+
* @returns {Promise<string>} The note describing what happened
|
|
186
|
+
*/
|
|
187
|
+
async function removeOriginalAfter(plan, delaySeconds, runner, context) {
|
|
188
|
+
report(
|
|
189
|
+
context,
|
|
190
|
+
`[Resume] Checking ${plan.newSessionName} again in ${formatElapsed(delaySeconds)} before removing ${plan.sessionName}`
|
|
191
|
+
);
|
|
192
|
+
await (context.sleep || sleep)(delaySeconds * 1000);
|
|
193
|
+
const docker = getDockerCommand();
|
|
194
|
+
const running = runner(docker, [
|
|
195
|
+
'inspect',
|
|
196
|
+
'-f',
|
|
197
|
+
'{{.State.Running}}',
|
|
198
|
+
plan.newSessionName,
|
|
199
|
+
]);
|
|
200
|
+
let note;
|
|
201
|
+
if (running.success && String(running.stdout).trim() === 'true') {
|
|
202
|
+
const removed = runner(docker, ['rm', plan.sessionName]);
|
|
203
|
+
note = removed.success
|
|
204
|
+
? `Stopped original container removed: ${plan.sessionName}`
|
|
205
|
+
: `Original container retained: ${detail(removed, 'docker rm failed')}`;
|
|
206
|
+
} else {
|
|
207
|
+
note = `Original container retained: the snapshot container is not running ${formatElapsed(delaySeconds)} after launch.`;
|
|
208
|
+
}
|
|
209
|
+
if (context.logPath) {
|
|
210
|
+
appendLogFile(context.logPath, `${note}\n`);
|
|
211
|
+
}
|
|
212
|
+
return note;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
commitWithProgress,
|
|
217
|
+
formatElapsed,
|
|
218
|
+
progressIntervalSeconds,
|
|
219
|
+
removeOriginalAfter,
|
|
220
|
+
runCommandAsync,
|
|
221
|
+
runSnapshotPrepare,
|
|
222
|
+
};
|
|
@@ -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)
|