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,267 @@
|
|
|
1
|
+
/** Bounded byte-wise sanitization for logs prepared for publication. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const BLOCK_SIZE = 64 * 1024;
|
|
7
|
+
const MARKER = '[REDACTED]';
|
|
8
|
+
const SECRET_ENV_NAME =
|
|
9
|
+
/(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)/i;
|
|
10
|
+
|
|
11
|
+
// Prefixes intentionally match even truncated tokens. Unbounded values retain a
|
|
12
|
+
// continuation state, so a token or header larger than a block cannot leak its
|
|
13
|
+
// suffix. Latin-1 provides a reversible byte mapping, including invalid UTF-8.
|
|
14
|
+
const RULES = [
|
|
15
|
+
{
|
|
16
|
+
pattern:
|
|
17
|
+
/\b[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{0,512}/g,
|
|
18
|
+
validate: (value) => {
|
|
19
|
+
try {
|
|
20
|
+
return (
|
|
21
|
+
typeof JSON.parse(
|
|
22
|
+
Buffer.from(value.split('.')[0], 'base64url').toString()
|
|
23
|
+
).alg === 'string'
|
|
24
|
+
);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
continuation: /[A-Za-z0-9_.-]/,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
pattern: /[A-Za-z0-9_-]{512,}[A-Za-z0-9_.-]*/g,
|
|
33
|
+
continuation: /[A-Za-z0-9_.-]/,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: /(?:gh[pousr]_|github_pat_|sk-)[A-Za-z0-9_-]*/g,
|
|
37
|
+
continuation: /[A-Za-z0-9_-]/,
|
|
38
|
+
},
|
|
39
|
+
{ pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/g },
|
|
40
|
+
{ pattern: /eyJ[A-Za-z0-9_.-]*/g, continuation: /[A-Za-z0-9_.-]/ },
|
|
41
|
+
{
|
|
42
|
+
pattern: /(?:Proxy-)?Authorization[ \t]{0,32}:[^\r\n]*/gi,
|
|
43
|
+
continuation: /[^\r\n]/,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
pattern:
|
|
47
|
+
/(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)[ \t]{0,32}[=:][ \t]*["']?[^\s"';,]*/g,
|
|
48
|
+
continuation: /[^\s"';,]/,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
pattern: /(?:https?|git|ssh):\/\/[^\s:/@]{1,256}:[^\s@]*/g,
|
|
52
|
+
continuation: /[^\s@]/,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
class StreamingSanitizer {
|
|
57
|
+
constructor(env = process.env) {
|
|
58
|
+
this.secrets = [
|
|
59
|
+
...new Set(
|
|
60
|
+
Object.entries(env)
|
|
61
|
+
.filter(
|
|
62
|
+
([name, value]) =>
|
|
63
|
+
SECRET_ENV_NAME.test(name) &&
|
|
64
|
+
typeof value === 'string' &&
|
|
65
|
+
value.length
|
|
66
|
+
)
|
|
67
|
+
.map(([, value]) => Buffer.from(value).toString('latin1'))
|
|
68
|
+
),
|
|
69
|
+
].sort((a, b) => b.length - a.length);
|
|
70
|
+
if (this.secrets.some((value) => value.length > BLOCK_SIZE)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'Known environment secret is too large for bounded sanitization.'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
this.holdback = this.secrets.reduce(
|
|
76
|
+
(size, value) => Math.max(size, value.length),
|
|
77
|
+
2048
|
|
78
|
+
);
|
|
79
|
+
this.pending = '';
|
|
80
|
+
this.continuation = null;
|
|
81
|
+
this.redactions = 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get pendingBytes() {
|
|
85
|
+
return this.pending.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
findMatches(input) {
|
|
89
|
+
const matches = [];
|
|
90
|
+
for (const rule of RULES) {
|
|
91
|
+
rule.pattern.lastIndex = 0;
|
|
92
|
+
for (
|
|
93
|
+
let match = rule.pattern.exec(input);
|
|
94
|
+
match;
|
|
95
|
+
match = rule.pattern.exec(input)
|
|
96
|
+
) {
|
|
97
|
+
if (!rule.validate || rule.validate(match[0])) {
|
|
98
|
+
matches.push({
|
|
99
|
+
index: match.index,
|
|
100
|
+
end: match.index + match[0].length,
|
|
101
|
+
continuation: rule.continuation,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const secret of this.secrets) {
|
|
107
|
+
for (
|
|
108
|
+
let index = input.indexOf(secret);
|
|
109
|
+
index >= 0;
|
|
110
|
+
index = input.indexOf(secret, index + 1)
|
|
111
|
+
) {
|
|
112
|
+
matches.push({ index, end: index + secret.length });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
matches.sort((a, b) => a.index - b.index || b.end - a.end);
|
|
116
|
+
const merged = [];
|
|
117
|
+
for (const match of matches) {
|
|
118
|
+
const previous = merged[merged.length - 1];
|
|
119
|
+
if (previous && match.index <= previous.end) {
|
|
120
|
+
if (match.end > previous.end) {
|
|
121
|
+
previous.end = match.end;
|
|
122
|
+
previous.continuation = match.continuation;
|
|
123
|
+
} else if (match.end === previous.end && match.continuation) {
|
|
124
|
+
// Nested tokens must not narrow a containing header/URL's suffix.
|
|
125
|
+
const retained = previous.continuation;
|
|
126
|
+
const additional = match.continuation;
|
|
127
|
+
previous.continuation = retained
|
|
128
|
+
? { test: (byte) => retained.test(byte) || additional.test(byte) }
|
|
129
|
+
: additional;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
merged.push({ ...match });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
push(block, final = false) {
|
|
139
|
+
let input = this.pending + block.toString('latin1');
|
|
140
|
+
this.pending = '';
|
|
141
|
+
if (this.continuation) {
|
|
142
|
+
let end = 0;
|
|
143
|
+
while (end < input.length && this.continuation.test(input[end])) {
|
|
144
|
+
end++;
|
|
145
|
+
}
|
|
146
|
+
input = input.slice(end);
|
|
147
|
+
if (!input.length && !final) {
|
|
148
|
+
return Buffer.alloc(0);
|
|
149
|
+
}
|
|
150
|
+
this.continuation = null;
|
|
151
|
+
}
|
|
152
|
+
const safeEnd = final
|
|
153
|
+
? input.length
|
|
154
|
+
: Math.max(0, input.length - this.holdback);
|
|
155
|
+
let offset = 0;
|
|
156
|
+
const output = [];
|
|
157
|
+
for (const match of this.findMatches(input)) {
|
|
158
|
+
if (match.index >= safeEnd) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
output.push(input.slice(offset, match.index), MARKER);
|
|
162
|
+
this.redactions++;
|
|
163
|
+
offset = match.end;
|
|
164
|
+
if (offset === input.length && match.continuation && !final) {
|
|
165
|
+
this.continuation = match.continuation;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const end = Math.max(offset, safeEnd);
|
|
170
|
+
output.push(input.slice(offset, end));
|
|
171
|
+
this.pending = input.slice(end);
|
|
172
|
+
return Buffer.from(output.join(''), 'latin1');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Returns a private copy plus cleanup; every preparation error blocks upload. */
|
|
177
|
+
function sanitizeLogToTemp(sourcePath, options = {}) {
|
|
178
|
+
let directory;
|
|
179
|
+
let source;
|
|
180
|
+
let destination;
|
|
181
|
+
let ready = false;
|
|
182
|
+
let stage = 'environment';
|
|
183
|
+
try {
|
|
184
|
+
const sanitizer = new StreamingSanitizer(options.env || process.env);
|
|
185
|
+
stage = 'source';
|
|
186
|
+
source = fs.openSync(sourcePath, 'r');
|
|
187
|
+
if (!fs.fstatSync(source).isFile()) {
|
|
188
|
+
throw new Error('Source must be a regular file.');
|
|
189
|
+
}
|
|
190
|
+
stage = 'private-file';
|
|
191
|
+
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'start-sanitized-'));
|
|
192
|
+
fs.chmodSync(directory, 0o700);
|
|
193
|
+
const target = path.join(directory, 'execution.log');
|
|
194
|
+
destination = fs.openSync(target, 'wx', 0o600);
|
|
195
|
+
fs.fchmodSync(destination, 0o600);
|
|
196
|
+
const buffer = Buffer.alloc(BLOCK_SIZE);
|
|
197
|
+
let bytes = 0;
|
|
198
|
+
const write = (output) => {
|
|
199
|
+
stage = 'write';
|
|
200
|
+
let offset = 0;
|
|
201
|
+
while (offset < output.length) {
|
|
202
|
+
const written = fs.writeSync(
|
|
203
|
+
destination,
|
|
204
|
+
output,
|
|
205
|
+
offset,
|
|
206
|
+
output.length - offset
|
|
207
|
+
);
|
|
208
|
+
if (!written) {
|
|
209
|
+
throw new Error('No progress writing sanitized log.');
|
|
210
|
+
}
|
|
211
|
+
offset += written;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
for (;;) {
|
|
215
|
+
stage = 'read';
|
|
216
|
+
const length = fs.readSync(source, buffer, 0, buffer.length, null);
|
|
217
|
+
if (!length) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
bytes += length;
|
|
221
|
+
stage = 'scan';
|
|
222
|
+
write(sanitizer.push(buffer.subarray(0, length)));
|
|
223
|
+
}
|
|
224
|
+
write(sanitizer.push(Buffer.alloc(0), true));
|
|
225
|
+
stage = 'sync';
|
|
226
|
+
fs.fsyncSync(destination);
|
|
227
|
+
fs.closeSync(destination);
|
|
228
|
+
destination = undefined;
|
|
229
|
+
fs.closeSync(source);
|
|
230
|
+
source = undefined;
|
|
231
|
+
if (options.verbose) {
|
|
232
|
+
console.log(
|
|
233
|
+
`Prepared private sanitized log: ${bytes} bytes scanned, ${sanitizer.redactions} redactions.`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
ready = true;
|
|
237
|
+
return {
|
|
238
|
+
path: target,
|
|
239
|
+
cleanup: () => fs.rmSync(directory, { recursive: true, force: true }),
|
|
240
|
+
};
|
|
241
|
+
} catch {
|
|
242
|
+
if (options.verbose) {
|
|
243
|
+
console.log(`Log sanitization failed at stage: ${stage}.`);
|
|
244
|
+
}
|
|
245
|
+
// Do not echo error messages: paths and exception details can carry secrets.
|
|
246
|
+
throw new Error('Log sanitization failed; upload blocked.');
|
|
247
|
+
} finally {
|
|
248
|
+
for (const fd of [destination, source]) {
|
|
249
|
+
if (fd !== undefined) {
|
|
250
|
+
try {
|
|
251
|
+
fs.closeSync(fd);
|
|
252
|
+
} catch {
|
|
253
|
+
/* Best effort after failure. */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (directory && !ready) {
|
|
258
|
+
try {
|
|
259
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
260
|
+
} catch {
|
|
261
|
+
/* Upload remains blocked. */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { StreamingSanitizer, sanitizeLogToTemp };
|
package/src/lib/log-uploader.js
CHANGED
|
@@ -6,6 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { spawnSync } = require('child_process');
|
|
9
|
+
const { sanitizeLogToTemp } = require('./log-sanitizer');
|
|
9
10
|
|
|
10
11
|
function isExecutable(filePath) {
|
|
11
12
|
try {
|
|
@@ -135,7 +136,7 @@ function ensureGhUploadLogAvailable() {
|
|
|
135
136
|
};
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
function uploadLogPath(logPath) {
|
|
139
|
+
function uploadLogPath(logPath, options = {}) {
|
|
139
140
|
if (!logPath) {
|
|
140
141
|
return {
|
|
141
142
|
success: false,
|
|
@@ -146,29 +147,47 @@ function uploadLogPath(logPath) {
|
|
|
146
147
|
return { success: false, error: `Log file not found: ${logPath}` };
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
150
|
+
let prepared;
|
|
151
|
+
try {
|
|
152
|
+
if (!options.noSanitize) {
|
|
153
|
+
// Wrapper options.env holds Docker arguments, not the process secrets.
|
|
154
|
+
prepared = sanitizeLogToTemp(logPath, { verbose: options.verbose });
|
|
155
|
+
}
|
|
156
|
+
const availability = ensureGhUploadLogAvailable();
|
|
157
|
+
if (!availability.success) {
|
|
158
|
+
return availability;
|
|
159
|
+
}
|
|
160
|
+
const result = runCommand(
|
|
161
|
+
availability.command,
|
|
162
|
+
[prepared ? prepared.path : logPath, '--private'],
|
|
163
|
+
{
|
|
164
|
+
stdio: 'inherit',
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
const exitCode =
|
|
168
|
+
result.status !== null && result.status !== undefined ? result.status : 1;
|
|
169
|
+
if (exitCode !== 0) {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
exitCode,
|
|
173
|
+
error: `gh-upload-log exited with code ${exitCode}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { success: true, exitCode: 0 };
|
|
177
|
+
} catch {
|
|
161
178
|
return {
|
|
162
179
|
success: false,
|
|
163
|
-
exitCode,
|
|
164
|
-
error:
|
|
180
|
+
exitCode: 1,
|
|
181
|
+
error: 'Log preparation or upload failed; upload blocked.',
|
|
165
182
|
};
|
|
183
|
+
} finally {
|
|
184
|
+
if (prepared) {
|
|
185
|
+
prepared.cleanup();
|
|
186
|
+
}
|
|
166
187
|
}
|
|
167
|
-
|
|
168
|
-
return { success: true, exitCode: 0 };
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
function uploadExecutionLog(store, identifier) {
|
|
190
|
+
function uploadExecutionLog(store, identifier, options = {}) {
|
|
172
191
|
if (!store) {
|
|
173
192
|
return { success: false, error: 'Execution tracking is disabled.' };
|
|
174
193
|
}
|
|
@@ -181,7 +200,7 @@ function uploadExecutionLog(store, identifier) {
|
|
|
181
200
|
};
|
|
182
201
|
}
|
|
183
202
|
|
|
184
|
-
return uploadLogPath(record.logPath);
|
|
203
|
+
return uploadLogPath(record.logPath, options);
|
|
185
204
|
}
|
|
186
205
|
|
|
187
206
|
module.exports = {
|
|
@@ -38,8 +38,8 @@ function handleListQuery(store, outputFormat, options = {}) {
|
|
|
38
38
|
return reportResult(listExecutions(store, outputFormat, options));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function handleUploadLogQuery(store, identifier) {
|
|
42
|
-
const result = uploadExecutionLog(store, identifier);
|
|
41
|
+
function handleUploadLogQuery(store, identifier, options = {}) {
|
|
42
|
+
const result = uploadExecutionLog(store, identifier, options);
|
|
43
43
|
if (result.success) {
|
|
44
44
|
return result.exitCode || 0;
|
|
45
45
|
}
|
|
@@ -55,9 +55,20 @@ function handleAttachQuery(store, identifier, readOnly) {
|
|
|
55
55
|
return reportResult(attachExecution(store, identifier, { readOnly }));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
async function handleResumeQuery(
|
|
58
|
+
async function handleResumeQuery(
|
|
59
|
+
store,
|
|
60
|
+
identifier,
|
|
61
|
+
command,
|
|
62
|
+
outputFormat,
|
|
63
|
+
resourceOptions = {}
|
|
64
|
+
) {
|
|
59
65
|
return reportResult(
|
|
60
|
-
await resumeExecution(store, identifier, {
|
|
66
|
+
await resumeExecution(store, identifier, {
|
|
67
|
+
command,
|
|
68
|
+
outputFormat,
|
|
69
|
+
resourceOptions,
|
|
70
|
+
removeOriginal: resourceOptions.removeOriginal === true,
|
|
71
|
+
})
|
|
61
72
|
);
|
|
62
73
|
}
|
|
63
74
|
|
|
@@ -154,7 +165,7 @@ async function dispatchQueryCommand(options, context = {}) {
|
|
|
154
165
|
return handleListQuery(store, outputFormat, { running: options.running });
|
|
155
166
|
}
|
|
156
167
|
if (hasValue(options.uploadLog)) {
|
|
157
|
-
return handleUploadLogQuery(store, options.uploadLog);
|
|
168
|
+
return handleUploadLogQuery(store, options.uploadLog, options);
|
|
158
169
|
}
|
|
159
170
|
if (hasValue(options.stop)) {
|
|
160
171
|
return handleControlQuery(store, options.stop, ControlAction.STOP);
|
|
@@ -174,7 +185,8 @@ async function dispatchQueryCommand(options, context = {}) {
|
|
|
174
185
|
store,
|
|
175
186
|
options.resume,
|
|
176
187
|
context.command || null,
|
|
177
|
-
outputFormat
|
|
188
|
+
outputFormat,
|
|
189
|
+
options
|
|
178
190
|
);
|
|
179
191
|
}
|
|
180
192
|
if (options.resumeAll) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const {
|
|
2
|
+
normalizeResourceLimits,
|
|
3
|
+
cpuCount,
|
|
4
|
+
withCpuLimit,
|
|
5
|
+
readDockerResourceLimits,
|
|
6
|
+
} = require('./docker-resource-limits');
|
|
7
|
+
const { resolveResourceOptions } = require('./docker-resource-options');
|
|
8
|
+
|
|
9
|
+
// Temporary penalty quotas must never become a resumed execution's base quota.
|
|
10
|
+
function restoreBaseCpu(limits, options) {
|
|
11
|
+
if (!options.cpuPenaltyConfig) {
|
|
12
|
+
return limits;
|
|
13
|
+
}
|
|
14
|
+
const base = normalizeResourceLimits(
|
|
15
|
+
options.baseResourceLimits || options.resourceLimits
|
|
16
|
+
);
|
|
17
|
+
return [
|
|
18
|
+
...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
19
|
+
...base.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function prepareResumeResources(record, probe, overrides, runner, random) {
|
|
24
|
+
const options = record.options || {};
|
|
25
|
+
if (options.isolated !== 'docker') {
|
|
26
|
+
if (
|
|
27
|
+
['memory', 'memorySwap', 'cpus'].some(
|
|
28
|
+
(k) => overrides[k] !== null && overrides[k] !== undefined
|
|
29
|
+
)
|
|
30
|
+
) {
|
|
31
|
+
throw new Error('Resource overrides require a Docker execution');
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const live =
|
|
36
|
+
probe.state === 'stopped'
|
|
37
|
+
? readDockerResourceLimits(options.sessionName, runner)
|
|
38
|
+
: null;
|
|
39
|
+
const base = restoreBaseCpu(
|
|
40
|
+
normalizeResourceLimits(live || options.resourceLimits),
|
|
41
|
+
options
|
|
42
|
+
);
|
|
43
|
+
const oldMemory = base.find((s) => s.startsWith('--memory='))?.slice(9);
|
|
44
|
+
const result = resolveResourceOptions(
|
|
45
|
+
{ ...overrides, resourceLimits: base },
|
|
46
|
+
runner,
|
|
47
|
+
random
|
|
48
|
+
);
|
|
49
|
+
if (overrides.cpus && probe.state === 'stopped') {
|
|
50
|
+
// Docker cannot switch an existing legacy quota to NanoCPUs in place.
|
|
51
|
+
const cpu = withCpuLimit(base, result.resolvedLimits.cpus);
|
|
52
|
+
result.resourceLimits = [
|
|
53
|
+
...result.resourceLimits.filter(
|
|
54
|
+
(s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)
|
|
55
|
+
),
|
|
56
|
+
...cpu.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
result.oldMemory = oldMemory
|
|
60
|
+
? require('./docker-resource-options').parseResourceSpec(oldMemory).amount
|
|
61
|
+
: 'unlimited';
|
|
62
|
+
result.resolvedLimits = {
|
|
63
|
+
...options.resolvedLimits,
|
|
64
|
+
...result.resolvedLimits,
|
|
65
|
+
};
|
|
66
|
+
result.resourceLimitSpecs = {
|
|
67
|
+
...options.resourceLimitSpecs,
|
|
68
|
+
...result.resourceLimitSpecs,
|
|
69
|
+
};
|
|
70
|
+
result.update =
|
|
71
|
+
probe.state === 'stopped' &&
|
|
72
|
+
(['memory', 'memorySwap', 'cpus'].some(
|
|
73
|
+
(k) => overrides[k] !== null && overrides[k] !== undefined
|
|
74
|
+
) ||
|
|
75
|
+
Boolean(options.cpuPenaltyConfig));
|
|
76
|
+
if (options.cpuPenaltyConfig && !overrides.cpus) {
|
|
77
|
+
result.resourceLimits = clampCpuToDaemon(result.resourceLimits, runner);
|
|
78
|
+
result.resolvedLimits.cpus = cpuCount(result.resourceLimits);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clampCpuToDaemon(limits, runner) {
|
|
84
|
+
const { readDaemonCapacity } = require('./docker-resource-options');
|
|
85
|
+
const capacity = readDaemonCapacity(runner).cpus;
|
|
86
|
+
return withCpuLimit(limits, Math.min(cpuCount(limits) || capacity, capacity));
|
|
87
|
+
}
|
|
88
|
+
module.exports = { prepareResumeResources, restoreBaseCpu, clampCpuToDaemon };
|
package/src/lib/shell-utils.js
CHANGED
|
@@ -86,6 +86,10 @@ function splitShellWords(command, style = defaultShellQuotingStyle()) {
|
|
|
86
86
|
if (i + 1 >= command.length) {
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
|
+
if (quote === '"' && !'$`"\\\n'.includes(command[i + 1])) {
|
|
90
|
+
current += char;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
89
93
|
current += command[++i];
|
|
90
94
|
continue;
|
|
91
95
|
}
|
|
@@ -121,10 +125,69 @@ function toShellWords(command) {
|
|
|
121
125
|
);
|
|
122
126
|
}
|
|
123
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Return literal argv only when no outer shell evaluation is needed. Operators,
|
|
130
|
+
* expansions and comments belong to the outer command line, not the -c script.
|
|
131
|
+
* Single quotes protect their contents; double quotes still permit expansion.
|
|
132
|
+
*/
|
|
133
|
+
function simpleShellWords(command) {
|
|
134
|
+
let quote = null;
|
|
135
|
+
for (let i = 0; i < command.length; i++) {
|
|
136
|
+
const char = command[i];
|
|
137
|
+
if (char === '\\' && quote !== "'") {
|
|
138
|
+
if (command[i + 1] === '\n') {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
if (quote !== '"' || '$`"\\\n'.includes(command[i + 1])) {
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (quote === "'") {
|
|
147
|
+
if (char === "'") {
|
|
148
|
+
quote = null;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (char === '$' || char === '`') {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (quote === '"') {
|
|
156
|
+
if (char === '"') {
|
|
157
|
+
quote = null;
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (';&|<>(){}\n\r*?[]~#'.includes(char)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (/\s/.test(char) && !' \t'.includes(char)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (char === "'" || char === '"') {
|
|
168
|
+
quote = char;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return splitShellWords(command, 'posix');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function shellScriptIndex(parts) {
|
|
175
|
+
if (!parts || !SHELL_NAMES.includes(path.basename(parts[0] || ''))) {
|
|
176
|
+
return -1;
|
|
177
|
+
}
|
|
178
|
+
const index = parts.indexOf('-c');
|
|
179
|
+
return index > 0 &&
|
|
180
|
+
index + 1 < parts.length &&
|
|
181
|
+
parts.slice(1, index).every((part) => /^--?[a-zA-Z][a-zA-Z-]*$/.test(part))
|
|
182
|
+
? index
|
|
183
|
+
: -1;
|
|
184
|
+
}
|
|
185
|
+
|
|
124
186
|
/** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
|
|
125
187
|
function isInteractiveShellCommand(command) {
|
|
126
|
-
const parts =
|
|
188
|
+
const parts = simpleShellWords(command);
|
|
127
189
|
return (
|
|
190
|
+
parts !== null &&
|
|
128
191
|
parts.length > 0 &&
|
|
129
192
|
SHELL_NAMES.includes(path.basename(parts[0])) &&
|
|
130
193
|
!parts.includes('-c')
|
|
@@ -133,25 +196,12 @@ function isInteractiveShellCommand(command) {
|
|
|
133
196
|
|
|
134
197
|
/** True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91). */
|
|
135
198
|
function isShellInvocationWithArgs(command) {
|
|
136
|
-
|
|
137
|
-
return (
|
|
138
|
-
parts.length > 0 &&
|
|
139
|
-
SHELL_NAMES.includes(path.basename(parts[0])) &&
|
|
140
|
-
parts.includes('-c')
|
|
141
|
-
);
|
|
199
|
+
return shellScriptIndex(simpleShellWords(command)) !== -1;
|
|
142
200
|
}
|
|
143
201
|
|
|
144
|
-
/**
|
|
202
|
+
/** Preserve the script word and every positional argument as separate argv. */
|
|
145
203
|
function buildShellWithArgsCmdArgs(command) {
|
|
146
|
-
|
|
147
|
-
const cIdx = parts.indexOf('-c');
|
|
148
|
-
if (cIdx === -1) {
|
|
149
|
-
return parts;
|
|
150
|
-
}
|
|
151
|
-
const scriptArg = parts.slice(cIdx + 1).join(' ');
|
|
152
|
-
return scriptArg.length > 0
|
|
153
|
-
? [...parts.slice(0, cIdx + 1), scriptArg]
|
|
154
|
-
: parts.slice(0, cIdx + 1);
|
|
204
|
+
return simpleShellWords(command) || toShellWords(command);
|
|
155
205
|
}
|
|
156
206
|
|
|
157
207
|
/** Quote an argument for display only, keeping the user-facing double-quoted form (issue #91). */
|
|
@@ -326,6 +326,9 @@ function applyEndTime(enriched, dockerState, footerFinishedAt) {
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
function resolveDetachedStatus(record, logTail) {
|
|
329
|
+
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
330
|
+
return record;
|
|
331
|
+
}
|
|
329
332
|
const footer = parseFooterFromTail(logTail);
|
|
330
333
|
const footerExit = footer.exitCode;
|
|
331
334
|
const dockerState = isDetachedDockerRecord(record)
|
|
@@ -444,6 +447,7 @@ function attachExitReason(record, logTail) {
|
|
|
444
447
|
logTail,
|
|
445
448
|
oomKilled: record.oomKilled,
|
|
446
449
|
cgroupMemory: record.cgroupMemory,
|
|
450
|
+
exitEvidence: record.options?.exitEvidence,
|
|
447
451
|
});
|
|
448
452
|
|
|
449
453
|
if (!exitReason) {
|
|
@@ -490,9 +494,22 @@ function attachMemoryExhaustion(record, logTail) {
|
|
|
490
494
|
logTail,
|
|
491
495
|
oomKilled: record.oomKilled,
|
|
492
496
|
cgroupMemory: record.cgroupMemory,
|
|
497
|
+
exitEvidence: record.options?.exitEvidence,
|
|
493
498
|
});
|
|
494
499
|
|
|
495
500
|
if (!memory) {
|
|
501
|
+
if (
|
|
502
|
+
record.memoryExhaustedReason?.startsWith('Docker reported') ||
|
|
503
|
+
record.memoryExhaustedReason?.startsWith('cgroup memory.events')
|
|
504
|
+
) {
|
|
505
|
+
const cleared = Object.assign(
|
|
506
|
+
Object.create(Object.getPrototypeOf(record)),
|
|
507
|
+
record
|
|
508
|
+
);
|
|
509
|
+
delete cleared.memoryExhausted;
|
|
510
|
+
delete cleared.memoryExhaustedReason;
|
|
511
|
+
return cleared;
|
|
512
|
+
}
|
|
496
513
|
return record;
|
|
497
514
|
}
|
|
498
515
|
|
|
@@ -517,6 +534,14 @@ function enrichDetachedStatus(record) {
|
|
|
517
534
|
if (!record) {
|
|
518
535
|
return record;
|
|
519
536
|
}
|
|
537
|
+
const cpuPenalty = require('./cpu-penalty-monitor').readState(record);
|
|
538
|
+
if (cpuPenalty) {
|
|
539
|
+
record = Object.assign(
|
|
540
|
+
Object.create(Object.getPrototypeOf(record)),
|
|
541
|
+
record
|
|
542
|
+
);
|
|
543
|
+
record.cpuPenalty = cpuPenalty;
|
|
544
|
+
}
|
|
520
545
|
const {
|
|
521
546
|
readAttemptActivity,
|
|
522
547
|
readAttemptLogTail,
|
|
@@ -693,6 +718,9 @@ function formatRecordAsText(record) {
|
|
|
693
718
|
...(formatCgroupMemory(obj.cgroupMemory)
|
|
694
719
|
? [`Cgroup Memory: ${formatCgroupMemory(obj.cgroupMemory)}`]
|
|
695
720
|
: []),
|
|
721
|
+
...(obj.cpuPenalty
|
|
722
|
+
? [`CPU Penalty: ${JSON.stringify(obj.cpuPenalty)}`]
|
|
723
|
+
: []),
|
|
696
724
|
`PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
|
|
697
725
|
`Working Directory: ${obj.workingDirectory}`,
|
|
698
726
|
`Shell: ${obj.shell}`,
|