start-command 0.35.4 → 0.37.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 +14 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/bin/cli.js +48 -7
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +32 -1
- package/src/lib/atomic-write.js +34 -0
- package/src/lib/attached-diagnostics.js +90 -0
- package/src/lib/cgroup-memory.js +43 -11
- package/src/lib/command-builder.js +43 -0
- package/src/lib/cpu-penalty-monitor.js +226 -0
- package/src/lib/cpu-penalty.js +175 -0
- package/src/lib/detached-finalize.js +16 -0
- package/src/lib/docker-cleanup.js +35 -7
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- package/src/lib/docker-resource-limits.js +28 -0
- package/src/lib/docker-resource-options.js +180 -0
- package/src/lib/docker-runtime-args.js +23 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +153 -30
- package/src/lib/execution-resume-all.js +11 -0
- package/src/lib/execution-resume.js +314 -54
- package/src/lib/execution-store.js +25 -121
- package/src/lib/exit-evidence.js +123 -0
- package/src/lib/exit-reason.js +24 -6
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +48 -7
- package/src/lib/launch-owner.js +25 -0
- package/src/lib/launch-persistence.js +54 -0
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +18 -6
- package/src/lib/resume-resources.js +88 -0
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/status-formatter.js +28 -0
- package/src/lib/store-lock.js +133 -0
- package/src/lib/usage.js +16 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +10 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +19 -11
- package/test/exit-reason.js +6 -1
- package/test/failure-handler.js +11 -0
- package/test/isolation.js +5 -4
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-174.js +3 -1
- package/test/regression-176.js +7 -0
- package/test/regression-180.js +17 -10
- package/test/regression-182.js +7 -3
- package/test/regression-185.js +4 -9
- package/test/regression-187.js +11 -2
- package/test/regression-189.js +224 -0
- package/test/regression-190.js +111 -0
- package/test/regression-191.js +160 -0
- package/test/regression-192.js +66 -0
- package/test/regression-193.js +251 -0
- package/test/regression-194.js +182 -0
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +54 -0
- package/test/status-query.js +97 -15
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** Launch and recovery resource specifications, resolved against the daemon. */
|
|
2
|
+
const { getDockerCommand } = require('./docker-cleanup');
|
|
3
|
+
const { normalizeResourceLimits } = require('./docker-resource-limits');
|
|
4
|
+
const FIELDS = {
|
|
5
|
+
'--memory': 'memory',
|
|
6
|
+
'--memory-swap': 'memorySwap',
|
|
7
|
+
'--cpus': 'cpus',
|
|
8
|
+
'--on-kill-resume-memory': 'onKillResumeMemory',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function parseResourceSpec(value, cpu = false) {
|
|
12
|
+
const text = String(value).trim().toLowerCase();
|
|
13
|
+
const percent = /^(\d+(?:\.\d+)?)%(?:-(\d+(?:\.\d+)?)%)?$/.exec(text);
|
|
14
|
+
if (percent) {
|
|
15
|
+
const min = Number(percent[1]),
|
|
16
|
+
max = Number(percent[2] || percent[1]);
|
|
17
|
+
if (min > 0 && max <= 100 && min <= max) {
|
|
18
|
+
return { min, max, percent: true };
|
|
19
|
+
}
|
|
20
|
+
} else {
|
|
21
|
+
const absolute = /^(\d+(?:\.\d+)?)(b|k|m|g|t|kb|mb|gb|tb)?$/.exec(text);
|
|
22
|
+
if (absolute && (!cpu || !absolute[2])) {
|
|
23
|
+
const power = ['', 'k', 'm', 'g', 't'].indexOf(
|
|
24
|
+
(absolute[2] || '').replace('b', '')
|
|
25
|
+
);
|
|
26
|
+
const amount =
|
|
27
|
+
Number(absolute[1]) * (cpu ? 1 : 1024 ** Math.max(0, power));
|
|
28
|
+
if (
|
|
29
|
+
Number.isFinite(amount) &&
|
|
30
|
+
amount > 0 &&
|
|
31
|
+
amount <= Number.MAX_SAFE_INTEGER
|
|
32
|
+
) {
|
|
33
|
+
return { amount };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Invalid resource limit: "${value}". Expected a positive ${cpu ? 'CPU count' : 'Docker size'}, N% or MIN%-MAX% within (0, 100].`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parse(args, index, options) {
|
|
43
|
+
const [flag, inline] = args[index].split('=', 2);
|
|
44
|
+
if (!FIELDS[flag]) {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const value = inline ?? args[index + 1];
|
|
48
|
+
if (!value || value.startsWith('-')) {
|
|
49
|
+
throw new Error(`Option ${flag} requires a resource limit`);
|
|
50
|
+
}
|
|
51
|
+
parseResourceSpec(value, flag === '--cpus');
|
|
52
|
+
options[FIELDS[flag]] = value;
|
|
53
|
+
return inline === undefined ? 2 : 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validate(options) {
|
|
57
|
+
if (
|
|
58
|
+
!Object.values(FIELDS).some(
|
|
59
|
+
(field) => options[field] !== null && options[field] !== undefined
|
|
60
|
+
)
|
|
61
|
+
) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
!options.resume &&
|
|
66
|
+
!(options.isolatedStack || [options.isolated]).includes('docker')
|
|
67
|
+
) {
|
|
68
|
+
throw new Error('Resource options require Docker isolation or --resume');
|
|
69
|
+
}
|
|
70
|
+
if (options.memorySwap && !options.memory && !options.resume) {
|
|
71
|
+
throw new Error('--memory-swap requires --memory');
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
options.onKillResumeMemory &&
|
|
75
|
+
!options.onKillResume &&
|
|
76
|
+
!options.recoveryCommand
|
|
77
|
+
) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'--on-kill-resume-memory requires --on-kill-resume or --recovery-command'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readDaemonCapacity(runner) {
|
|
85
|
+
const result = runner(getDockerCommand(), ['info', '--format', '{{json .}}']);
|
|
86
|
+
if (!result?.success) {
|
|
87
|
+
throw new Error('Cannot read resource capacity from Docker daemon');
|
|
88
|
+
}
|
|
89
|
+
const info = JSON.parse(result.stdout);
|
|
90
|
+
if (!(info.MemTotal > 0) || !(info.NCPU > 0)) {
|
|
91
|
+
throw new Error('Docker daemon did not report MemTotal/NCPU');
|
|
92
|
+
}
|
|
93
|
+
return { memory: info.MemTotal, cpus: info.NCPU };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveResourceOptions(options, runner, random = Math.random) {
|
|
97
|
+
const specs = {};
|
|
98
|
+
for (const field of ['memory', 'memorySwap', 'cpus']) {
|
|
99
|
+
if (options[field] !== null && options[field] !== undefined) {
|
|
100
|
+
specs[field] = parseResourceSpec(options[field], field === 'cpus');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const capacity = Object.values(specs).some((s) => s.percent)
|
|
104
|
+
? readDaemonCapacity(runner)
|
|
105
|
+
: null;
|
|
106
|
+
const resolved = {};
|
|
107
|
+
const limits = normalizeResourceLimits(options.resourceLimits);
|
|
108
|
+
for (const [field, spec] of Object.entries(specs)) {
|
|
109
|
+
const amount = spec.percent
|
|
110
|
+
? (capacity[field === 'cpus' ? 'cpus' : 'memory'] *
|
|
111
|
+
(spec.min + (spec.max - spec.min) * random())) /
|
|
112
|
+
100
|
|
113
|
+
: spec.amount;
|
|
114
|
+
resolved[field] =
|
|
115
|
+
field === 'cpus' ? Math.floor(amount * 1e9) / 1e9 : Math.floor(amount);
|
|
116
|
+
if (resolved[field] < (field === 'cpus' ? 0.01 : 6 * 1024 * 1024)) {
|
|
117
|
+
throw new Error(`Resolved ${field} is below Docker's minimum`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
resolved.memory !== null &&
|
|
122
|
+
resolved.memory !== undefined &&
|
|
123
|
+
(resolved.memorySwap === null || resolved.memorySwap === undefined)
|
|
124
|
+
) {
|
|
125
|
+
resolved.memorySwap = resolved.memory;
|
|
126
|
+
}
|
|
127
|
+
const existingMemory = limits
|
|
128
|
+
.find((flag) => flag.startsWith('--memory='))
|
|
129
|
+
?.slice(9);
|
|
130
|
+
const memory =
|
|
131
|
+
resolved.memory ??
|
|
132
|
+
(existingMemory ? parseResourceSpec(existingMemory).amount : null);
|
|
133
|
+
if (
|
|
134
|
+
resolved.memorySwap !== null &&
|
|
135
|
+
resolved.memorySwap !== undefined &&
|
|
136
|
+
(memory === null || memory === undefined || resolved.memorySwap < memory)
|
|
137
|
+
) {
|
|
138
|
+
throw new Error('memory-swap must be at least memory');
|
|
139
|
+
}
|
|
140
|
+
for (const [field, amount] of Object.entries(resolved)) {
|
|
141
|
+
const flag = field === 'memorySwap' ? '--memory-swap' : `--${field}`;
|
|
142
|
+
for (let i = limits.length - 1; i >= 0; i--) {
|
|
143
|
+
if (
|
|
144
|
+
limits[i].startsWith(`${flag}=`) ||
|
|
145
|
+
(field === 'cpus' && /^--cpu-(quota|period)=/.test(limits[i]))
|
|
146
|
+
) {
|
|
147
|
+
limits.splice(i, 1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
limits.push(`${flag}=${amount}`);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
resourceLimits: limits,
|
|
154
|
+
resolvedLimits: resolved,
|
|
155
|
+
resourceLimitSpecs: Object.fromEntries(
|
|
156
|
+
Object.keys(specs).map((field) => [field, options[field]])
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function limitsLogLine(options) {
|
|
162
|
+
const values = options.resolvedLimits || {};
|
|
163
|
+
return Object.keys(values).length
|
|
164
|
+
? `Limits: ${Object.entries(values)
|
|
165
|
+
.map(
|
|
166
|
+
([field, value]) =>
|
|
167
|
+
`${field}=${value}${options.resourceLimitSpecs?.[field] ? ` (${options.resourceLimitSpecs[field]} -> ${value})` : ''}`
|
|
168
|
+
)
|
|
169
|
+
.join(' ')}`
|
|
170
|
+
: null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
parse,
|
|
175
|
+
validate,
|
|
176
|
+
parseResourceSpec,
|
|
177
|
+
readDaemonCapacity,
|
|
178
|
+
resolveResourceOptions,
|
|
179
|
+
limitsLogLine,
|
|
180
|
+
};
|
|
@@ -42,6 +42,9 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
42
42
|
for (const envVar of options.env || []) {
|
|
43
43
|
args.push('-e', envVar);
|
|
44
44
|
}
|
|
45
|
+
for (const label of require('./docker-labels').dockerLabels(options)) {
|
|
46
|
+
args.push('--label', label);
|
|
47
|
+
}
|
|
45
48
|
for (const volume of options.volumes || []) {
|
|
46
49
|
args.push('-v', volume);
|
|
47
50
|
}
|
|
@@ -69,6 +72,9 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
69
72
|
*/
|
|
70
73
|
function buildDockerRuntimeStatusLines(options = {}) {
|
|
71
74
|
const lines = [];
|
|
75
|
+
if (options.labels && options.labels.length > 0) {
|
|
76
|
+
lines.push(`[Isolation] Labels: ${options.labels.join(', ')}`);
|
|
77
|
+
}
|
|
72
78
|
if (options.volumes && options.volumes.length > 0) {
|
|
73
79
|
lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
|
|
74
80
|
}
|
|
@@ -97,6 +103,12 @@ function buildDockerRuntimeStatusLines(options = {}) {
|
|
|
97
103
|
if (limitsLine) {
|
|
98
104
|
lines.push(limitsLine);
|
|
99
105
|
}
|
|
106
|
+
const limitsSummary = require('./docker-resource-options').limitsLogLine(
|
|
107
|
+
options
|
|
108
|
+
);
|
|
109
|
+
if (limitsSummary) {
|
|
110
|
+
lines.push(limitsSummary);
|
|
111
|
+
}
|
|
100
112
|
const onKillResume = getOnKillResume(options);
|
|
101
113
|
if (onKillResume) {
|
|
102
114
|
const what = options.recoveryCommand || 'the original command';
|
|
@@ -123,6 +135,8 @@ function buildDockerRuntimeMetadata(options = {}) {
|
|
|
123
135
|
options.volumes && options.volumes.length > 0 ? options.volumes : null,
|
|
124
136
|
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
125
137
|
env: options.env && options.env.length > 0 ? options.env : null,
|
|
138
|
+
labels: options.labels && options.labels.length > 0 ? options.labels : null,
|
|
139
|
+
commandHandoff: options.detached ? true : null,
|
|
126
140
|
privileged: options.privileged || null,
|
|
127
141
|
network: networks[0] || null,
|
|
128
142
|
networks: networks.length > 0 ? networks : null,
|
|
@@ -131,6 +145,15 @@ function buildDockerRuntimeMetadata(options = {}) {
|
|
|
131
145
|
? options.networkAliases
|
|
132
146
|
: null,
|
|
133
147
|
resourceLimits: resourceLimits.length > 0 ? resourceLimits : null,
|
|
148
|
+
resolvedLimits: options.resolvedLimits || null,
|
|
149
|
+
resourceLimitSpecs: options.resourceLimitSpecs || null,
|
|
150
|
+
onKillResumeMemory: options.onKillResumeMemory || null,
|
|
151
|
+
...(options.cpuPenaltyConfig
|
|
152
|
+
? {
|
|
153
|
+
cpuPenaltyConfig: options.cpuPenaltyConfig,
|
|
154
|
+
baseResourceLimits: resourceLimits,
|
|
155
|
+
}
|
|
156
|
+
: {}),
|
|
134
157
|
onKillResume: getOnKillResume(options),
|
|
135
158
|
recoveryCommand: options.recoveryCommand || null,
|
|
136
159
|
onKillResumeDelay: getOnKillResume(options)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** Serialize costly snapshot resumes and check every image-store filesystem. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { LockManager } = require('./store-lock');
|
|
6
|
+
|
|
7
|
+
const GiB = 1024 ** 3;
|
|
8
|
+
const SNAPSHOT_RESERVE_BYTES = 10 * GiB;
|
|
9
|
+
const SNAPSHOT_IMAGE_LABEL = 'start-command.snapshot-image';
|
|
10
|
+
|
|
11
|
+
function acquireSnapshotLock(options = {}) {
|
|
12
|
+
const lockPath =
|
|
13
|
+
options.lockPath ||
|
|
14
|
+
process.env.START_SNAPSHOT_LOCK ||
|
|
15
|
+
path.join(
|
|
16
|
+
process.platform === 'win32' ? os.tmpdir() : '/tmp',
|
|
17
|
+
'start-command-docker-snapshot.lock'
|
|
18
|
+
);
|
|
19
|
+
const lock = new LockManager(lockPath);
|
|
20
|
+
// The ordinary store lock is held only for a brief write. A snapshot can
|
|
21
|
+
// legitimately take hours, so never reclaim this lock merely for its age.
|
|
22
|
+
lock.isLockStale = (data) => {
|
|
23
|
+
if (
|
|
24
|
+
!data ||
|
|
25
|
+
data.hostname !== os.hostname() ||
|
|
26
|
+
!Number.isInteger(data.pid) ||
|
|
27
|
+
data.pid <= 0
|
|
28
|
+
) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
process.kill(data.pid, 0);
|
|
33
|
+
return false;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return error.code === 'ESRCH';
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
if (!lock.acquire(1000)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'A Docker snapshot resume is in progress on this host. Retry after it finishes.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return lock;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function containerdRoot(config = '') {
|
|
47
|
+
// Only the root-level TOML key configures containerd's storage root.
|
|
48
|
+
const topLevel = config.split(/^\s*\[/m)[0];
|
|
49
|
+
return (
|
|
50
|
+
topLevel.match(/^\s*root\s*=\s*["']([^"']+)["']/m)?.[1] ||
|
|
51
|
+
'/var/lib/containerd'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function defaultContainerdRoot() {
|
|
56
|
+
if (process.env.START_CONTAINERD_ROOT) {
|
|
57
|
+
return process.env.START_CONTAINERD_ROOT;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return containerdRoot(
|
|
61
|
+
fs.readFileSync('/etc/containerd/config.toml', 'utf8')
|
|
62
|
+
);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT') {
|
|
65
|
+
return containerdRoot();
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readFreeBytes(root) {
|
|
72
|
+
const stats = fs.statfsSync(root, { bigint: true });
|
|
73
|
+
return Number(stats.bavail * stats.bsize);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function preflightSnapshot(containerName, runner, options = {}) {
|
|
77
|
+
const docker = require('./docker-cleanup').getDockerCommand();
|
|
78
|
+
const sizeResult = runner(docker, [
|
|
79
|
+
'inspect',
|
|
80
|
+
'--size',
|
|
81
|
+
'--format',
|
|
82
|
+
'{{.SizeRw}}',
|
|
83
|
+
containerName,
|
|
84
|
+
]);
|
|
85
|
+
const size = Number(String(sizeResult.stdout || '').trim());
|
|
86
|
+
if (
|
|
87
|
+
!sizeResult.success ||
|
|
88
|
+
!/^\d+$/.test(String(sizeResult.stdout || '').trim()) ||
|
|
89
|
+
!Number.isSafeInteger(size) ||
|
|
90
|
+
size < 0
|
|
91
|
+
) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'Cannot determine the writable layer size; refusing Docker snapshot resume.'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const infoResult = runner(docker, ['info', '--format', '{{json .}}']);
|
|
97
|
+
let info;
|
|
98
|
+
try {
|
|
99
|
+
info = JSON.parse(infoResult.stdout);
|
|
100
|
+
} catch {
|
|
101
|
+
/* Refuse unknown storage. */
|
|
102
|
+
}
|
|
103
|
+
if (!infoResult.success || !info?.DockerRootDir) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
'Cannot determine Docker data root; refusing Docker snapshot resume.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const roots = [info.DockerRootDir];
|
|
109
|
+
if (
|
|
110
|
+
JSON.stringify(info.DriverStatus || []).includes(
|
|
111
|
+
'io.containerd.snapshotter'
|
|
112
|
+
)
|
|
113
|
+
) {
|
|
114
|
+
roots.push(options.containerdRoot || defaultContainerdRoot());
|
|
115
|
+
}
|
|
116
|
+
const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
|
|
117
|
+
const free = options.freeBytes || readFreeBytes;
|
|
118
|
+
for (const root of [...new Set(roots)]) {
|
|
119
|
+
let available;
|
|
120
|
+
try {
|
|
121
|
+
available = free(root);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Cannot check free disk at ${root}: ${error.message}; refusing Docker snapshot resume.`,
|
|
125
|
+
{ cause: error }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!Number.isFinite(available) || available < required) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Insufficient disk at ${root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(available / GiB).toFixed(2)} GiB. Docker snapshot resume refused.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
size,
|
|
136
|
+
required,
|
|
137
|
+
roots,
|
|
138
|
+
message: `[Resume] snapshotting ${(size / GiB).toFixed(2)} GiB; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function snapshotImageCleanupSnippet(
|
|
143
|
+
containerName,
|
|
144
|
+
redirection = '>/dev/null 2>&1'
|
|
145
|
+
) {
|
|
146
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
147
|
+
const name = shellQuote(containerName);
|
|
148
|
+
const format = shellQuote(
|
|
149
|
+
`{{index .Config.Labels "${SNAPSHOT_IMAGE_LABEL}"}}`
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
capture: `__start_snapshot_image=$(docker inspect -f ${format} ${name} 2>/dev/null || true)`,
|
|
153
|
+
remove: `case "$__start_snapshot_image" in start-command-resume/*) docker rmi "$__start_snapshot_image" ${redirection} || true;; esac`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
acquireSnapshotLock,
|
|
159
|
+
containerdRoot,
|
|
160
|
+
preflightSnapshot,
|
|
161
|
+
SNAPSHOT_RESERVE_BYTES,
|
|
162
|
+
SNAPSHOT_IMAGE_LABEL,
|
|
163
|
+
snapshotImageCleanupSnippet,
|
|
164
|
+
};
|
|
@@ -39,6 +39,11 @@ const {
|
|
|
39
39
|
readDockerResourceLimits,
|
|
40
40
|
} = require('./docker-resource-limits');
|
|
41
41
|
const { shellQuote } = require('./isolation-log-utils');
|
|
42
|
+
const {
|
|
43
|
+
reserveLaunch,
|
|
44
|
+
rollbackLaunch,
|
|
45
|
+
failLaunchedPersistence,
|
|
46
|
+
} = require('./launch-persistence');
|
|
42
47
|
const {
|
|
43
48
|
CGROUP_SHELL_VARS,
|
|
44
49
|
formatCgroupMemoryLogLine,
|
|
@@ -299,7 +304,51 @@ function recoverKilledExecution(params = {}) {
|
|
|
299
304
|
// Docker keeps `docker update` limits in the HostConfig across restarts;
|
|
300
305
|
// they are read back so the log and `--status` show what the resumed run
|
|
301
306
|
// is held to, and so a later relaunch can re-apply them.
|
|
302
|
-
|
|
307
|
+
let resourceLimits =
|
|
308
|
+
readDockerResourceLimits(containerName, runner) ||
|
|
309
|
+
opts.resourceLimits ||
|
|
310
|
+
[];
|
|
311
|
+
resourceLimits = require('./resume-resources').restoreBaseCpu(
|
|
312
|
+
resourceLimits,
|
|
313
|
+
opts
|
|
314
|
+
);
|
|
315
|
+
if (opts.cpuPenaltyConfig) {
|
|
316
|
+
try {
|
|
317
|
+
resourceLimits = require('./resume-resources').clampCpuToDaemon(
|
|
318
|
+
resourceLimits,
|
|
319
|
+
runner
|
|
320
|
+
);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return giveUp(error.message);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const evidence = require('./exit-evidence');
|
|
326
|
+
const terminalOom =
|
|
327
|
+
(params.oomKilled === true || params.oomKilled === 'true') &&
|
|
328
|
+
evidence.recentOomDelta(params.cgroupMemory, params.finishedAt) &&
|
|
329
|
+
!evidence.fromLog(
|
|
330
|
+
record.logPath
|
|
331
|
+
? require('./execution-attempt').readAttemptLogTail(record, 65536)
|
|
332
|
+
: null
|
|
333
|
+
).daemonRestart;
|
|
334
|
+
let recoveryLimits = null;
|
|
335
|
+
if (opts.onKillResumeMemory && terminalOom) {
|
|
336
|
+
try {
|
|
337
|
+
recoveryLimits =
|
|
338
|
+
require('./docker-resource-options').resolveResourceOptions(
|
|
339
|
+
{ memory: opts.onKillResumeMemory, resourceLimits },
|
|
340
|
+
runner,
|
|
341
|
+
params.random
|
|
342
|
+
);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
return giveUp(`recovery limit failed: ${error.message}`);
|
|
345
|
+
}
|
|
346
|
+
log(
|
|
347
|
+
`Recovery memory limit: ${resourceLimits.find((s) => s.startsWith('--memory=')) || 'unlimited'} -> ${recoveryLimits.resolvedLimits.memory} (${opts.onKillResumeMemory})\n`
|
|
348
|
+
);
|
|
349
|
+
resourceLimits = recoveryLimits.resourceLimits;
|
|
350
|
+
}
|
|
351
|
+
|
|
303
352
|
const limitsLine = buildResourceLimitsStatusLine(resourceLimits);
|
|
304
353
|
if (limitsLine) {
|
|
305
354
|
log(`${limitsLine}\n`);
|
|
@@ -308,7 +357,14 @@ function recoverKilledExecution(params = {}) {
|
|
|
308
357
|
if (opts.recoveryCommand) {
|
|
309
358
|
let copied;
|
|
310
359
|
try {
|
|
311
|
-
copied =
|
|
360
|
+
copied = opts.commandHandoff
|
|
361
|
+
? require('./docker-command-handoff').writeCommandHandoff(
|
|
362
|
+
containerName,
|
|
363
|
+
`export ${RECOVERY_ATTEMPT_ENV}=${attempt}; ${opts.recoveryCommand}`,
|
|
364
|
+
runner,
|
|
365
|
+
opts
|
|
366
|
+
)
|
|
367
|
+
: writeRecoveryMarker(containerName, attempt, runner);
|
|
312
368
|
} catch (err) {
|
|
313
369
|
copied = { success: false, error: err.message };
|
|
314
370
|
}
|
|
@@ -337,18 +393,6 @@ function recoverKilledExecution(params = {}) {
|
|
|
337
393
|
'resume-started'
|
|
338
394
|
);
|
|
339
395
|
}
|
|
340
|
-
const started = runner(getDockerCommand(), ['start', containerName]);
|
|
341
|
-
if (!started.success) {
|
|
342
|
-
if (nextAttempt) {
|
|
343
|
-
appendLifecycle(
|
|
344
|
-
{ uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
|
|
345
|
-
'launch-failed',
|
|
346
|
-
{ error: failureDetail(started) }
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
return giveUp(`docker start failed: ${failureDetail(started)}`);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
396
|
const described = describeExitCode(params.exitCode);
|
|
353
397
|
const delayed = delayRange ? `, delayMs=${delayMs}` : '';
|
|
354
398
|
// The killed run's own counters: the resumed run starts a fresh cgroup.
|
|
@@ -363,7 +407,7 @@ function recoverKilledExecution(params = {}) {
|
|
|
363
407
|
cgroupMemory: memory || undefined,
|
|
364
408
|
});
|
|
365
409
|
archiveAttempt(record);
|
|
366
|
-
|
|
410
|
+
|
|
367
411
|
record.attempt = nextAttempt;
|
|
368
412
|
}
|
|
369
413
|
const counted = memory
|
|
@@ -374,6 +418,18 @@ function recoverKilledExecution(params = {}) {
|
|
|
374
418
|
: '';
|
|
375
419
|
record.options = {
|
|
376
420
|
...opts,
|
|
421
|
+
...(recoveryLimits
|
|
422
|
+
? {
|
|
423
|
+
resolvedLimits: {
|
|
424
|
+
...opts.resolvedLimits,
|
|
425
|
+
...recoveryLimits.resolvedLimits,
|
|
426
|
+
},
|
|
427
|
+
resourceLimitSpecs: {
|
|
428
|
+
...opts.resourceLimitSpecs,
|
|
429
|
+
memory: opts.onKillResumeMemory,
|
|
430
|
+
},
|
|
431
|
+
}
|
|
432
|
+
: {}),
|
|
377
433
|
recoveryAttempts: attempt,
|
|
378
434
|
recoveryHistory: [
|
|
379
435
|
...(Array.isArray(opts.recoveryHistory) ? opts.recoveryHistory : []),
|
|
@@ -383,6 +439,7 @@ function recoverKilledExecution(params = {}) {
|
|
|
383
439
|
...(delayRange ? { lastRecoveryDelayMs: delayMs } : {}),
|
|
384
440
|
...(resourceLimits && resourceLimits.length > 0 ? { resourceLimits } : {}),
|
|
385
441
|
};
|
|
442
|
+
delete record.options.exitEvidence;
|
|
386
443
|
record.status = 'executing';
|
|
387
444
|
record.exitCode = null;
|
|
388
445
|
record.endTime = null;
|
|
@@ -395,11 +452,56 @@ function recoverKilledExecution(params = {}) {
|
|
|
395
452
|
record.observedAt = undefined;
|
|
396
453
|
record.staleDetectedAt = undefined;
|
|
397
454
|
record.containerStartedAt = undefined;
|
|
455
|
+
const previousData = globalThis.structuredClone(store.get(executionId));
|
|
456
|
+
const previous = record.constructor.fromObject
|
|
457
|
+
? record.constructor.fromObject(previousData)
|
|
458
|
+
: previousData;
|
|
459
|
+
try {
|
|
460
|
+
reserveLaunch(store, record, previous);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return giveUp(`launch reservation failed: ${error.message}`);
|
|
463
|
+
}
|
|
464
|
+
if (recoveryLimits || opts.cpuPenaltyConfig) {
|
|
465
|
+
const updated = runner(getDockerCommand(), [
|
|
466
|
+
'update',
|
|
467
|
+
...resourceLimits.filter((s) =>
|
|
468
|
+
/^--(?:memory|memory-swap|cpus|cpu-quota|cpu-period)=/.test(s)
|
|
469
|
+
),
|
|
470
|
+
containerName,
|
|
471
|
+
]);
|
|
472
|
+
if (!updated.success) {
|
|
473
|
+
rollbackLaunch(store, previous);
|
|
474
|
+
return giveUp(`docker update failed: ${failureDetail(updated)}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const started = runner(getDockerCommand(), ['start', containerName]);
|
|
478
|
+
if (!started.success) {
|
|
479
|
+
if (nextAttempt) {
|
|
480
|
+
appendLifecycle(
|
|
481
|
+
{ uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
|
|
482
|
+
'launch-failed',
|
|
483
|
+
{ error: failureDetail(started) }
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
rollbackLaunch(store, previous);
|
|
487
|
+
return giveUp(`docker start failed: ${failureDetail(started)}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (opts.cpuPenaltyConfig) {
|
|
491
|
+
record.options.resolvedLimits = {
|
|
492
|
+
...record.options.resolvedLimits,
|
|
493
|
+
cpus: require('./docker-resource-limits').cpuCount(resourceLimits),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
record.options.launchPending = false;
|
|
497
|
+
if (nextAttempt) {
|
|
498
|
+
nextAttempt.launchAcceptedAt = new Date().toISOString();
|
|
499
|
+
}
|
|
500
|
+
let persistenceError = null;
|
|
398
501
|
try {
|
|
399
502
|
store.save(record);
|
|
400
|
-
} catch {
|
|
401
|
-
|
|
402
|
-
// it and finalizes the record when it ends.
|
|
503
|
+
} catch (error) {
|
|
504
|
+
persistenceError = error;
|
|
403
505
|
}
|
|
404
506
|
|
|
405
507
|
const startWatcher =
|
|
@@ -407,18 +509,29 @@ function recoverKilledExecution(params = {}) {
|
|
|
407
509
|
if (nextAttempt) {
|
|
408
510
|
appendLifecycle(record, 'launch-accepted');
|
|
409
511
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
512
|
+
try {
|
|
513
|
+
startWatcher(
|
|
514
|
+
containerName,
|
|
515
|
+
getDockerContainerCleanupPolicy(opts),
|
|
516
|
+
record.logPath || null,
|
|
517
|
+
record.uuid,
|
|
518
|
+
{
|
|
519
|
+
since,
|
|
520
|
+
recoverOnKill: true,
|
|
521
|
+
...(nextAttempt ? { attemptNumber: nextAttempt.number } : {}),
|
|
522
|
+
}
|
|
523
|
+
);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
const warning = failLaunchedPersistence(
|
|
526
|
+
store,
|
|
527
|
+
record,
|
|
528
|
+
runner,
|
|
529
|
+
persistenceError || error
|
|
530
|
+
);
|
|
531
|
+
log(`[Recovery] ${warning}\n`);
|
|
532
|
+
return { recovered: false, reason: warning, attempt };
|
|
533
|
+
}
|
|
534
|
+
if (nextAttempt && !persistenceError) {
|
|
422
535
|
const current = patchAttempt(store, record, {
|
|
423
536
|
watcherAttachedAt: new Date().toISOString(),
|
|
424
537
|
});
|
|
@@ -426,6 +539,16 @@ function recoverKilledExecution(params = {}) {
|
|
|
426
539
|
appendLifecycle(current, 'watcher-attached');
|
|
427
540
|
}
|
|
428
541
|
}
|
|
542
|
+
if (persistenceError) {
|
|
543
|
+
const warning = failLaunchedPersistence(
|
|
544
|
+
store,
|
|
545
|
+
record,
|
|
546
|
+
runner,
|
|
547
|
+
persistenceError
|
|
548
|
+
);
|
|
549
|
+
log(`[Recovery] ${warning}\n`);
|
|
550
|
+
return { recovered: false, reason: warning, attempt };
|
|
551
|
+
}
|
|
429
552
|
return {
|
|
430
553
|
recovered: true,
|
|
431
554
|
reason: 'resumed',
|
|
@@ -125,6 +125,17 @@ function resumeAllExecutions(store, deps = {}) {
|
|
|
125
125
|
for (const record of records) {
|
|
126
126
|
const probe = probeFn(record);
|
|
127
127
|
const sessionName = probe.sessionName;
|
|
128
|
+
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
129
|
+
executions.push(
|
|
130
|
+
describeRecord(
|
|
131
|
+
record,
|
|
132
|
+
probe,
|
|
133
|
+
ResumeAllAction.RUNNING,
|
|
134
|
+
'Launch is reserved; no watcher or duplicate launch was started.'
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
128
139
|
|
|
129
140
|
if (probe.alive && probe.backend === 'docker') {
|
|
130
141
|
startWatcher(
|