start-command 0.34.0 → 0.35.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 +12 -0
- package/package.json +2 -2
- package/src/bin/cli.js +2 -1
- package/src/lib/args-parser.js +10 -0
- package/src/lib/command-stream.js +4 -2
- package/src/lib/detached-finalize.js +31 -7
- package/src/lib/docker-cleanup.js +55 -11
- package/src/lib/docker-post-mortem.js +93 -0
- package/src/lib/docker-recovery-options.js +84 -0
- package/src/lib/docker-resource-limits.js +211 -0
- package/src/lib/docker-runtime-args.js +141 -0
- package/src/lib/execution-control.js +39 -0
- package/src/lib/execution-recovery.js +350 -0
- package/src/lib/execution-resume.js +68 -4
- package/src/lib/exit-reason.js +24 -0
- package/src/lib/isolation-log-utils.js +9 -5
- package/src/lib/isolation.js +20 -92
- package/src/lib/spawn-helpers.js +4 -2
- package/src/lib/usage.js +3 -0
- package/test/docker-runtime-options.js +6 -0
- package/test/regression-170.js +4 -1
- package/test/regression-174.js +397 -0
- package/test/regression-176.js +897 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker resource limits that must survive a resume (issue #176).
|
|
3
|
+
*
|
|
4
|
+
* `docker commit` captures a container's filesystem and image config, but not
|
|
5
|
+
* its HostConfig. A snapshot-based resume (`$ --resume <id> -- <cmd>`) used to
|
|
6
|
+
* start the replacement container without the memory, CPU and PIDs limits the
|
|
7
|
+
* original one had — limits a supervisor typically applied with
|
|
8
|
+
* `docker update`, so they were never part of the stored launch options.
|
|
9
|
+
*
|
|
10
|
+
* This module reads the limits back out of `docker inspect` and translates the
|
|
11
|
+
* non-default ones into `docker run` flags. The flags are kept in the
|
|
12
|
+
* `--flag=value` form, so the same list is stored in the execution record,
|
|
13
|
+
* shown in `[Isolation]` status lines, and spliced into `docker run` as is.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { getDockerCommand } = require('./docker-cleanup');
|
|
17
|
+
|
|
18
|
+
/** Docker's default `/dev/shm` size; only a different size is re-applied. */
|
|
19
|
+
const DEFAULT_SHM_SIZE = 64 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
const KIB = 1024;
|
|
22
|
+
const MIB = KIB * 1024;
|
|
23
|
+
const GIB = MIB * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format a byte count with the largest binary unit `docker run` accepts that
|
|
27
|
+
* represents it exactly (`268435456` → `256m`).
|
|
28
|
+
* @param {number} bytes - Byte count
|
|
29
|
+
* @returns {string} Docker memory size
|
|
30
|
+
*/
|
|
31
|
+
function formatDockerBytes(bytes) {
|
|
32
|
+
for (const [unit, size] of [
|
|
33
|
+
['g', GIB],
|
|
34
|
+
['m', MIB],
|
|
35
|
+
['k', KIB],
|
|
36
|
+
]) {
|
|
37
|
+
if (bytes >= size && bytes % size === 0) {
|
|
38
|
+
return `${bytes / size}${unit}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return String(bytes);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format `NanoCpus` as the decimal `--cpus` value (`500000000` → `0.5`).
|
|
46
|
+
* @param {number} nanoCpus - CPU quota in units of 1e-9 CPUs
|
|
47
|
+
* @returns {string} Docker CPU count
|
|
48
|
+
*/
|
|
49
|
+
function formatDockerCpus(nanoCpus) {
|
|
50
|
+
return String(Number((nanoCpus / 1e9).toFixed(9)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function positive(value) {
|
|
54
|
+
const number = Number(value);
|
|
55
|
+
return Number.isFinite(number) && number > 0 ? number : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nonEmpty(value) {
|
|
59
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Translate an inspected `HostConfig` into `docker run` flags.
|
|
64
|
+
*
|
|
65
|
+
* Only limits that differ from Docker's defaults are emitted, and only in
|
|
66
|
+
* combinations `docker run` accepts: `--memory-swap` needs `--memory`, and
|
|
67
|
+
* `--cpus` cannot be combined with `--cpu-quota`/`--cpu-period`.
|
|
68
|
+
*
|
|
69
|
+
* @param {?object} hostConfig - `docker inspect` `.HostConfig`
|
|
70
|
+
* @returns {string[]} Flags in `--flag=value` form
|
|
71
|
+
*/
|
|
72
|
+
function parseDockerResourceLimits(hostConfig) {
|
|
73
|
+
if (!hostConfig || typeof hostConfig !== 'object') {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const h = hostConfig;
|
|
77
|
+
const limits = [];
|
|
78
|
+
|
|
79
|
+
const memory = positive(h.Memory);
|
|
80
|
+
if (memory) {
|
|
81
|
+
limits.push(`--memory=${formatDockerBytes(memory)}`);
|
|
82
|
+
const swap = Number(h.MemorySwap);
|
|
83
|
+
if (swap === -1) {
|
|
84
|
+
limits.push('--memory-swap=-1');
|
|
85
|
+
} else if (positive(swap)) {
|
|
86
|
+
limits.push(`--memory-swap=${formatDockerBytes(swap)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const reservation = positive(h.MemoryReservation);
|
|
90
|
+
if (reservation) {
|
|
91
|
+
limits.push(`--memory-reservation=${formatDockerBytes(reservation)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const nanoCpus = positive(h.NanoCpus);
|
|
95
|
+
if (nanoCpus) {
|
|
96
|
+
limits.push(`--cpus=${formatDockerCpus(nanoCpus)}`);
|
|
97
|
+
} else {
|
|
98
|
+
const quota = positive(h.CpuQuota);
|
|
99
|
+
if (quota) {
|
|
100
|
+
limits.push(`--cpu-quota=${quota}`);
|
|
101
|
+
}
|
|
102
|
+
const period = positive(h.CpuPeriod);
|
|
103
|
+
if (period) {
|
|
104
|
+
limits.push(`--cpu-period=${period}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const shares = positive(h.CpuShares);
|
|
108
|
+
if (shares) {
|
|
109
|
+
limits.push(`--cpu-shares=${shares}`);
|
|
110
|
+
}
|
|
111
|
+
const cpusetCpus = nonEmpty(h.CpusetCpus);
|
|
112
|
+
if (cpusetCpus) {
|
|
113
|
+
limits.push(`--cpuset-cpus=${cpusetCpus}`);
|
|
114
|
+
}
|
|
115
|
+
const cpusetMems = nonEmpty(h.CpusetMems);
|
|
116
|
+
if (cpusetMems) {
|
|
117
|
+
limits.push(`--cpuset-mems=${cpusetMems}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pids = positive(h.PidsLimit);
|
|
121
|
+
if (pids) {
|
|
122
|
+
limits.push(`--pids-limit=${pids}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const shm = positive(h.ShmSize);
|
|
126
|
+
if (shm && shm !== DEFAULT_SHM_SIZE) {
|
|
127
|
+
limits.push(`--shm-size=${formatDockerBytes(shm)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (h.StorageOpt && typeof h.StorageOpt === 'object') {
|
|
131
|
+
for (const key of Object.keys(h.StorageOpt).sort()) {
|
|
132
|
+
const value = nonEmpty(String(h.StorageOpt[key] ?? ''));
|
|
133
|
+
if (value) {
|
|
134
|
+
limits.push(`--storage-opt=${key}=${value}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const ulimit of Array.isArray(h.Ulimits) ? h.Ulimits : []) {
|
|
140
|
+
if (ulimit && nonEmpty(ulimit.Name)) {
|
|
141
|
+
limits.push(`--ulimit=${ulimit.Name}=${ulimit.Soft}:${ulimit.Hard}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return limits;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read a container's resource limits with `docker inspect`.
|
|
150
|
+
* @param {string} containerName - Container to inspect
|
|
151
|
+
* @param {Function} runner - `(command, args) => {success, stdout}`
|
|
152
|
+
* @returns {?string[]} Flags, or null when the container could not be read
|
|
153
|
+
*/
|
|
154
|
+
function readDockerResourceLimits(containerName, runner) {
|
|
155
|
+
let result;
|
|
156
|
+
try {
|
|
157
|
+
result = runner(getDockerCommand(), [
|
|
158
|
+
'inspect',
|
|
159
|
+
'--format',
|
|
160
|
+
'{{json .HostConfig}}',
|
|
161
|
+
containerName,
|
|
162
|
+
]);
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (!result || !result.success) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return parseDockerResourceLimits(JSON.parse(String(result.stdout).trim()));
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Normalize a stored `resourceLimits` value to a list of flags.
|
|
178
|
+
* @param {*} value - Value from an execution record or options object
|
|
179
|
+
* @returns {string[]} Flags
|
|
180
|
+
*/
|
|
181
|
+
function normalizeResourceLimits(value) {
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
return value.map(String).filter((flag) => flag.startsWith('--'));
|
|
184
|
+
}
|
|
185
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
186
|
+
return value.split(/\s+/).filter((flag) => flag.startsWith('--'));
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `[Isolation]` status line for resource limits, or null when there are none.
|
|
193
|
+
* @param {*} value - Stored `resourceLimits`
|
|
194
|
+
* @returns {?string} Status line
|
|
195
|
+
*/
|
|
196
|
+
function buildResourceLimitsStatusLine(value) {
|
|
197
|
+
const limits = normalizeResourceLimits(value);
|
|
198
|
+
return limits.length > 0
|
|
199
|
+
? `[Isolation] Resource limits: ${limits.join(' ')}`
|
|
200
|
+
: null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
DEFAULT_SHM_SIZE,
|
|
205
|
+
buildResourceLimitsStatusLine,
|
|
206
|
+
formatDockerBytes,
|
|
207
|
+
formatDockerCpus,
|
|
208
|
+
normalizeResourceLimits,
|
|
209
|
+
parseDockerResourceLimits,
|
|
210
|
+
readDockerResourceLimits,
|
|
211
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker runtime options shared by `docker run`, `[Isolation]` status lines
|
|
3
|
+
* and execution-record metadata: privileged mode, environment, volumes,
|
|
4
|
+
* mounts, networks (issue #150) and resource limits (issue #176).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { getDockerNetworks } = require('./docker-network-lifecycle');
|
|
8
|
+
const {
|
|
9
|
+
buildResourceLimitsStatusLine,
|
|
10
|
+
normalizeResourceLimits,
|
|
11
|
+
} = require('./docker-resource-limits');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Number of kill recoveries requested at launch; a recovery command on its own
|
|
15
|
+
* implies one (issue #176).
|
|
16
|
+
* @param {object} options - Options (onKillResume, recoveryCommand)
|
|
17
|
+
* @returns {?number} Attempt limit, or null when recovery is off
|
|
18
|
+
*/
|
|
19
|
+
function getOnKillResume(options = {}) {
|
|
20
|
+
const count = Number(options.onKillResume) || 0;
|
|
21
|
+
if (count > 0) {
|
|
22
|
+
return count;
|
|
23
|
+
}
|
|
24
|
+
return options.recoveryCommand ? 1 : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the docker run runtime argument list contributed by configurable
|
|
29
|
+
* container options: privileged mode, environment variables, volumes/bind
|
|
30
|
+
* mounts, --mount specs, network configuration and resource limits. Returned
|
|
31
|
+
* in a stable order so they can be spliced into the `docker run` argv before
|
|
32
|
+
* the image name.
|
|
33
|
+
* @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases, resourceLimits)
|
|
34
|
+
* @returns {string[]} Docker CLI arguments
|
|
35
|
+
*/
|
|
36
|
+
function buildDockerRuntimeArgs(options = {}) {
|
|
37
|
+
const args = [];
|
|
38
|
+
if (options.privileged) {
|
|
39
|
+
args.push('--privileged');
|
|
40
|
+
}
|
|
41
|
+
for (const envVar of options.env || []) {
|
|
42
|
+
args.push('-e', envVar);
|
|
43
|
+
}
|
|
44
|
+
for (const volume of options.volumes || []) {
|
|
45
|
+
args.push('-v', volume);
|
|
46
|
+
}
|
|
47
|
+
for (const mount of options.mounts || []) {
|
|
48
|
+
args.push('--mount', mount);
|
|
49
|
+
}
|
|
50
|
+
const [firstNetwork] = getDockerNetworks(options);
|
|
51
|
+
if (firstNetwork) {
|
|
52
|
+
args.push('--network', firstNetwork);
|
|
53
|
+
}
|
|
54
|
+
for (const alias of options.networkAliases || []) {
|
|
55
|
+
args.push('--network-alias', alias);
|
|
56
|
+
}
|
|
57
|
+
args.push(...normalizeResourceLimits(options.resourceLimits));
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the human-readable `[Isolation]` status lines for docker runtime
|
|
63
|
+
* options (volumes, mounts, env, privileged, networks, resource limits and
|
|
64
|
+
* the launch-time recovery command). Empty collections and a falsy privileged
|
|
65
|
+
* flag contribute no lines.
|
|
66
|
+
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand)
|
|
67
|
+
* @returns {string[]} Status lines for the start block / log header
|
|
68
|
+
*/
|
|
69
|
+
function buildDockerRuntimeStatusLines(options = {}) {
|
|
70
|
+
const lines = [];
|
|
71
|
+
if (options.volumes && options.volumes.length > 0) {
|
|
72
|
+
lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
if (options.mounts && options.mounts.length > 0) {
|
|
75
|
+
lines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
if (options.env && options.env.length > 0) {
|
|
78
|
+
lines.push(`[Isolation] Env: ${options.env.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
if (options.privileged) {
|
|
81
|
+
lines.push(`[Isolation] Privileged: true`);
|
|
82
|
+
}
|
|
83
|
+
const networks = getDockerNetworks(options);
|
|
84
|
+
if (networks.length > 0) {
|
|
85
|
+
lines.push(`[Isolation] Network: ${networks[0]}`);
|
|
86
|
+
}
|
|
87
|
+
if (networks.length > 1) {
|
|
88
|
+
lines.push(`[Isolation] Networks: ${networks.join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
if (options.networkAliases && options.networkAliases.length > 0) {
|
|
91
|
+
lines.push(
|
|
92
|
+
`[Isolation] Network aliases: ${options.networkAliases.join(', ')}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const limitsLine = buildResourceLimitsStatusLine(options.resourceLimits);
|
|
96
|
+
if (limitsLine) {
|
|
97
|
+
lines.push(limitsLine);
|
|
98
|
+
}
|
|
99
|
+
const onKillResume = getOnKillResume(options);
|
|
100
|
+
if (onKillResume) {
|
|
101
|
+
const what = options.recoveryCommand || 'the original command';
|
|
102
|
+
lines.push(
|
|
103
|
+
`[Isolation] On kill: resume up to ${onKillResume} time(s) with ${what}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return lines;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the execution-record metadata for docker runtime options, normalizing
|
|
111
|
+
* empty collections and a falsy privileged flag to `null`.
|
|
112
|
+
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand)
|
|
113
|
+
* @returns {object} Record metadata
|
|
114
|
+
*/
|
|
115
|
+
function buildDockerRuntimeMetadata(options = {}) {
|
|
116
|
+
const networks = getDockerNetworks(options);
|
|
117
|
+
const resourceLimits = normalizeResourceLimits(options.resourceLimits);
|
|
118
|
+
return {
|
|
119
|
+
volumes:
|
|
120
|
+
options.volumes && options.volumes.length > 0 ? options.volumes : null,
|
|
121
|
+
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
122
|
+
env: options.env && options.env.length > 0 ? options.env : null,
|
|
123
|
+
privileged: options.privileged || null,
|
|
124
|
+
network: networks[0] || null,
|
|
125
|
+
networks: networks.length > 0 ? networks : null,
|
|
126
|
+
networkAliases:
|
|
127
|
+
options.networkAliases && options.networkAliases.length > 0
|
|
128
|
+
? options.networkAliases
|
|
129
|
+
: null,
|
|
130
|
+
resourceLimits: resourceLimits.length > 0 ? resourceLimits : null,
|
|
131
|
+
onKillResume: getOnKillResume(options),
|
|
132
|
+
recoveryCommand: options.recoveryCommand || null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
buildDockerRuntimeArgs,
|
|
138
|
+
buildDockerRuntimeStatusLines,
|
|
139
|
+
buildDockerRuntimeMetadata,
|
|
140
|
+
getOnKillResume,
|
|
141
|
+
};
|
|
@@ -268,6 +268,40 @@ function formatControlResultAsLinksNotation(result) {
|
|
|
268
268
|
return lines.join('\n');
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Record that a stop was requested, for sessions with launch-time kill
|
|
273
|
+
* recovery (issue #176).
|
|
274
|
+
* @param {object} store - Execution store
|
|
275
|
+
* @param {object} record - Execution record
|
|
276
|
+
* @returns {string|undefined} The previous marker, to restore on failure
|
|
277
|
+
*/
|
|
278
|
+
function markStopRequested(store, record) {
|
|
279
|
+
const options = record.options || {};
|
|
280
|
+
if (!options.onKillResume) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
const previous = options.stopRequestedAt;
|
|
284
|
+
record.options = { ...options, stopRequestedAt: new Date().toISOString() };
|
|
285
|
+
try {
|
|
286
|
+
store.save(record);
|
|
287
|
+
} catch {
|
|
288
|
+
// Best effort: without the marker the session may be recovered once more.
|
|
289
|
+
}
|
|
290
|
+
return previous;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function restoreStopRequested(store, record, previous) {
|
|
294
|
+
if (!record.options || !record.options.onKillResume) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
record.options = { ...record.options, stopRequestedAt: previous };
|
|
298
|
+
try {
|
|
299
|
+
store.save(record);
|
|
300
|
+
} catch {
|
|
301
|
+
// Best effort, see markStopRequested.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
271
305
|
function controlExecution(store, identifier, action, runner = runCommand) {
|
|
272
306
|
if (!store) {
|
|
273
307
|
return { success: false, error: 'Execution tracking is disabled.' };
|
|
@@ -286,8 +320,13 @@ function controlExecution(store, identifier, action, runner = runCommand) {
|
|
|
286
320
|
return { success: false, error: control.error };
|
|
287
321
|
}
|
|
288
322
|
|
|
323
|
+
// A deliberate stop must not be mistaken for a kill that launch-time
|
|
324
|
+
// recovery should undo (issue #176): `docker stop` escalates to SIGKILL,
|
|
325
|
+
// which exits 137 just like the OOM killer.
|
|
326
|
+
const previousStopRequestedAt = markStopRequested(store, record);
|
|
289
327
|
const result = runner(control.command, control.args);
|
|
290
328
|
if (!result.success) {
|
|
329
|
+
restoreStopRequested(store, record, previousStopRequestedAt);
|
|
291
330
|
const detail =
|
|
292
331
|
result.stderr || result.error || `exit code ${result.status}`;
|
|
293
332
|
return {
|