start-command 0.39.0 → 0.39.2
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 +2 -1
- package/package.json +2 -1
- package/src/lib/cgroup-memory.js +35 -11
- package/src/lib/failure-handler.js +12 -6
- package/src/lib/log-sanitizer.js +303 -220
- package/src/lib/log-uploader.js +6 -3
- package/src/lib/screen-isolation.js +72 -42
- package/test/isolation.js +32 -0
- package/test/log-sanitizer.js +302 -135
- package/test/regression-247.js +140 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.39.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 328fa0c: Sanitize `--upload-log` logs and auto-reported issue text with `@link-foundation/sensitive-data-sanitizer` (#250) instead of start-command's own rules, which removed content that is not secret (#249). Before, `Authorization:` was masked to the end of the line, which dropped the rest of one-line JSON request bodies. Every URL with a port was masked, and `sk-` matched inside words such as `disk-baseline-`. Now only credentials are masked (`--credentials-only`): values of credential-named environment variables, the gh `hosts.yml` tokens, and `Authorization` schemes with their token. The rest of the line, account details and URLs with ports stay. The log streams through the library CLI from stdin into a private `0600` copy, and invalid UTF-8 bytes are kept. Any failure blocks the upload with a value-free code such as `Log sanitization failed (ERR_BINARY); upload blocked.` `START_SENSITIVE_DATA_SANITIZER` selects another CLI, and `--no-sanitize` is unchanged.
|
|
8
|
+
|
|
9
|
+
## 0.39.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 55c57b0: Stop the Docker cgroup sampler when the counters can never be read (#247). On hosts whose containers have no `memory.events` (the `memory` controller is not delegated, e.g. a root `cgroup.subtree_control` of `cpuset cpu pids`) and whose local cgroup is hidden, the sampler started one `docker exec` and one `docker inspect -f {{.State.Running}}` every interval for the whole life of the task without ever recording a sample. Exit 3 (unreadable counters) from the long-lived stream or from the per-round read now records the reason and stops sampling, as a missing `sh` (exit 126/127) already did; the post-mortem reports `Memory: unavailable (local cgroup unavailable; Docker exec exit=3: private cgroup v2 counters unreadable)` instead of the last round's error. Other failed rounds are still retried, but each one doubles the wait, up to 60 s (or the interval, if longer); a sample restores the interval. The completion watcher (`docker wait`) still finishes the run.
|
|
14
|
+
|
|
15
|
+
Attached `--isolated screen` runs no longer report a failed command as exit 0 when `screen -dmS` returns before `screen -ls` lists the new session. A session missing from `screen -ls` now counts as finished only once its exit-code file is written, or after 20 polls (about 2 s) without the session.
|
|
16
|
+
|
|
3
17
|
## 0.39.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -41,7 +41,8 @@ checked examples.
|
|
|
41
41
|
|
|
42
42
|
`-h` also prints usage. Labels persist across Docker resumes. New detached
|
|
43
43
|
containers support copy-free command handoff; legacy snapshots require capacity
|
|
44
|
-
preflight. Log uploads are private and sanitized
|
|
44
|
+
preflight. Log uploads are private and sanitized with the bundled
|
|
45
|
+
`@link-foundation/sensitive-data-sanitizer`; manual `--no-sanitize` opts out.
|
|
45
46
|
|
|
46
47
|
Snapshot resumes take `--snapshot-wait`, `--snapshot-disk-wait`,
|
|
47
48
|
`--remove-original-after`, `--snapshot-size-hint` and `--snapshot-prepare`, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "start-command",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.2",
|
|
4
4
|
"description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
|
|
5
5
|
"main": "src/bin/cli.js",
|
|
6
6
|
"exports": {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
]
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
+
"@link-foundation/sensitive-data-sanitizer": "^0.12.0",
|
|
71
72
|
"command-stream": "^0.9.4",
|
|
72
73
|
"lino-objects-codec": "^0.4.0"
|
|
73
74
|
},
|
package/src/lib/cgroup-memory.js
CHANGED
|
@@ -30,6 +30,15 @@ const { shellQuote } = require('./isolation-log-utils');
|
|
|
30
30
|
/** Default seconds between samples; the watcher environment may override it. */
|
|
31
31
|
const CGROUP_SAMPLE_INTERVAL_SECONDS = 5;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Longest wait between rounds that keep failing (issue #247), unless the
|
|
35
|
+
* interval itself is longer. Each round without a sample doubles the wait.
|
|
36
|
+
*/
|
|
37
|
+
const CGROUP_SAMPLE_BACKOFF_MAX_SECONDS = 60;
|
|
38
|
+
|
|
39
|
+
/** Exit status of both in-container reads for unreadable counters. */
|
|
40
|
+
const CGROUP_UNREADABLE_EXIT = 3;
|
|
41
|
+
|
|
33
42
|
/** Shell variables used by the sampler snippets. */
|
|
34
43
|
const CGROUP_SHELL_VARS = {
|
|
35
44
|
/** Last sample: `<memory.max> <memory.peak> <oom> <oom_kill>`. */
|
|
@@ -80,17 +89,18 @@ function buildCgroupFunctionsSnippet() {
|
|
|
80
89
|
* (`docker exec -i`), so it runs only shell builtins: the task's own CPU
|
|
81
90
|
* limit pays for no `cat`, `sed` or `sleep` processes. Stdin reaches EOF when
|
|
82
91
|
* the exec's client is gone, so the loop never outlives the sampler; dockerd
|
|
83
|
-
* would accept its output even then. Exit 3: unreadable counters
|
|
92
|
+
* would accept its output even then. Exit 3: unreadable counters (no
|
|
93
|
+
* delegated `memory` controller), which no later round can change.
|
|
84
94
|
* @returns {string} Shell script for `sh -c`
|
|
85
95
|
*/
|
|
86
96
|
function buildCgroupStreamScript() {
|
|
87
97
|
return [
|
|
88
98
|
'while read -r __scx_tick; do __scx_oom=; __scx_kill=; __scx_max=-; __scx_peak=-',
|
|
89
|
-
|
|
99
|
+
`[ -r "$1/memory.events" ] || exit ${CGROUP_UNREADABLE_EXIT}`,
|
|
90
100
|
'while read -r __scx_key __scx_value; do case "$__scx_key" in ' +
|
|
91
101
|
'oom) __scx_oom=$__scx_value;; oom_kill) __scx_kill=$__scx_value;; esac; ' +
|
|
92
102
|
'done < "$1/memory.events"',
|
|
93
|
-
|
|
103
|
+
`case "$__scx_oom:$__scx_kill" in *[!0-9:]*|:*|*:) exit ${CGROUP_UNREADABLE_EXIT};; esac`,
|
|
94
104
|
'[ ! -r "$1/memory.max" ] || read -r __scx_max < "$1/memory.max"',
|
|
95
105
|
'[ ! -r "$1/memory.peak" ] || read -r __scx_peak < "$1/memory.peak"',
|
|
96
106
|
'printf \'%s %s %s %s %s\\n\' "${__scx_max:--}" "${__scx_peak:--}" ' +
|
|
@@ -116,15 +126,21 @@ const CGROUP_SAMPLING_OFF_REASON =
|
|
|
116
126
|
* successful read proves the container runs, so Docker is asked for liveness
|
|
117
127
|
* only after a failed round or when the stream ends. Without a usable stream it falls back
|
|
118
128
|
* to one `docker exec` per round; without `sh` in the image (exit 126/127) or
|
|
119
|
-
* without any readable cgroup
|
|
120
|
-
*
|
|
129
|
+
* without any readable cgroup (exit 3 from either exec, issue #247) it
|
|
130
|
+
* records the reason and stops. Other failed rounds are retried, each one
|
|
131
|
+
* doubling the wait up to CGROUP_SAMPLE_BACKOFF_MAX_SECONDS; a sample restores
|
|
132
|
+
* the interval. `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` disables
|
|
133
|
+
* sampling.
|
|
121
134
|
* @param {string} containerName - Container to sample
|
|
122
135
|
* @returns {string} Shell command
|
|
123
136
|
*/
|
|
124
137
|
function buildCgroupSamplerStartSnippet(containerName) {
|
|
125
138
|
const v = CGROUP_SHELL_VARS;
|
|
126
139
|
const name = shellQuote(containerName);
|
|
127
|
-
const remoteRead =
|
|
140
|
+
const remoteRead = `__start_command_cgroup_read /sys/fs/cgroup || exit ${CGROUP_UNREADABLE_EXIT}`;
|
|
141
|
+
// Exit statuses no later round can change: no `sh`, or no counters.
|
|
142
|
+
const permanent = `${CGROUP_UNREADABLE_EXIT}|126|127`;
|
|
143
|
+
const backoff = CGROUP_SAMPLE_BACKOFF_MAX_SECONDS;
|
|
128
144
|
const unavailable = (detail) =>
|
|
129
145
|
`printf '%s\\n' "local cgroup unavailable; ${detail}" > "$${v.file}.reason"`;
|
|
130
146
|
const execReason = unavailable(
|
|
@@ -155,7 +171,8 @@ function buildCgroupSamplerStartSnippet(containerName) {
|
|
|
155
171
|
`__scs_dir=$(__start_command_cgroup_dir ${name}); ` +
|
|
156
172
|
// The namespace is fixed at container creation, including when reads fail.
|
|
157
173
|
`__scs_ns=$(docker inspect -f '{{.HostConfig.CgroupnsMode}}' ${name} 2>/dev/null); __scs_stream=1; __scs_reader=''; ` +
|
|
158
|
-
`
|
|
174
|
+
`__scs_delay=$__scs_interval; ` +
|
|
175
|
+
`while :; do __scs_ok=''; __scs_sampled=''; if [ -n "$__scs_dir" ]; then ` +
|
|
159
176
|
'__scs_line=$(__start_command_cgroup_read "$__scs_dir") && __scs_record "$__scs_line" && __scs_ok=1; ' +
|
|
160
177
|
`elif [ "$__scs_ns" != private ]; then ${unavailable('container cgroup namespace=${__scs_ns:-unavailable}, private required')}; break; ` +
|
|
161
178
|
`elif [ "$__scs_stream" = 1 ]; then ` +
|
|
@@ -171,15 +188,20 @@ function buildCgroupSamplerStartSnippet(containerName) {
|
|
|
171
188
|
`[ -z "$__scs_got" ] || exit 0; ${execReason}; ` +
|
|
172
189
|
`case "$__scs_exit" in ''|0|*[!0-9]*) exit 1;; esac; exit "$__scs_exit"; } & ` +
|
|
173
190
|
`__scs_reader=$!; wait "$__scs_reader"; __scs_exit=$?; trap - TERM; __scs_reader=''; ` +
|
|
174
|
-
// No shell in the image: a per-round exec cannot
|
|
175
|
-
|
|
191
|
+
// No shell in the image or no counters: a per-round exec cannot
|
|
192
|
+
// succeed either.
|
|
193
|
+
`case "$__scs_exit" in 0) __scs_sampled=1;; ${permanent}) break;; *) __scs_stream=0;; esac; ` +
|
|
176
194
|
`else __scs_remote=$(docker exec ${name} sh -c ${shellQuote(`${buildCgroupFunctionsSnippet()}; ${remoteRead}`)} 2>&1); __scs_exit=$?; ` +
|
|
177
195
|
'if [ "$__scs_exit" = 0 ] && __scs_record "$__scs_remote"; then __scs_ok=1; ' +
|
|
178
|
-
`else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); ${execReason};
|
|
196
|
+
`else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); ${execReason}; ` +
|
|
197
|
+
`case "$__scs_exit" in ${permanent}) break;; esac; fi; fi; ` +
|
|
179
198
|
// A successful read needs no Docker liveness probe, and the completion
|
|
180
199
|
// watcher still stops us after docker wait.
|
|
181
200
|
`if [ -z "$__scs_ok" ] && [ "$(docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = false ]; then break; fi; ` +
|
|
182
|
-
|
|
201
|
+
// Back off while rounds keep failing, never below the interval.
|
|
202
|
+
`if [ -n "$__scs_ok$__scs_sampled" ]; then __scs_delay=$__scs_interval; else __scs_delay=$((__scs_delay*2)); ` +
|
|
203
|
+
`[ "$__scs_delay" -le ${backoff} ] || __scs_delay=${backoff}; [ "$__scs_delay" -ge "$__scs_interval" ] || __scs_delay=$__scs_interval; fi; ` +
|
|
204
|
+
`sleep "$__scs_delay"; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
|
|
183
205
|
].join('; ');
|
|
184
206
|
}
|
|
185
207
|
|
|
@@ -373,10 +395,12 @@ function formatCgroupMemory(memory) {
|
|
|
373
395
|
}
|
|
374
396
|
|
|
375
397
|
module.exports = {
|
|
398
|
+
CGROUP_SAMPLE_BACKOFF_MAX_SECONDS,
|
|
376
399
|
CGROUP_SAMPLE_INTERVAL_SECONDS,
|
|
377
400
|
CGROUP_SAMPLING_OFF_REASON,
|
|
378
401
|
CGROUP_SHELL_VARS,
|
|
379
402
|
CGROUP_STREAM_NAME,
|
|
403
|
+
CGROUP_UNREADABLE_EXIT,
|
|
380
404
|
OOM_SCOPE,
|
|
381
405
|
OOM_SCOPE_NOTES,
|
|
382
406
|
buildCgroupFunctionsSnippet,
|
|
@@ -9,7 +9,11 @@ const os = require('os');
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { getTimestamp } = require('./isolation');
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
SanitizationError,
|
|
14
|
+
sanitizeLogToTemp,
|
|
15
|
+
sanitizeText,
|
|
16
|
+
} = require('./log-sanitizer');
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* Handle command failure - detect repository, upload log, create issue
|
|
@@ -296,8 +300,12 @@ function uploadLog(logPath, options = {}) {
|
|
|
296
300
|
}
|
|
297
301
|
|
|
298
302
|
return null;
|
|
299
|
-
} catch {
|
|
300
|
-
console.log(
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.log(
|
|
305
|
+
error instanceof SanitizationError
|
|
306
|
+
? `Warning: ${error.message}`
|
|
307
|
+
: 'Warning: Log preparation or upload failed; upload blocked.'
|
|
308
|
+
);
|
|
301
309
|
return null;
|
|
302
310
|
} finally {
|
|
303
311
|
if (prepared) {
|
|
@@ -334,9 +342,7 @@ function canCreateIssue(owner, repo) {
|
|
|
334
342
|
*/
|
|
335
343
|
function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
|
|
336
344
|
try {
|
|
337
|
-
fullCommand =
|
|
338
|
-
.push(Buffer.from(fullCommand), true)
|
|
339
|
-
.toString();
|
|
345
|
+
fullCommand = sanitizeText(fullCommand);
|
|
340
346
|
const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
|
|
341
347
|
|
|
342
348
|
// Get runtime information
|