start-command 0.37.8 → 0.38.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 +16 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/bin/cli.js +66 -26
- package/src/lib/args-parser-queries.js +90 -9
- package/src/lib/args-parser.js +20 -1
- package/src/lib/container-removal.js +163 -0
- package/src/lib/detached-finalize.js +29 -2
- package/src/lib/docker-cleanup.js +7 -4
- package/src/lib/docker-command-handoff.js +34 -6
- package/src/lib/docker-post-mortem.js +2 -0
- package/src/lib/docker-runtime-args.js +7 -0
- package/src/lib/docker-snapshot-resume.js +222 -0
- package/src/lib/docker-snapshot-safety.js +124 -12
- package/src/lib/execution-attempt.js +21 -9
- package/src/lib/execution-chain.js +182 -0
- package/src/lib/execution-cleanup.js +193 -0
- package/src/lib/execution-control.js +2 -1
- package/src/lib/execution-env.js +120 -0
- package/src/lib/execution-identity.js +180 -0
- package/src/lib/execution-recovery.js +19 -0
- package/src/lib/execution-resume.js +77 -6
- package/src/lib/execution-store.js +63 -22
- package/src/lib/isolation.js +25 -3
- package/src/lib/launch-identity-options.js +81 -0
- package/src/lib/launch-identity.js +411 -0
- package/src/lib/log-uploader.js +15 -1
- package/src/lib/query-commands.js +34 -6
- package/src/lib/screen-isolation.js +11 -2
- package/src/lib/snapshot-resume-options.js +174 -0
- package/src/lib/spawn-helpers.js +33 -16
- package/src/lib/status-formatter.js +64 -35
- package/src/lib/status-links-notation.js +97 -0
- package/src/lib/store-lock.js +3 -0
- package/src/lib/usage.js +15 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +2 -2
- package/test/regression-224.js +807 -0
- package/test/regression-225.js +415 -0
- package/test/regression-226.js +81 -0
- package/test/regression-229.js +267 -0
- package/test/regression-230.js +234 -0
- package/test/regression-231.js +298 -0
- package/test/shared-golden.js +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8e62079: Executions now carry a stable identity a supervisor can rely on. A launch with an explicit `--session-id` that already exists is refused with exit 1 (`execution <uuid> already exists; use $ --resume <uuid> -- <command> to continue it, or pass --append-attempt`) instead of replacing the record and truncating its log; `--append-attempt` keeps the record, archives the previous attempt in `attemptHistory` and appends the new attempt to the same log (#229). Every backend (direct, `--isolated-user`, screen, tmux, docker, ssh, and docker resumes of stopped containers) exports `START_EXECUTION_ID`, `START_SESSION_NAME` and `START_ATTEMPT` to the command it runs (#230). `$ --alias <id> <name>` adds a name and `$ --rename <id> <name>` sets the display name of an execution, `--continues <id>` records the execution a fresh launch continues, `--status` reports `aliases` and the continuation `chain`, and `--upload-log` uploads the logs of the whole chain, oldest first (#231).
|
|
8
|
+
|
|
9
|
+
## 0.37.9
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 55ed66d: A Docker snapshot resume can now be supervised safely. `--snapshot-wait <seconds>` (or `START_SNAPSHOT_LOCK_WAIT`) waits for another resume's snapshot on the host and says once which container it is waiting for. `--snapshot-disk-wait <seconds>` (or `START_SNAPSHOT_DISK_WAIT`) re-checks free disk instead of refusing at once. A busy lock and disk that is still short now exit 75 (`EX_TEMPFAIL`), so a caller can tell "retry later" from a real failure. `--remove-original-after <seconds>` removes the original only if the replacement is still running after the delay. `--snapshot-size-hint <bytes>` skips the `docker inspect --size` walk, and `--snapshot-prepare <command>` runs a cleanup command in the started original before the commit. A long `docker commit` writes its elapsed time to the log every minute. The `start-command.snapshot-image` label is documented as a stable interface, and `$ --cleanup <uuid-or-session-name>` removes an execution's containers and then their snapshot images, never forcing a removal (#224).
|
|
14
|
+
|
|
15
|
+
`$ --status` now reports `containerState removed` and `containerRemovedObservedAt` when the docker container of a finished detached execution no longer exists, instead of showing the old `containerId` as if the container still held its disk. The removal is recorded when start's own cleanup policy removes the container and when `--status` finds it gone; `containerId` is kept for history, and a docker daemon that cannot be reached is not mistaken for a removed container (#225).
|
|
16
|
+
|
|
17
|
+
`$ --status` no longer prints `[object Object]` for nested option values such as `launchOwner`, `exitEvidence`, `resolvedLimits` and `resourceLimitSpecs`: the links-notation output nests them like other objects and the text output prints them as compact JSON (#226).
|
|
18
|
+
|
|
3
19
|
## 0.37.8
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -29,6 +29,9 @@ $ -i docker -d --label task.tool=codex -- my-task
|
|
|
29
29
|
$ --attach <id>
|
|
30
30
|
$ --resume <id> -- <command>
|
|
31
31
|
$ --resume-all
|
|
32
|
+
$ --session-id <uuid> --append-attempt -- <command>
|
|
33
|
+
$ --continues <id> -- <command>
|
|
34
|
+
$ --rename <id> <name>
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
|
|
@@ -40,6 +43,11 @@ checked examples.
|
|
|
40
43
|
containers support copy-free command handoff; legacy snapshots require capacity
|
|
41
44
|
preflight. Log uploads are private and sanitized; manual `--no-sanitize` opts out.
|
|
42
45
|
|
|
46
|
+
Snapshot resumes take `--snapshot-wait`, `--snapshot-disk-wait`,
|
|
47
|
+
`--remove-original-after`, `--snapshot-size-hint` and `--snapshot-prepare`, and
|
|
48
|
+
exit 75 when they should be retried later; `--cleanup <id>` removes one
|
|
49
|
+
execution's containers and snapshot images without forcing.
|
|
50
|
+
|
|
43
51
|
## Development
|
|
44
52
|
|
|
45
53
|
```bash
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -18,7 +18,6 @@ const {
|
|
|
18
18
|
getTimestamp,
|
|
19
19
|
createLogHeader,
|
|
20
20
|
createLogFooter,
|
|
21
|
-
writeLogFile,
|
|
22
21
|
appendLogFile,
|
|
23
22
|
createLogPath,
|
|
24
23
|
getDefaultDockerImage,
|
|
@@ -41,6 +40,7 @@ const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
|
|
|
41
40
|
const { resolveChildExitCode } = require('../lib/exit-reason');
|
|
42
41
|
const { printUsage } = require('../lib/usage');
|
|
43
42
|
const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
|
|
43
|
+
const launchIdentity = require('../lib/launch-identity');
|
|
44
44
|
|
|
45
45
|
// A closed stdout/stderr pipe (`$ --version | head -1`) must neither crash
|
|
46
46
|
// start nor replace the command's exit code: drop the failed output and keep
|
|
@@ -245,6 +245,8 @@ const useCommandStream =
|
|
|
245
245
|
|
|
246
246
|
// Generate session ID if not provided (auto-generate UUID)
|
|
247
247
|
const sessionId = wrapperOptions.sessionId || generateUUID();
|
|
248
|
+
// How this launch uses its execution id (issue #229), set before dispatch
|
|
249
|
+
let launch = null;
|
|
248
250
|
|
|
249
251
|
// Main execution
|
|
250
252
|
(async () => {
|
|
@@ -256,6 +258,19 @@ const sessionId = wrapperOptions.sessionId || generateUUID();
|
|
|
256
258
|
process.exit(queryExitCode === null ? 0 : queryExitCode);
|
|
257
259
|
}
|
|
258
260
|
|
|
261
|
+
// An explicit --session-id must not overwrite a tracked execution (#229).
|
|
262
|
+
try {
|
|
263
|
+
launch = launchIdentity.prepareLaunch(
|
|
264
|
+
getExecutionStore(),
|
|
265
|
+
wrapperOptions,
|
|
266
|
+
sessionId,
|
|
267
|
+
createLogPath(wrapperOptions.isolated || 'direct', sessionId)
|
|
268
|
+
);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error(`Error: ${error.message}`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
259
274
|
if (wrapperOptions.isolated === 'docker') {
|
|
260
275
|
const {
|
|
261
276
|
resolveResourceOptions,
|
|
@@ -314,8 +329,8 @@ async function runWithIsolation(
|
|
|
314
329
|
effectiveImage = getDefaultDockerImage();
|
|
315
330
|
}
|
|
316
331
|
|
|
317
|
-
// Create log file path
|
|
318
|
-
const logFilePath =
|
|
332
|
+
// Create log file path (an appended attempt keeps the execution's log)
|
|
333
|
+
const logFilePath = launch.logPath;
|
|
319
334
|
|
|
320
335
|
// Get session name (will be generated by runIsolated if not provided)
|
|
321
336
|
const sessionName =
|
|
@@ -424,6 +439,7 @@ async function runWithIsolation(
|
|
|
424
439
|
useCommandStream,
|
|
425
440
|
},
|
|
426
441
|
});
|
|
442
|
+
launchIdentity.applyLaunch(executionRecord, launch);
|
|
427
443
|
}
|
|
428
444
|
|
|
429
445
|
// Print start block with session ID and isolation info
|
|
@@ -448,8 +464,13 @@ async function runWithIsolation(
|
|
|
448
464
|
currentExecutionRecord = executionRecord;
|
|
449
465
|
require('../lib/launch-owner').markLaunch(executionRecord);
|
|
450
466
|
try {
|
|
451
|
-
|
|
467
|
+
launchIdentity.claimLaunch(store, executionRecord, launch);
|
|
452
468
|
} catch (err) {
|
|
469
|
+
if (err instanceof launchIdentity.LaunchRefusedError) {
|
|
470
|
+
console.error(`Error: ${err.message}`);
|
|
471
|
+
currentExecutionRecord = null;
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
453
474
|
console.error(
|
|
454
475
|
JSON.stringify({
|
|
455
476
|
code: 'LAUNCH_RESERVATION_FAILED',
|
|
@@ -483,7 +504,12 @@ async function runWithIsolation(
|
|
|
483
504
|
);
|
|
484
505
|
}
|
|
485
506
|
|
|
486
|
-
|
|
507
|
+
launchIdentity.writeLaunchLog(
|
|
508
|
+
logFilePath,
|
|
509
|
+
logContent,
|
|
510
|
+
launch,
|
|
511
|
+
executionRecord
|
|
512
|
+
);
|
|
487
513
|
|
|
488
514
|
let result;
|
|
489
515
|
|
|
@@ -495,6 +521,7 @@ async function runWithIsolation(
|
|
|
495
521
|
...options,
|
|
496
522
|
session: sessionName,
|
|
497
523
|
sessionId,
|
|
524
|
+
attemptNumber: launch.attemptNumber,
|
|
498
525
|
image: effectiveImage,
|
|
499
526
|
endpoint: options.endpoint,
|
|
500
527
|
detached: mode === 'detached',
|
|
@@ -513,7 +540,10 @@ async function runWithIsolation(
|
|
|
513
540
|
});
|
|
514
541
|
} else if (createdUser) {
|
|
515
542
|
// Run directly as the created user (no isolation environment)
|
|
516
|
-
result = await runAsIsolatedUser(
|
|
543
|
+
result = await runAsIsolatedUser(
|
|
544
|
+
launchIdentity.isolatedUserCommand(cmd, sessionId, launch),
|
|
545
|
+
createdUser
|
|
546
|
+
);
|
|
517
547
|
} else {
|
|
518
548
|
// This shouldn't happen in isolation mode, but handle gracefully
|
|
519
549
|
result = { success: false, message: 'No isolation configuration provided' };
|
|
@@ -549,6 +579,10 @@ async function runWithIsolation(
|
|
|
549
579
|
if (mode !== 'detached') {
|
|
550
580
|
executionRecord.complete(exitCode);
|
|
551
581
|
}
|
|
582
|
+
if (result.containerRemoved) {
|
|
583
|
+
// The cleanup policy removed the container (issue #225).
|
|
584
|
+
require('../lib/container-removal').markContainerRemoved(executionRecord);
|
|
585
|
+
}
|
|
552
586
|
try {
|
|
553
587
|
store.save(
|
|
554
588
|
mode === 'detached'
|
|
@@ -633,8 +667,8 @@ async function runDirect(cmd, sessionId) {
|
|
|
633
667
|
const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
|
|
634
668
|
const shellArgs = isWindows ? ['-Command', cmd] : ['-c', cmd];
|
|
635
669
|
|
|
636
|
-
// Setup logging
|
|
637
|
-
const logFilePath =
|
|
670
|
+
// Setup logging (an appended attempt keeps the execution's log)
|
|
671
|
+
const logFilePath = launch.logPath;
|
|
638
672
|
|
|
639
673
|
let logContent = '';
|
|
640
674
|
const startTime = getTimestamp();
|
|
@@ -662,6 +696,8 @@ async function runDirect(cmd, sessionId) {
|
|
|
662
696
|
runtimeVersion,
|
|
663
697
|
},
|
|
664
698
|
});
|
|
699
|
+
launchIdentity.applyLaunch(executionRecord, launch);
|
|
700
|
+
launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
|
|
665
701
|
// Set global reference for signal cleanup
|
|
666
702
|
currentExecutionRecord = executionRecord;
|
|
667
703
|
}
|
|
@@ -742,7 +778,13 @@ async function runDirect(cmd, sessionId) {
|
|
|
742
778
|
shell,
|
|
743
779
|
shellArgs,
|
|
744
780
|
logFilePath,
|
|
745
|
-
logContent
|
|
781
|
+
logContent: launchIdentity.launchLogContent(
|
|
782
|
+
logContent,
|
|
783
|
+
launch,
|
|
784
|
+
executionRecord
|
|
785
|
+
),
|
|
786
|
+
appendLog: launch.append,
|
|
787
|
+
env: launchIdentity.directLaunchEnv(sessionId, launch),
|
|
746
788
|
startTimeMs,
|
|
747
789
|
executionRecord,
|
|
748
790
|
store,
|
|
@@ -782,8 +824,8 @@ async function runDirectWithCommandStream(
|
|
|
782
824
|
const isWindows = process.platform === 'win32';
|
|
783
825
|
const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
|
|
784
826
|
|
|
785
|
-
// Setup logging
|
|
786
|
-
const logFilePath =
|
|
827
|
+
// Setup logging (an appended attempt keeps the execution's log)
|
|
828
|
+
const logFilePath = launch.logPath;
|
|
787
829
|
|
|
788
830
|
let logContent = '';
|
|
789
831
|
const startTime = getTimestamp();
|
|
@@ -812,6 +854,8 @@ async function runDirectWithCommandStream(
|
|
|
812
854
|
executionMode: 'command-stream',
|
|
813
855
|
},
|
|
814
856
|
});
|
|
857
|
+
launchIdentity.applyLaunch(executionRecord, launch);
|
|
858
|
+
launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
|
|
815
859
|
// Set global reference for signal cleanup
|
|
816
860
|
currentExecutionRecord = executionRecord;
|
|
817
861
|
}
|
|
@@ -849,27 +893,23 @@ async function runDirectWithCommandStream(
|
|
|
849
893
|
console.log('[command-stream] Using command-stream library');
|
|
850
894
|
console.log('');
|
|
851
895
|
|
|
852
|
-
// Save initial execution record (PID will be updated later if available)
|
|
853
|
-
if (executionRecord && store) {
|
|
854
|
-
try {
|
|
855
|
-
store.save(executionRecord);
|
|
856
|
-
} catch (err) {
|
|
857
|
-
if (config.verbose) {
|
|
858
|
-
console.error(
|
|
859
|
-
`[Tracking] Warning: Could not save execution record: ${err.message}`
|
|
860
|
-
);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
|
|
865
896
|
// Execute the command using command-stream with real-time output
|
|
866
897
|
// Using mirror: true to show output in real-time, capture: true to collect it
|
|
867
898
|
// Using raw() to avoid auto-escaping that might interfere with complex shell commands
|
|
868
|
-
const $cmd = $({
|
|
899
|
+
const $cmd = $({
|
|
900
|
+
mirror: true,
|
|
901
|
+
capture: true,
|
|
902
|
+
env: launchIdentity.directLaunchEnv(sessionId, launch),
|
|
903
|
+
});
|
|
869
904
|
|
|
870
905
|
let exitCode;
|
|
871
906
|
try {
|
|
872
|
-
|
|
907
|
+
launchIdentity.writeLaunchLog(
|
|
908
|
+
logFilePath,
|
|
909
|
+
logContent,
|
|
910
|
+
launch,
|
|
911
|
+
executionRecord
|
|
912
|
+
);
|
|
873
913
|
// Use raw() to pass the command without auto-escaping
|
|
874
914
|
// This is important for complex commands with pipes, redirects, etc.
|
|
875
915
|
const result = await $cmd`${raw(cmd)}`;
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Query modes: --status, --list [--running]
|
|
9
9
|
* Control modes: --upload-log, --stop, --terminate, --attach [--read-only],
|
|
10
|
-
* --resume [-- <command>], --resume-all,
|
|
10
|
+
* --resume [-- <command>], --resume-all,
|
|
11
|
+
* --cleanup [<uuid-or-session-name>]
|
|
12
|
+
* Naming modes: --alias <id> <name>, --rename <id> <name> (issue #231)
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -37,6 +39,10 @@ function createQueryOptionDefaults() {
|
|
|
37
39
|
resumeAll: false, // Re-attach/resume every execution still marked running
|
|
38
40
|
cleanup: false, // Clean up stale "executing" records
|
|
39
41
|
cleanupDryRun: false, // Show what would be cleaned without cleaning
|
|
42
|
+
cleanupTarget: null, // UUID/session name whose containers and images --cleanup removes
|
|
43
|
+
alias: null, // Execution that --alias adds a name to
|
|
44
|
+
rename: null, // Execution that --rename renames
|
|
45
|
+
newName: null, // Name given by --alias/--rename
|
|
40
46
|
};
|
|
41
47
|
}
|
|
42
48
|
|
|
@@ -75,19 +81,78 @@ const FLAG_OPTIONS = {
|
|
|
75
81
|
'--resume-all': (options) => {
|
|
76
82
|
options.resumeAll = true;
|
|
77
83
|
},
|
|
78
|
-
'--cleanup': (options) => {
|
|
79
|
-
options.cleanup = true;
|
|
80
|
-
},
|
|
81
|
-
'--cleanup-dry-run': (options) => {
|
|
82
|
-
options.cleanup = true;
|
|
83
|
-
options.cleanupDryRun = true;
|
|
84
|
-
},
|
|
85
84
|
};
|
|
86
85
|
|
|
86
|
+
/**
|
|
87
|
+
* `--cleanup` and `--cleanup-dry-run` take an optional execution: without one
|
|
88
|
+
* they clean up stale records, with one they remove that execution's Docker
|
|
89
|
+
* containers and snapshot images (issue #224).
|
|
90
|
+
*/
|
|
91
|
+
const CLEANUP_OPTIONS = {
|
|
92
|
+
'--cleanup': false,
|
|
93
|
+
'--cleanup-dry-run': true,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function parseCleanupOption(args, index, options) {
|
|
97
|
+
const arg = args[index];
|
|
98
|
+
const equals = arg.indexOf('=');
|
|
99
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
100
|
+
if (!(name in CLEANUP_OPTIONS)) {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
options.cleanup = true;
|
|
104
|
+
options.cleanupDryRun = options.cleanupDryRun || CLEANUP_OPTIONS[name];
|
|
105
|
+
if (equals !== -1) {
|
|
106
|
+
const value = arg.slice(equals + 1);
|
|
107
|
+
if (!value) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Option ${name} requires a UUID or session name argument`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
options.cleanupTarget = value;
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
const next = args[index + 1];
|
|
116
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
117
|
+
options.cleanupTarget = next;
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
87
123
|
function hasValue(value) {
|
|
88
124
|
return value !== null && value !== undefined;
|
|
89
125
|
}
|
|
90
126
|
|
|
127
|
+
/**
|
|
128
|
+
* `--alias <id> <name>` and `--rename <id> <name>` take two arguments.
|
|
129
|
+
*/
|
|
130
|
+
const NAMING_OPTIONS = {
|
|
131
|
+
'--alias': 'alias',
|
|
132
|
+
'--rename': 'rename',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function parseNamingOption(args, index, options) {
|
|
136
|
+
const key = NAMING_OPTIONS[args[index]];
|
|
137
|
+
if (!key) {
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
const [identifier, name] = args.slice(index + 1, index + 3);
|
|
141
|
+
if (
|
|
142
|
+
identifier === undefined ||
|
|
143
|
+
name === undefined ||
|
|
144
|
+
identifier.startsWith('-') ||
|
|
145
|
+
name.startsWith('-')
|
|
146
|
+
) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Option ${args[index]} requires a UUID or session name and a new name`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
options[key] = identifier;
|
|
152
|
+
options.newName = name;
|
|
153
|
+
return 3;
|
|
154
|
+
}
|
|
155
|
+
|
|
91
156
|
/**
|
|
92
157
|
* Parse a single query/control option.
|
|
93
158
|
* @param {string[]} args - Arguments array
|
|
@@ -98,6 +163,16 @@ function hasValue(value) {
|
|
|
98
163
|
function parseQueryOption(args, index, options) {
|
|
99
164
|
const arg = args[index];
|
|
100
165
|
|
|
166
|
+
const cleanupConsumed = parseCleanupOption(args, index, options);
|
|
167
|
+
if (cleanupConsumed > 0) {
|
|
168
|
+
return cleanupConsumed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const namingConsumed = parseNamingOption(args, index, options);
|
|
172
|
+
if (namingConsumed > 0) {
|
|
173
|
+
return namingConsumed;
|
|
174
|
+
}
|
|
175
|
+
|
|
101
176
|
const flag = FLAG_OPTIONS[arg];
|
|
102
177
|
if (flag) {
|
|
103
178
|
flag(options);
|
|
@@ -179,6 +254,12 @@ function activeQueryModes(options) {
|
|
|
179
254
|
if (options.cleanup) {
|
|
180
255
|
modes.push('--cleanup');
|
|
181
256
|
}
|
|
257
|
+
if (hasValue(options.alias)) {
|
|
258
|
+
modes.push('--alias');
|
|
259
|
+
}
|
|
260
|
+
if (hasValue(options.rename)) {
|
|
261
|
+
modes.push('--rename');
|
|
262
|
+
}
|
|
182
263
|
return modes;
|
|
183
264
|
}
|
|
184
265
|
|
|
@@ -211,7 +292,7 @@ function validateQueryOptions(options) {
|
|
|
211
292
|
|
|
212
293
|
if (modes.length > 1) {
|
|
213
294
|
throw new Error(
|
|
214
|
-
'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --
|
|
295
|
+
'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename in the same invocation'
|
|
215
296
|
);
|
|
216
297
|
}
|
|
217
298
|
|
package/src/lib/args-parser.js
CHANGED
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
* --read-only Follow --attach output without forwarding stdin
|
|
38
38
|
* --resume <uuid-or-session> [-- cmd] Resume a stored execution (optionally with a new command)
|
|
39
39
|
* --resume-all Re-attach or reconcile every execution still marked running
|
|
40
|
-
* --cleanup
|
|
40
|
+
* --cleanup [<uuid-or-session>] Clean up stale "executing" records, or one execution's containers and snapshot images
|
|
41
41
|
* --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
|
|
42
|
+
* --alias <id> <name>, --rename <id> <name>, --append-attempt, --continues <id>: see args-parser-queries.js, launch-identity-options.js
|
|
42
43
|
*/
|
|
43
44
|
|
|
44
45
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
@@ -46,6 +47,8 @@ const dockerNetworkOptions = require('./docker-network-options');
|
|
|
46
47
|
const dockerRecoveryOptions = require('./docker-recovery-options');
|
|
47
48
|
const cpuPenalty = require('./cpu-penalty');
|
|
48
49
|
const dockerResourceOptions = require('./docker-resource-options');
|
|
50
|
+
const snapshotResumeOptions = require('./snapshot-resume-options');
|
|
51
|
+
const launchIdentityOptions = require('./launch-identity-options');
|
|
49
52
|
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
50
53
|
const { buildCommandString } = require('./shell-utils');
|
|
51
54
|
const {
|
|
@@ -215,6 +218,8 @@ function parseArgs(args) {
|
|
|
215
218
|
shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
|
|
216
219
|
useCommandStream: false, // Use command-stream library for command execution
|
|
217
220
|
...createQueryOptionDefaults(),
|
|
221
|
+
...snapshotResumeOptions.createSnapshotOptionDefaults(),
|
|
222
|
+
...launchIdentityOptions.createLaunchIdentityOptionDefaults(),
|
|
218
223
|
};
|
|
219
224
|
|
|
220
225
|
let commandArgs = [];
|
|
@@ -566,12 +571,24 @@ function parseOption(args, index, options) {
|
|
|
566
571
|
return 1;
|
|
567
572
|
}
|
|
568
573
|
|
|
574
|
+
// Snapshot resume options (--snapshot-wait, --remove-original-after, ...)
|
|
575
|
+
const snapshotConsumed = snapshotResumeOptions.parse(args, index, options);
|
|
576
|
+
if (snapshotConsumed > 0) {
|
|
577
|
+
return snapshotConsumed;
|
|
578
|
+
}
|
|
579
|
+
|
|
569
580
|
// Query/control options (--status, --list, --attach, --resume, ...)
|
|
570
581
|
const queryConsumed = parseQueryOption(args, index, options);
|
|
571
582
|
if (queryConsumed > 0) {
|
|
572
583
|
return queryConsumed;
|
|
573
584
|
}
|
|
574
585
|
|
|
586
|
+
// --append-attempt, --continues <id> (issues #229, #231)
|
|
587
|
+
const identityConsumed = launchIdentityOptions.parse(args, index, options);
|
|
588
|
+
if (identityConsumed > 0) {
|
|
589
|
+
return identityConsumed;
|
|
590
|
+
}
|
|
591
|
+
|
|
575
592
|
// Not a recognized wrapper option
|
|
576
593
|
return 0;
|
|
577
594
|
}
|
|
@@ -820,7 +837,9 @@ function validateOptions(options) {
|
|
|
820
837
|
throw new Error('--keep-user option is only valid with --isolated-user');
|
|
821
838
|
}
|
|
822
839
|
|
|
840
|
+
snapshotResumeOptions.validate(options);
|
|
823
841
|
validateQueryOptions(options);
|
|
842
|
+
launchIdentityOptions.validate(options);
|
|
824
843
|
|
|
825
844
|
// Validate shell option
|
|
826
845
|
if (options.shell !== null && options.shell !== undefined) {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removed-container bookkeeping for `--status` (issue #225).
|
|
3
|
+
*
|
|
4
|
+
* A kept docker container can be removed later (by hand, by an orchestrator,
|
|
5
|
+
* or by start-command's own cleanup policy). `docker inspect` then exits 1
|
|
6
|
+
* with `No such object` / `No such container`, which used to be folded into
|
|
7
|
+
* the same `null` as "docker could not be queried at all", so `--status` kept
|
|
8
|
+
* reporting the old `containerId` as if the container still held its disk.
|
|
9
|
+
*
|
|
10
|
+
* This module tells the two failures apart and records the removal on the
|
|
11
|
+
* execution record as `containerState: removed` plus
|
|
12
|
+
* `containerRemovedObservedAt`. `containerId` is kept for history.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawnSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
/** `containerState` of a record whose container no longer exists. */
|
|
18
|
+
const CONTAINER_STATE_REMOVED = 'removed';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Docker's answer for a container that does not exist:
|
|
22
|
+
* `Error: No such object: <name>` (`docker inspect`, Docker 29 prints it
|
|
23
|
+
* lower-case) and `Error response from daemon: No such container: <name>`
|
|
24
|
+
* (`docker container inspect`). A daemon that cannot be reached prints
|
|
25
|
+
* "Cannot connect" / "failed to connect", which must not match.
|
|
26
|
+
*/
|
|
27
|
+
const DOCKER_NOT_FOUND_PATTERN = /no such (object|container)/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Environment variable the detached watcher sets for the finalizer when its
|
|
31
|
+
* own `docker rm -f` removed the container.
|
|
32
|
+
*/
|
|
33
|
+
const CONTAINER_REMOVED_ENV = 'START_INTERNAL_CONTAINER_REMOVED';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{status?: number|null, stderr?: string|Buffer, error?: Error}} result
|
|
37
|
+
* `spawnSync` result of a `docker inspect`
|
|
38
|
+
* @returns {boolean} True only when docker answered that the object is unknown
|
|
39
|
+
*/
|
|
40
|
+
function isDockerNotFound(result) {
|
|
41
|
+
if (!result || result.error || result.status === 0) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return DOCKER_NOT_FOUND_PATTERN.test(String(result.stderr || ''));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run `docker inspect -f <format> <target>`.
|
|
49
|
+
* @returns {{stdout: string|null, notFound: boolean}} `stdout` is null when
|
|
50
|
+
* the inspect failed; `notFound` says the failure was "no such container"
|
|
51
|
+
*/
|
|
52
|
+
function dockerInspect(dockerCommand, spawnOptions, format, target) {
|
|
53
|
+
const result = spawnSync(
|
|
54
|
+
dockerCommand,
|
|
55
|
+
['inspect', '-f', format, target],
|
|
56
|
+
spawnOptions
|
|
57
|
+
);
|
|
58
|
+
if (result.error || result.status !== 0 || !result.stdout) {
|
|
59
|
+
return { stdout: null, notFound: isDockerNotFound(result) };
|
|
60
|
+
}
|
|
61
|
+
return { stdout: String(result.stdout), notFound: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Set `containerState: removed` on a record, keeping an earlier observation
|
|
66
|
+
* time when there is one.
|
|
67
|
+
* @param {Object} record - Execution record (mutated)
|
|
68
|
+
* @param {string} [observedAt] - When the removal was observed
|
|
69
|
+
* @returns {Object} The same record
|
|
70
|
+
*/
|
|
71
|
+
function markContainerRemoved(record, observedAt = new Date().toISOString()) {
|
|
72
|
+
record.containerState = CONTAINER_STATE_REMOVED;
|
|
73
|
+
record.containerRemovedObservedAt =
|
|
74
|
+
record.containerRemovedObservedAt || observedAt;
|
|
75
|
+
return record;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a finished detached docker record should be checked for removal.
|
|
80
|
+
* Executing records are never touched: a container that is not visible yet
|
|
81
|
+
* on a slow Docker-in-Docker host is not a removed one (issue #136).
|
|
82
|
+
*/
|
|
83
|
+
function shouldProbeRemoval(record) {
|
|
84
|
+
const opts = (record && record.options) || {};
|
|
85
|
+
return (
|
|
86
|
+
record.status === 'executed' &&
|
|
87
|
+
opts.isolated === 'docker' &&
|
|
88
|
+
opts.isolationMode === 'detached' &&
|
|
89
|
+
Boolean(opts.sessionName || opts.containerId) &&
|
|
90
|
+
record.containerState !== CONTAINER_STATE_REMOVED
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Attach `containerState: removed` to a finished detached docker record whose
|
|
96
|
+
* container no longer exists. The container id is preferred over the session
|
|
97
|
+
* name, so a later container that reuses the name is not mistaken for it.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} record - Enriched execution record
|
|
100
|
+
* @param {{dockerCommand: string, spawnOptions: Object,
|
|
101
|
+
* sessionNotFound?: boolean}} probe - How to run docker, and whether the
|
|
102
|
+
* status probe by session name already answered "no such container"
|
|
103
|
+
* @returns {Object} The record, or a copy carrying the removal
|
|
104
|
+
*/
|
|
105
|
+
function attachContainerRemoval(record, probe) {
|
|
106
|
+
if (!shouldProbeRemoval(record)) {
|
|
107
|
+
return record;
|
|
108
|
+
}
|
|
109
|
+
const containerId = record.options.containerId;
|
|
110
|
+
const notFound = containerId
|
|
111
|
+
? dockerInspect(
|
|
112
|
+
probe.dockerCommand,
|
|
113
|
+
probe.spawnOptions,
|
|
114
|
+
'{{.Id}}',
|
|
115
|
+
containerId
|
|
116
|
+
).notFound
|
|
117
|
+
: probe.sessionNotFound === true;
|
|
118
|
+
if (!notFound) {
|
|
119
|
+
return record;
|
|
120
|
+
}
|
|
121
|
+
const copy = Object.assign(
|
|
122
|
+
Object.create(Object.getPrototypeOf(record)),
|
|
123
|
+
record
|
|
124
|
+
);
|
|
125
|
+
return markContainerRemoved(copy);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Persist a removal `--status` observed, best effort: a read-only query must
|
|
130
|
+
* never fail because the store could not be written.
|
|
131
|
+
* @param {Object} store - ExecutionStore
|
|
132
|
+
* @param {Object} stored - Record as read from the store
|
|
133
|
+
* @param {Object} enriched - Record as reported
|
|
134
|
+
*/
|
|
135
|
+
function persistContainerRemoval(store, stored, enriched) {
|
|
136
|
+
if (
|
|
137
|
+
!store ||
|
|
138
|
+
enriched.containerState !== CONTAINER_STATE_REMOVED ||
|
|
139
|
+
stored.containerState === CONTAINER_STATE_REMOVED ||
|
|
140
|
+
typeof store.markContainerRemoved !== 'function'
|
|
141
|
+
) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
store.markContainerRemoved(
|
|
146
|
+
stored.uuid,
|
|
147
|
+
enriched.containerRemovedObservedAt
|
|
148
|
+
);
|
|
149
|
+
} catch {
|
|
150
|
+
// The removal is still reported; it is simply observed again next time.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
CONTAINER_REMOVED_ENV,
|
|
156
|
+
CONTAINER_STATE_REMOVED,
|
|
157
|
+
DOCKER_NOT_FOUND_PATTERN,
|
|
158
|
+
attachContainerRemoval,
|
|
159
|
+
dockerInspect,
|
|
160
|
+
isDockerNotFound,
|
|
161
|
+
markContainerRemoved,
|
|
162
|
+
persistContainerRemoval,
|
|
163
|
+
};
|