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.
@@ -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 {