start-command 0.30.5 → 0.32.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 +15 -0
- package/package.json +1 -1
- package/src/bin/cli.js +1 -4
- package/src/lib/args-parser.js +13 -3
- package/src/lib/docker-cleanup.js +17 -0
- package/src/lib/docker-network-lifecycle.js +68 -0
- package/src/lib/docker-network-options.js +47 -0
- package/src/lib/isolation.js +86 -9
- package/src/lib/usage.js +2 -0
- package/test/check-js-coverage.mjs +61 -0
- package/test/ci-workflow-invariants.js +274 -0
- package/test/docker-network-integration.js +181 -0
- package/test/docker-network-lifecycle.js +72 -0
- package/test/docker-runtime-options.js +88 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.32.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Allow Docker isolation to join multiple networks before starting the command.
|
|
8
|
+
|
|
9
|
+
Make the Docker multi-network integration test hermetic and add opt-in
|
|
10
|
+
`START_DEBUG` tracing to the Docker network lifecycle helpers.
|
|
11
|
+
|
|
12
|
+
## 0.31.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- b14f11c: Add `--network` and repeatable `--network-alias` options for Docker-isolated commands.
|
|
17
|
+
|
|
3
18
|
## 0.30.5
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -581,10 +581,7 @@ async function runWithIsolation(
|
|
|
581
581
|
alwaysCleanupContainer: options.alwaysCleanupContainer,
|
|
582
582
|
keepContainer: options.keepContainer,
|
|
583
583
|
keepContainerOnFail: options.keepContainerOnFail,
|
|
584
|
-
|
|
585
|
-
mounts: options.mounts,
|
|
586
|
-
env: options.env,
|
|
587
|
-
privileged: options.privileged,
|
|
584
|
+
...buildDockerRuntimeMetadata(options),
|
|
588
585
|
shell: options.shell,
|
|
589
586
|
logPath: logFilePath,
|
|
590
587
|
});
|
package/src/lib/args-parser.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Supports two syntax patterns:
|
|
5
5
|
* 1. $ [wrapper-options] -- [command-options]
|
|
6
6
|
* 2. $ [wrapper-options] command [command-options]
|
|
7
|
-
*
|
|
8
7
|
* Wrapper Options:
|
|
9
8
|
* --isolated, --isolation, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
|
|
10
9
|
* --attached, -a Run in attached mode (foreground)
|
|
@@ -15,6 +14,8 @@
|
|
|
15
14
|
* --mount <mount-spec> Docker --mount spec (repeatable, docker only)
|
|
16
15
|
* --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
17
16
|
* --privileged Run docker container in privileged mode (docker only)
|
|
17
|
+
* --network <name> Connect docker container to a named network (repeatable, docker only)
|
|
18
|
+
* --network-alias <alias> Add an alias to the first network (repeatable, docker only)
|
|
18
19
|
* --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
19
20
|
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
|
|
20
21
|
* --keep-user Keep isolated user after command completes (don't delete)
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
*/
|
|
38
39
|
|
|
39
40
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
41
|
+
const dockerNetworkOptions = require('./docker-network-options');
|
|
40
42
|
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
41
43
|
|
|
42
44
|
// Debug mode from environment
|
|
@@ -178,6 +180,9 @@ function parseArgs(args) {
|
|
|
178
180
|
mounts: [], // Docker --mount specs, applied to docker levels
|
|
179
181
|
env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
|
|
180
182
|
privileged: false, // Run docker container in privileged mode
|
|
183
|
+
network: null, // First Docker network name (compatibility accessor)
|
|
184
|
+
networks: [], // Ordered Docker network names
|
|
185
|
+
networkAliases: [], // Docker network-scoped aliases
|
|
181
186
|
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
182
187
|
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
183
188
|
user: false, // Create isolated user
|
|
@@ -396,6 +401,11 @@ function parseOption(args, index, options) {
|
|
|
396
401
|
return 1;
|
|
397
402
|
}
|
|
398
403
|
|
|
404
|
+
const networkOption = dockerNetworkOptions.parse(args, index, options);
|
|
405
|
+
if (networkOption) {
|
|
406
|
+
return networkOption;
|
|
407
|
+
}
|
|
408
|
+
|
|
399
409
|
// --endpoint (for ssh) - supports sequence for stacked isolation
|
|
400
410
|
if (arg === '--endpoint') {
|
|
401
411
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
@@ -645,8 +655,7 @@ function parseOption(args, index, options) {
|
|
|
645
655
|
}
|
|
646
656
|
|
|
647
657
|
/**
|
|
648
|
-
* Throw if docker runtime options
|
|
649
|
-
* are present but the isolation configuration does not include docker.
|
|
658
|
+
* Throw if docker runtime options are present without docker isolation.
|
|
650
659
|
* @param {object} options - Parsed options
|
|
651
660
|
* @throws {Error} If a docker-only option is set without docker isolation
|
|
652
661
|
*/
|
|
@@ -671,6 +680,7 @@ function validateDockerRuntimeOptionsRequireDocker(options) {
|
|
|
671
680
|
'--privileged option is only valid when isolation stack includes docker'
|
|
672
681
|
);
|
|
673
682
|
}
|
|
683
|
+
dockerNetworkOptions.validateRequireDocker(options);
|
|
674
684
|
}
|
|
675
685
|
|
|
676
686
|
function validateDockerCleanupOptions(options, hasDocker) {
|
|
@@ -109,6 +109,22 @@ function readDockerContainerOomKilled(containerName) {
|
|
|
109
109
|
return null;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function readDockerContainerStatus(containerName) {
|
|
113
|
+
const result = spawnSync(
|
|
114
|
+
getDockerCommand(),
|
|
115
|
+
['inspect', '-f', '{{.State.Status}}', containerName],
|
|
116
|
+
getDockerSpawnOptions({
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
env: process.env,
|
|
119
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
if (result.error || result.status !== 0) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return String(result.stdout || '').trim() || null;
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
function removeDockerContainer(containerName, logPath = null) {
|
|
113
129
|
const result = spawnSync(
|
|
114
130
|
getDockerCommand(),
|
|
@@ -237,6 +253,7 @@ module.exports = {
|
|
|
237
253
|
getDockerContainerCleanupInstructions,
|
|
238
254
|
appendDockerContainerCleanupPolicyMessage,
|
|
239
255
|
readDockerContainerOomKilled,
|
|
256
|
+
readDockerContainerStatus,
|
|
240
257
|
removeDockerContainer,
|
|
241
258
|
buildDetachedDockerCompletionScript,
|
|
242
259
|
startDetachedDockerCompletionWatcher,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Docker multi-network lifecycle helpers. */
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
// Opt-in tracing, off by default: the multi-network attachment sequence
|
|
6
|
+
// (`docker create` -> `docker network connect` -> `docker start`) previously
|
|
7
|
+
// failed in CI with nothing but an exit code to go on (issue #158). Enable with
|
|
8
|
+
// START_DEBUG=1 to see every docker invocation, its status and its output.
|
|
9
|
+
const DEBUG =
|
|
10
|
+
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
11
|
+
|
|
12
|
+
function debugLog(message) {
|
|
13
|
+
if (DEBUG) {
|
|
14
|
+
console.error(`[docker-network] ${message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getDockerNetworks(options = {}) {
|
|
19
|
+
return options.networks?.length
|
|
20
|
+
? options.networks
|
|
21
|
+
: options.network
|
|
22
|
+
? [options.network]
|
|
23
|
+
: [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runDockerCommand(args) {
|
|
27
|
+
debugLog(`$ docker ${args.join(' ')}`);
|
|
28
|
+
const result = spawnSync('docker', args, {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
});
|
|
32
|
+
if (result.error) {
|
|
33
|
+
debugLog(`spawn failed: ${result.error.message}`);
|
|
34
|
+
throw result.error;
|
|
35
|
+
}
|
|
36
|
+
debugLog(
|
|
37
|
+
`exit=${result.status} stdout=${JSON.stringify(
|
|
38
|
+
(result.stdout || '').trim()
|
|
39
|
+
)} stderr=${JSON.stringify((result.stderr || '').trim())}`
|
|
40
|
+
);
|
|
41
|
+
if (result.status !== 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
result.stderr.trim() ||
|
|
44
|
+
result.stdout.trim() ||
|
|
45
|
+
`docker ${args[0]} exited with code ${result.status}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return result.stdout.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function connectAdditionalDockerNetworks(containerName, options = {}) {
|
|
52
|
+
const networks = getDockerNetworks(options);
|
|
53
|
+
debugLog(
|
|
54
|
+
`connecting ${containerName} to additional networks: ${
|
|
55
|
+
networks.slice(1).join(', ') || '<none>'
|
|
56
|
+
}`
|
|
57
|
+
);
|
|
58
|
+
for (const network of networks.slice(1)) {
|
|
59
|
+
runDockerCommand(['network', 'connect', network, containerName]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
connectAdditionalDockerNetworks,
|
|
65
|
+
debugLog,
|
|
66
|
+
getDockerNetworks,
|
|
67
|
+
runDockerCommand,
|
|
68
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Parse and validate Docker network wrapper options. */
|
|
2
|
+
|
|
3
|
+
function parseDockerNetworkOption(args, index, options) {
|
|
4
|
+
const arg = args[index];
|
|
5
|
+
if (arg === '--network' || arg === '--network-alias') {
|
|
6
|
+
if (index + 1 >= args.length || args[index + 1].startsWith('-')) {
|
|
7
|
+
const value = arg === '--network' ? 'network name' : 'alias';
|
|
8
|
+
throw new Error(`Option ${arg} requires a ${value} argument`);
|
|
9
|
+
}
|
|
10
|
+
if (arg === '--network') {
|
|
11
|
+
options.networks.push(args[index + 1]);
|
|
12
|
+
options.network ??= args[index + 1];
|
|
13
|
+
} else {
|
|
14
|
+
options.networkAliases.push(args[index + 1]);
|
|
15
|
+
}
|
|
16
|
+
return 2;
|
|
17
|
+
}
|
|
18
|
+
if (arg.startsWith('--network=')) {
|
|
19
|
+
const network = arg.slice('--network='.length);
|
|
20
|
+
options.networks.push(network);
|
|
21
|
+
options.network ??= network;
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
if (arg.startsWith('--network-alias=')) {
|
|
25
|
+
options.networkAliases.push(arg.slice('--network-alias='.length));
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateDockerNetworkOptionsRequireDocker(options) {
|
|
32
|
+
if (options.network) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'--network option is only valid when isolation stack includes docker'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (options.networkAliases?.length > 0) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'--network-alias option is only valid when isolation stack includes docker'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
parse: parseDockerNetworkOption,
|
|
46
|
+
validateRequireDocker: validateDockerNetworkOptionsRequireDocker,
|
|
47
|
+
};
|
package/src/lib/isolation.js
CHANGED
|
@@ -4,6 +4,11 @@ const { execSync, spawn, spawnSync } = require('child_process');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { generateSessionName } = require('./args-parser');
|
|
6
6
|
const outputBlocks = require('./output-blocks');
|
|
7
|
+
const {
|
|
8
|
+
connectAdditionalDockerNetworks,
|
|
9
|
+
getDockerNetworks,
|
|
10
|
+
runDockerCommand,
|
|
11
|
+
} = require('./docker-network-lifecycle');
|
|
7
12
|
|
|
8
13
|
// Debug mode from environment
|
|
9
14
|
const DEBUG =
|
|
@@ -28,6 +33,7 @@ const {
|
|
|
28
33
|
getDockerContainerCleanupInstructions,
|
|
29
34
|
appendDockerContainerCleanupPolicyMessage,
|
|
30
35
|
readDockerContainerOomKilled,
|
|
36
|
+
readDockerContainerStatus,
|
|
31
37
|
removeDockerContainer,
|
|
32
38
|
startDetachedDockerCompletionWatcher,
|
|
33
39
|
spawnAttachedDocker,
|
|
@@ -499,9 +505,9 @@ const {
|
|
|
499
505
|
/**
|
|
500
506
|
* Build the docker run runtime argument list contributed by configurable
|
|
501
507
|
* container options: privileged mode, environment variables, volumes/bind
|
|
502
|
-
* mounts,
|
|
508
|
+
* mounts, --mount specs, and network configuration. Returned in a stable order so they can be spliced
|
|
503
509
|
* into the `docker run` argv before the image name.
|
|
504
|
-
* @param {object} options - Options (privileged, env, volumes, mounts)
|
|
510
|
+
* @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases)
|
|
505
511
|
* @returns {string[]} Docker CLI arguments
|
|
506
512
|
*/
|
|
507
513
|
function buildDockerRuntimeArgs(options = {}) {
|
|
@@ -518,6 +524,13 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
518
524
|
for (const mount of options.mounts || []) {
|
|
519
525
|
args.push('--mount', mount);
|
|
520
526
|
}
|
|
527
|
+
const [firstNetwork] = getDockerNetworks(options);
|
|
528
|
+
if (firstNetwork) {
|
|
529
|
+
args.push('--network', firstNetwork);
|
|
530
|
+
}
|
|
531
|
+
for (const alias of options.networkAliases || []) {
|
|
532
|
+
args.push('--network-alias', alias);
|
|
533
|
+
}
|
|
521
534
|
return args;
|
|
522
535
|
}
|
|
523
536
|
|
|
@@ -525,7 +538,7 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
525
538
|
* Build the human-readable `[Isolation]` status lines for docker runtime
|
|
526
539
|
* options (volumes, mounts, env, privileged). Empty collections and a falsy
|
|
527
540
|
* privileged flag contribute no lines.
|
|
528
|
-
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
541
|
+
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
|
|
529
542
|
* @returns {string[]} Status lines for the start block / log header
|
|
530
543
|
*/
|
|
531
544
|
function buildDockerRuntimeStatusLines(options = {}) {
|
|
@@ -542,22 +555,41 @@ function buildDockerRuntimeStatusLines(options = {}) {
|
|
|
542
555
|
if (options.privileged) {
|
|
543
556
|
lines.push(`[Isolation] Privileged: true`);
|
|
544
557
|
}
|
|
558
|
+
const networks = getDockerNetworks(options);
|
|
559
|
+
if (networks.length > 0) {
|
|
560
|
+
lines.push(`[Isolation] Network: ${networks[0]}`);
|
|
561
|
+
}
|
|
562
|
+
if (networks.length > 1) {
|
|
563
|
+
lines.push(`[Isolation] Networks: ${networks.join(', ')}`);
|
|
564
|
+
}
|
|
565
|
+
if (options.networkAliases && options.networkAliases.length > 0) {
|
|
566
|
+
lines.push(
|
|
567
|
+
`[Isolation] Network aliases: ${options.networkAliases.join(', ')}`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
545
570
|
return lines;
|
|
546
571
|
}
|
|
547
572
|
|
|
548
573
|
/**
|
|
549
574
|
* Build the execution-record metadata for docker runtime options, normalizing
|
|
550
575
|
* empty collections and a falsy privileged flag to `null`.
|
|
551
|
-
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
552
|
-
* @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}}
|
|
576
|
+
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
|
|
577
|
+
* @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networks: ?string[], networkAliases: ?string[]}}
|
|
553
578
|
*/
|
|
554
579
|
function buildDockerRuntimeMetadata(options = {}) {
|
|
580
|
+
const networks = getDockerNetworks(options);
|
|
555
581
|
return {
|
|
556
582
|
volumes:
|
|
557
583
|
options.volumes && options.volumes.length > 0 ? options.volumes : null,
|
|
558
584
|
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
559
585
|
env: options.env && options.env.length > 0 ? options.env : null,
|
|
560
586
|
privileged: options.privileged || null,
|
|
587
|
+
network: networks[0] || null,
|
|
588
|
+
networks: networks.length > 0 ? networks : null,
|
|
589
|
+
networkAliases:
|
|
590
|
+
options.networkAliases && options.networkAliases.length > 0
|
|
591
|
+
? options.networkAliases
|
|
592
|
+
: null,
|
|
561
593
|
};
|
|
562
594
|
}
|
|
563
595
|
|
|
@@ -597,6 +629,8 @@ function runInDocker(command, options = {}) {
|
|
|
597
629
|
}
|
|
598
630
|
|
|
599
631
|
const containerName = options.session || generateSessionName('docker');
|
|
632
|
+
const containerExistedBeforeLaunch =
|
|
633
|
+
readDockerContainerStatus(containerName) !== null;
|
|
600
634
|
const cleanupPolicy = getDockerContainerCleanupPolicy(options);
|
|
601
635
|
if (!dockerImageExists(options.image)) {
|
|
602
636
|
// Pass logPath so the image-preparation phase (docker pull) is recorded in
|
|
@@ -622,8 +656,9 @@ function runInDocker(command, options = {}) {
|
|
|
622
656
|
console.log();
|
|
623
657
|
|
|
624
658
|
try {
|
|
659
|
+
const needsNetworkSetup = getDockerNetworks(options).length > 1;
|
|
625
660
|
if (options.detached) {
|
|
626
|
-
const dockerArgs = ['run', '-d'];
|
|
661
|
+
const dockerArgs = needsNetworkSetup ? ['create'] : ['run', '-d'];
|
|
627
662
|
if (options.keepAlive) {
|
|
628
663
|
dockerArgs.push('-i', '-t');
|
|
629
664
|
}
|
|
@@ -671,11 +706,29 @@ function runInDocker(command, options = {}) {
|
|
|
671
706
|
dockerResult.stderr.trim() ||
|
|
672
707
|
dockerResult.stdout.trim() ||
|
|
673
708
|
`docker exited with code ${dockerResult.status}`;
|
|
709
|
+
if (
|
|
710
|
+
!containerExistedBeforeLaunch &&
|
|
711
|
+
readDockerContainerStatus(containerName) === 'created'
|
|
712
|
+
) {
|
|
713
|
+
removeDockerContainer(containerName, options.logPath);
|
|
714
|
+
}
|
|
674
715
|
throw new Error(dockerError);
|
|
675
716
|
}
|
|
676
717
|
|
|
677
718
|
const containerId = dockerResult.stdout.trim();
|
|
678
719
|
|
|
720
|
+
if (needsNetworkSetup) {
|
|
721
|
+
try {
|
|
722
|
+
connectAdditionalDockerNetworks(containerName, options);
|
|
723
|
+
runDockerCommand(['start', containerName]);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (!containerExistedBeforeLaunch) {
|
|
726
|
+
removeDockerContainer(containerName, options.logPath);
|
|
727
|
+
}
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
679
732
|
startDetachedDockerCompletionWatcher(
|
|
680
733
|
containerName,
|
|
681
734
|
cleanupPolicy,
|
|
@@ -707,7 +760,7 @@ function runInDocker(command, options = {}) {
|
|
|
707
760
|
message,
|
|
708
761
|
});
|
|
709
762
|
} else {
|
|
710
|
-
const dockerArgs = ['run'];
|
|
763
|
+
const dockerArgs = [needsNetworkSetup ? 'create' : 'run'];
|
|
711
764
|
dockerArgs.push(hasTTY() ? '-it' : '-i');
|
|
712
765
|
dockerArgs.push('--name', containerName);
|
|
713
766
|
if (options.user) {
|
|
@@ -741,9 +794,24 @@ function runInDocker(command, options = {}) {
|
|
|
741
794
|
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
742
795
|
}
|
|
743
796
|
|
|
797
|
+
if (needsNetworkSetup) {
|
|
798
|
+
try {
|
|
799
|
+
runDockerCommand(dockerArgs);
|
|
800
|
+
connectAdditionalDockerNetworks(containerName, options);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (!containerExistedBeforeLaunch) {
|
|
803
|
+
removeDockerContainer(containerName, options.logPath);
|
|
804
|
+
}
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
744
809
|
return new Promise((resolve) => {
|
|
745
810
|
const startTime = Date.now();
|
|
746
|
-
const
|
|
811
|
+
const launchArgs = needsNetworkSetup
|
|
812
|
+
? ['start', '-a', '-i', containerName]
|
|
813
|
+
: dockerArgs;
|
|
814
|
+
const child = spawnAttachedDocker(launchArgs, options.logPath);
|
|
747
815
|
|
|
748
816
|
child.on('exit', (code) => {
|
|
749
817
|
const durationMs = Date.now() - startTime;
|
|
@@ -760,8 +828,17 @@ function runInDocker(command, options = {}) {
|
|
|
760
828
|
}
|
|
761
829
|
|
|
762
830
|
const oomKilled = readDockerContainerOomKilled(containerName);
|
|
831
|
+
const launchFailed =
|
|
832
|
+
!containerExistedBeforeLaunch &&
|
|
833
|
+
readDockerContainerStatus(containerName) === 'created';
|
|
763
834
|
|
|
764
|
-
if (
|
|
835
|
+
if (launchFailed) {
|
|
836
|
+
if (removeDockerContainer(containerName, options.logPath)) {
|
|
837
|
+
message += `\nContainer removed after launch failure.`;
|
|
838
|
+
} else {
|
|
839
|
+
message += `\nWarning: failed to remove container after launch failure.`;
|
|
840
|
+
}
|
|
841
|
+
} else if (
|
|
765
842
|
shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
|
|
766
843
|
) {
|
|
767
844
|
if (removeDockerContainer(containerName, options.logPath)) {
|
package/src/lib/usage.js
CHANGED
|
@@ -14,6 +14,8 @@ Options:
|
|
|
14
14
|
--mount <spec> Docker --mount spec (repeatable, docker only)
|
|
15
15
|
--env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
16
16
|
--privileged Run docker container in privileged mode (docker only)
|
|
17
|
+
--network <name> Connect to a named network (repeatable, docker only)
|
|
18
|
+
--network-alias <alias> Add alias to the first network (repeatable, docker only)
|
|
17
19
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
18
20
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
19
21
|
--keep-user Keep isolated user after command completes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
checkCoverage,
|
|
5
|
+
parseLineCoverage,
|
|
6
|
+
} from '../../scripts/check-js-coverage.mjs';
|
|
7
|
+
|
|
8
|
+
// Verbatim Bun 1.3 text reporter output: the totals row carries no `%` suffix,
|
|
9
|
+
// which is why the previous CI grep never matched (issue #158).
|
|
10
|
+
const BUN_REPORT = `bun test v1.3.14 (0d9b296a)
|
|
11
|
+
-----------------|---------|---------|-------------------
|
|
12
|
+
File | % Funcs | % Lines | Uncovered Line #s
|
|
13
|
+
-----------------|---------|---------|-------------------
|
|
14
|
+
All files | 100.00 | 83.33 |
|
|
15
|
+
test/version.js | 100.00 | 83.33 | 38-43,103,105-108
|
|
16
|
+
-----------------|---------|---------|-------------------
|
|
17
|
+
|
|
18
|
+
14 pass
|
|
19
|
+
0 fail
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
describe('check-js-coverage', () => {
|
|
23
|
+
it('parses line coverage from a real Bun text report', () => {
|
|
24
|
+
expect(parseLineCoverage(BUN_REPORT)).toBe(83.33);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reads the line column, not the function column', () => {
|
|
28
|
+
const report = 'All files | 92.60 | 88.09 |';
|
|
29
|
+
expect(parseLineCoverage(report)).toBe(88.09);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('ignores the uncovered-lines column when picking numbers', () => {
|
|
33
|
+
const report = 'All files | 10.00 | 20.00 | 30-40,50';
|
|
34
|
+
expect(parseLineCoverage(report)).toBe(20);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns null when no totals row is present', () => {
|
|
38
|
+
expect(parseLineCoverage('bun test v1.3.14\n 0 pass\n')).toBeNull();
|
|
39
|
+
expect(parseLineCoverage('')).toBeNull();
|
|
40
|
+
expect(parseLineCoverage(undefined)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('fails instead of skipping when coverage cannot be determined', () => {
|
|
44
|
+
const result = checkCoverage('no table here', 45);
|
|
45
|
+
expect(result.ok).toBe(false);
|
|
46
|
+
expect(result.coverage).toBeNull();
|
|
47
|
+
expect(result.message).toContain('Could not determine coverage');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('passes when coverage meets the threshold', () => {
|
|
51
|
+
const result = checkCoverage(BUN_REPORT, 45);
|
|
52
|
+
expect(result.ok).toBe(true);
|
|
53
|
+
expect(result.coverage).toBe(83.33);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('fails when coverage is below the threshold', () => {
|
|
57
|
+
const result = checkCoverage(BUN_REPORT, 90);
|
|
58
|
+
expect(result.ok).toBe(false);
|
|
59
|
+
expect(result.message).toContain('below the 90% threshold');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Structural invariants for every GitHub Actions workflow (issue #158).
|
|
4
|
+
*
|
|
5
|
+
* These guard the CI/CD properties that silently regressed before: a job with
|
|
6
|
+
* no timeout, a workflow-level `concurrency` block that cancels a started
|
|
7
|
+
* release, a bare `always()` that keeps work running after cancellation, a
|
|
8
|
+
* missing aggregate status job that lets a red run look green, and coverage
|
|
9
|
+
* gates that swallow their own failures.
|
|
10
|
+
*
|
|
11
|
+
* The Rust suite mirrors these checks in `rust/tests/ci_workflow_invariants.rs`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { describe, it } = require('node:test');
|
|
15
|
+
const assert = require('assert');
|
|
16
|
+
const { readdirSync, readFileSync } = require('fs');
|
|
17
|
+
const { join, resolve } = require('path');
|
|
18
|
+
|
|
19
|
+
const workflowDir = resolve(__dirname, '..', '..', '.github', 'workflows');
|
|
20
|
+
const repoRoot = resolve(__dirname, '..', '..');
|
|
21
|
+
|
|
22
|
+
// Jobs that push commits, tags, releases or packages. They must share one
|
|
23
|
+
// repository-wide concurrency group so two writers never run at once.
|
|
24
|
+
const WRITER_JOBS = new Set([
|
|
25
|
+
'release',
|
|
26
|
+
'instant-release',
|
|
27
|
+
'changeset-pr',
|
|
28
|
+
'auto-release',
|
|
29
|
+
'manual-release',
|
|
30
|
+
]);
|
|
31
|
+
const MAIN_WRITER_GROUP = 'main-writer-${{ github.repository }}-main';
|
|
32
|
+
|
|
33
|
+
function listWorkflows() {
|
|
34
|
+
return readdirSync(workflowDir)
|
|
35
|
+
.filter((name) => /\.ya?ml$/.test(name))
|
|
36
|
+
.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readWorkflow(name) {
|
|
40
|
+
return readFileSync(join(workflowDir, name), 'utf8').replaceAll('\r\n', '\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Split a workflow into `{ name, body }` job blocks using indentation. */
|
|
44
|
+
function parseJobs(workflow) {
|
|
45
|
+
const lines = workflow.split('\n');
|
|
46
|
+
const jobsIndex = lines.findIndex((line) => line === 'jobs:');
|
|
47
|
+
assert.notStrictEqual(jobsIndex, -1, 'workflow has no jobs: block');
|
|
48
|
+
|
|
49
|
+
const jobs = [];
|
|
50
|
+
for (let index = jobsIndex + 1; index < lines.length; index++) {
|
|
51
|
+
const header = lines[index].match(/^ {2}([a-zA-Z0-9_-]+):\s*$/);
|
|
52
|
+
if (!header) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const body = [];
|
|
56
|
+
for (let cursor = index + 1; cursor < lines.length; cursor++) {
|
|
57
|
+
const line = lines[cursor];
|
|
58
|
+
if (/^ {2}[a-zA-Z0-9_-]+:\s*$/.test(line) || /^[a-zA-Z]/.test(line)) {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
body.push(line);
|
|
62
|
+
}
|
|
63
|
+
jobs.push({ name: header[1], body: body.join('\n') });
|
|
64
|
+
}
|
|
65
|
+
return jobs;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Value of a key inside a job body, at any indentation depth. */
|
|
69
|
+
function jobKey(body, key, indent = 4) {
|
|
70
|
+
const match = body.match(new RegExp(`^ {${indent}}${key}:[ \\t]*(.*)$`, 'm'));
|
|
71
|
+
return match ? match[1].trim() : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Value of a key inside the job's `concurrency:` block. */
|
|
75
|
+
function concurrencyKey(body, key) {
|
|
76
|
+
return jobKey(body, key, 6);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const workflows = listWorkflows();
|
|
80
|
+
|
|
81
|
+
describe('CI workflow invariants', () => {
|
|
82
|
+
it('finds every workflow file', () => {
|
|
83
|
+
assert.ok(workflows.length >= 4, `only found ${workflows.join(', ')}`);
|
|
84
|
+
for (const expected of [
|
|
85
|
+
'js.yml',
|
|
86
|
+
'rust.yml',
|
|
87
|
+
'security.yml',
|
|
88
|
+
'links.yml',
|
|
89
|
+
]) {
|
|
90
|
+
assert.ok(workflows.includes(expected), `missing ${expected}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('declares a least-privilege default permission set', () => {
|
|
95
|
+
for (const name of workflows) {
|
|
96
|
+
const workflow = readWorkflow(name);
|
|
97
|
+
assert.match(
|
|
98
|
+
workflow,
|
|
99
|
+
/^permissions:\n {2}contents: read$/m,
|
|
100
|
+
`${name} must default to read-only contents permission`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('gives every job a timeout', () => {
|
|
106
|
+
for (const name of workflows) {
|
|
107
|
+
for (const job of parseJobs(readWorkflow(name))) {
|
|
108
|
+
assert.ok(
|
|
109
|
+
jobKey(job.body, 'timeout-minutes'),
|
|
110
|
+
`${name}: job "${job.name}" has no timeout-minutes`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('never puts concurrency at workflow level', () => {
|
|
117
|
+
for (const name of workflows) {
|
|
118
|
+
assert.doesNotMatch(
|
|
119
|
+
readWorkflow(name),
|
|
120
|
+
/^concurrency:$/m,
|
|
121
|
+
`${name}: workflow-level concurrency would also cancel started writers`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('gives every job its own concurrency group', () => {
|
|
127
|
+
for (const name of workflows) {
|
|
128
|
+
for (const job of parseJobs(readWorkflow(name))) {
|
|
129
|
+
if (job.name === 'pipeline-status') {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const group = concurrencyKey(job.body, 'group');
|
|
133
|
+
assert.ok(group, `${name}: job "${job.name}" has no concurrency group`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('makes writers non-cancellable and read-only checks cancellable', () => {
|
|
139
|
+
for (const name of workflows) {
|
|
140
|
+
for (const job of parseJobs(readWorkflow(name))) {
|
|
141
|
+
const group = concurrencyKey(job.body, 'group');
|
|
142
|
+
if (!group) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const cancel = concurrencyKey(job.body, 'cancel-in-progress');
|
|
146
|
+
if (WRITER_JOBS.has(job.name)) {
|
|
147
|
+
assert.strictEqual(
|
|
148
|
+
group,
|
|
149
|
+
MAIN_WRITER_GROUP,
|
|
150
|
+
`${name}: writer "${job.name}" must use the shared main-writer group`
|
|
151
|
+
);
|
|
152
|
+
assert.strictEqual(
|
|
153
|
+
cancel,
|
|
154
|
+
'false',
|
|
155
|
+
`${name}: writer "${job.name}" must not be cancellable`
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
assert.ok(
|
|
159
|
+
group.startsWith('check-'),
|
|
160
|
+
`${name}: check "${job.name}" must use a check-* group, got ${group}`
|
|
161
|
+
);
|
|
162
|
+
assert.strictEqual(
|
|
163
|
+
cancel,
|
|
164
|
+
'true',
|
|
165
|
+
`${name}: superseded check "${job.name}" should be cancelled`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('uses !cancelled() rather than always() outside the status job', () => {
|
|
173
|
+
for (const name of workflows) {
|
|
174
|
+
for (const job of parseJobs(readWorkflow(name))) {
|
|
175
|
+
if (job.name === 'pipeline-status') {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
assert.doesNotMatch(
|
|
179
|
+
job.body,
|
|
180
|
+
/always\(\)/,
|
|
181
|
+
`${name}: job "${job.name}" uses always(); use !cancelled() so cancellation propagates`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('aggregates every job into a pipeline-status gate', () => {
|
|
188
|
+
for (const name of workflows) {
|
|
189
|
+
const jobs = parseJobs(readWorkflow(name));
|
|
190
|
+
const status = jobs.find((job) => job.name === 'pipeline-status');
|
|
191
|
+
assert.ok(status, `${name}: no pipeline-status job`);
|
|
192
|
+
assert.match(
|
|
193
|
+
status.body,
|
|
194
|
+
/if: always\(\)/,
|
|
195
|
+
`${name}: pipeline-status must run even when jobs are cancelled`
|
|
196
|
+
);
|
|
197
|
+
for (const job of jobs) {
|
|
198
|
+
if (job.name === 'pipeline-status') {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
assert.match(
|
|
202
|
+
status.body,
|
|
203
|
+
new RegExp(`(^|[\\s\\[,])${job.name}([\\s\\],]|$)`, 'm'),
|
|
204
|
+
`${name}: pipeline-status does not depend on "${job.name}"`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('configures git before checkout so no init hints are printed', () => {
|
|
211
|
+
for (const name of workflows) {
|
|
212
|
+
const workflow = readWorkflow(name);
|
|
213
|
+
assert.match(workflow, /GIT_CONFIG_COUNT: '1'/, `${name}`);
|
|
214
|
+
assert.match(
|
|
215
|
+
workflow,
|
|
216
|
+
/GIT_CONFIG_KEY_0: init\.defaultBranch/,
|
|
217
|
+
`${name}`
|
|
218
|
+
);
|
|
219
|
+
assert.match(workflow, /GIT_CONFIG_VALUE_0: main/, `${name}`);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('never swallows a failing command in a quality gate', () => {
|
|
224
|
+
for (const name of workflows) {
|
|
225
|
+
const workflow = readWorkflow(name);
|
|
226
|
+
for (const [index, line] of workflow.split('\n').entries()) {
|
|
227
|
+
// `|| true` on a `grep` that is allowed to find nothing is fine; the
|
|
228
|
+
// regression was `... | tee coverage.txt || true`, which hid failing
|
|
229
|
+
// tests from the coverage job.
|
|
230
|
+
const hidesAGate = /\btee\b|\bbun run\b|\bcargo\b|\bnpm run\b/.test(
|
|
231
|
+
line
|
|
232
|
+
);
|
|
233
|
+
if (
|
|
234
|
+
line.includes('|| true') &&
|
|
235
|
+
hidesAGate &&
|
|
236
|
+
!line.trimStart().startsWith('#')
|
|
237
|
+
) {
|
|
238
|
+
assert.fail(
|
|
239
|
+
`${name}:${index + 1} uses "|| true", which hides failures: ${line.trim()}`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('references only helper scripts that exist', () => {
|
|
247
|
+
const pattern = /(?:bash|node|bun)\s+(scripts\/[A-Za-z0-9._/-]+)/g;
|
|
248
|
+
for (const name of workflows) {
|
|
249
|
+
const workflow = readWorkflow(name);
|
|
250
|
+
for (const match of workflow.matchAll(pattern)) {
|
|
251
|
+
const scriptPath = join(repoRoot, match[1]);
|
|
252
|
+
assert.doesNotThrow(
|
|
253
|
+
() => readFileSync(scriptPath),
|
|
254
|
+
`${name} references missing ${match[1]}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('parses coverage through the tested helper, not an inline grep', () => {
|
|
261
|
+
const js = readWorkflow('js.yml');
|
|
262
|
+
assert.doesNotMatch(
|
|
263
|
+
js,
|
|
264
|
+
/grep -oP '\\d\+\\\.\\d\+\(\?=%\)'/,
|
|
265
|
+
'the inline coverage grep never matched Bun output (issue #158)'
|
|
266
|
+
);
|
|
267
|
+
assert.match(js, /scripts\/check-js-coverage\.mjs/);
|
|
268
|
+
assert.doesNotMatch(
|
|
269
|
+
js,
|
|
270
|
+
/Could not determine coverage, skipping check/,
|
|
271
|
+
'an unparsable coverage report must fail, not skip'
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** Real-daemon integration coverage for Docker network isolation (issues #154, #156, #158). */
|
|
3
|
+
|
|
4
|
+
const { after, before, describe, it } = require('node:test');
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const { randomUUID } = require('crypto');
|
|
8
|
+
const { runInDocker } = require('../src/lib/isolation');
|
|
9
|
+
|
|
10
|
+
// Bun's `node:test` shim ignores a `describe`-level timeout, so every `it`
|
|
11
|
+
// declares its own budget explicitly (issue #158).
|
|
12
|
+
const TEST_TIMEOUT = 120000;
|
|
13
|
+
|
|
14
|
+
const suffix = randomUUID().slice(0, 8);
|
|
15
|
+
const network = `start-network-${suffix}`;
|
|
16
|
+
const secondNetwork = `start-network-b-${suffix}`;
|
|
17
|
+
const sidecar = `start-sidecar-${suffix}`;
|
|
18
|
+
const secondSidecar = `start-sidecar-b-${suffix}`;
|
|
19
|
+
const connected = `start-connected-${suffix}`;
|
|
20
|
+
const control = `start-control-${suffix}`;
|
|
21
|
+
const missing = `start-missing-${suffix}`;
|
|
22
|
+
const missingAttached = `start-missing-attached-${suffix}`;
|
|
23
|
+
let dockerAvailable = false;
|
|
24
|
+
|
|
25
|
+
function docker(args) {
|
|
26
|
+
return spawnSync('docker', args, { encoding: 'utf8' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Container output, folded into assertion messages so a CI failure carries the
|
|
31
|
+
* evidence needed to diagnose it instead of only reporting the exit code.
|
|
32
|
+
*/
|
|
33
|
+
function containerDiagnostics(name) {
|
|
34
|
+
const logs = docker(['logs', name]);
|
|
35
|
+
const output = `${logs.stdout || ''}${logs.stderr || ''}`.trim();
|
|
36
|
+
return `\n--- docker logs ${name} ---\n${output || '<no output>'}\n---`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createInternalNetworkWithSidecar(networkName, containerName, alias) {
|
|
40
|
+
const created = docker(['network', 'create', '--internal', networkName]);
|
|
41
|
+
assert.strictEqual(created.status, 0, created.stderr);
|
|
42
|
+
const started = docker([
|
|
43
|
+
'run',
|
|
44
|
+
'-d',
|
|
45
|
+
'--name',
|
|
46
|
+
containerName,
|
|
47
|
+
'--network',
|
|
48
|
+
networkName,
|
|
49
|
+
'--network-alias',
|
|
50
|
+
alias,
|
|
51
|
+
'alpine:3.23',
|
|
52
|
+
'sleep',
|
|
53
|
+
'300',
|
|
54
|
+
]);
|
|
55
|
+
assert.strictEqual(started.status, 0, started.stderr);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('Docker named network integration', () => {
|
|
59
|
+
before(() => {
|
|
60
|
+
dockerAvailable =
|
|
61
|
+
process.platform === 'linux' && docker(['info']).status === 0;
|
|
62
|
+
if (!dockerAvailable) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
createInternalNetworkWithSidecar(network, sidecar, 'formal-ai');
|
|
67
|
+
createInternalNetworkWithSidecar(secondNetwork, secondSidecar, 'formal-db');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
after(() => {
|
|
71
|
+
if (!dockerAvailable) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
docker([
|
|
75
|
+
'rm',
|
|
76
|
+
'-f',
|
|
77
|
+
sidecar,
|
|
78
|
+
secondSidecar,
|
|
79
|
+
connected,
|
|
80
|
+
control,
|
|
81
|
+
missing,
|
|
82
|
+
missingAttached,
|
|
83
|
+
]);
|
|
84
|
+
docker(['network', 'rm', network, secondNetwork]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it(
|
|
88
|
+
'reaches a private sidecar on each of two networks',
|
|
89
|
+
{ timeout: TEST_TIMEOUT },
|
|
90
|
+
async () => {
|
|
91
|
+
if (!dockerAvailable) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Both endpoints are local, user-defined networks on purpose: probing a
|
|
96
|
+
// public endpoint (previously `https://api.github.com`) made this test
|
|
97
|
+
// fail whenever the shared runner IP hit the 60 requests/hour
|
|
98
|
+
// unauthenticated GitHub API rate limit (issue #158).
|
|
99
|
+
const joined = await runInDocker(
|
|
100
|
+
'ping -c 1 formal-ai && ping -c 1 formal-db',
|
|
101
|
+
{
|
|
102
|
+
image: 'alpine:3.23',
|
|
103
|
+
session: connected,
|
|
104
|
+
network,
|
|
105
|
+
networks: [network, secondNetwork],
|
|
106
|
+
detached: true,
|
|
107
|
+
shell: 'sh',
|
|
108
|
+
keepContainer: true,
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
assert.strictEqual(joined.success, true, joined.message);
|
|
112
|
+
const joinedExit = docker(['wait', connected]);
|
|
113
|
+
assert.strictEqual(joinedExit.status, 0, joinedExit.stderr);
|
|
114
|
+
assert.strictEqual(
|
|
115
|
+
joinedExit.stdout.trim(),
|
|
116
|
+
'0',
|
|
117
|
+
`container did not exit cleanly${containerDiagnostics(connected)}`
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const unconnected = await runInDocker('ping -c 1 formal-ai', {
|
|
121
|
+
image: 'alpine:3.23',
|
|
122
|
+
session: control,
|
|
123
|
+
shell: 'sh',
|
|
124
|
+
alwaysCleanupContainer: true,
|
|
125
|
+
});
|
|
126
|
+
assert.strictEqual(unconnected.success, false, unconnected.message);
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
it(
|
|
131
|
+
'fails for a missing network without orphaning a container',
|
|
132
|
+
{ timeout: TEST_TIMEOUT },
|
|
133
|
+
async () => {
|
|
134
|
+
if (!dockerAvailable) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = await runInDocker('echo should-not-run', {
|
|
139
|
+
image: 'alpine:3.23',
|
|
140
|
+
session: missing,
|
|
141
|
+
network: `${network}-absent`,
|
|
142
|
+
detached: true,
|
|
143
|
+
shell: 'sh',
|
|
144
|
+
});
|
|
145
|
+
assert.strictEqual(result.success, false);
|
|
146
|
+
assert.match(result.message, /network .* not found/i);
|
|
147
|
+
assert.notStrictEqual(docker(['inspect', missing]).status, 0);
|
|
148
|
+
|
|
149
|
+
const missingSecond = await runInDocker('echo should-not-run', {
|
|
150
|
+
image: 'alpine:3.23',
|
|
151
|
+
session: missing,
|
|
152
|
+
network,
|
|
153
|
+
networks: [network, `${network}-absent`],
|
|
154
|
+
detached: true,
|
|
155
|
+
shell: 'sh',
|
|
156
|
+
});
|
|
157
|
+
assert.strictEqual(missingSecond.success, false);
|
|
158
|
+
assert.match(missingSecond.message, /network .* not found/i);
|
|
159
|
+
assert.notStrictEqual(docker(['inspect', missing]).status, 0);
|
|
160
|
+
|
|
161
|
+
const attachedResult = await runInDocker('echo should-not-run', {
|
|
162
|
+
image: 'alpine:3.23',
|
|
163
|
+
session: missingAttached,
|
|
164
|
+
network: `${network}-absent`,
|
|
165
|
+
shell: 'sh',
|
|
166
|
+
});
|
|
167
|
+
assert.strictEqual(attachedResult.success, false);
|
|
168
|
+
assert.notStrictEqual(docker(['inspect', missingAttached]).status, 0);
|
|
169
|
+
|
|
170
|
+
const conflict = await runInDocker('echo should-not-run', {
|
|
171
|
+
image: 'alpine:3.23',
|
|
172
|
+
session: sidecar,
|
|
173
|
+
network: `${network}-absent`,
|
|
174
|
+
detached: true,
|
|
175
|
+
shell: 'sh',
|
|
176
|
+
});
|
|
177
|
+
assert.strictEqual(conflict.success, false);
|
|
178
|
+
assert.strictEqual(docker(['inspect', sidecar]).status, 0);
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Unit coverage for the Docker multi-network lifecycle helpers (issue #158).
|
|
4
|
+
*
|
|
5
|
+
* The verbose mode added here exists because the failing CI run gave nothing
|
|
6
|
+
* but an exit code for the `docker create` -> `network connect` -> `start`
|
|
7
|
+
* sequence. It must stay OFF unless START_DEBUG is explicitly set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { describe, it } = require('node:test');
|
|
11
|
+
const assert = require('assert');
|
|
12
|
+
const {
|
|
13
|
+
debugLog,
|
|
14
|
+
getDockerNetworks,
|
|
15
|
+
} = require('../src/lib/docker-network-lifecycle');
|
|
16
|
+
|
|
17
|
+
/** Capture everything written to console.error while `run` executes. */
|
|
18
|
+
function captureStderr(run) {
|
|
19
|
+
const original = console.error;
|
|
20
|
+
const lines = [];
|
|
21
|
+
console.error = (...args) => lines.push(args.join(' '));
|
|
22
|
+
try {
|
|
23
|
+
run();
|
|
24
|
+
} finally {
|
|
25
|
+
console.error = original;
|
|
26
|
+
}
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('docker network lifecycle helpers', () => {
|
|
31
|
+
it('returns an empty list when no network is requested', () => {
|
|
32
|
+
assert.deepStrictEqual(getDockerNetworks({}), []);
|
|
33
|
+
assert.deepStrictEqual(getDockerNetworks(), []);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('wraps a single network into a list', () => {
|
|
37
|
+
assert.deepStrictEqual(getDockerNetworks({ network: 'bridge' }), [
|
|
38
|
+
'bridge',
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('prefers the explicit networks list over the single network', () => {
|
|
43
|
+
assert.deepStrictEqual(
|
|
44
|
+
getDockerNetworks({ network: 'bridge', networks: ['a', 'b'] }),
|
|
45
|
+
['a', 'b']
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('ignores an empty networks list and falls back to network', () => {
|
|
50
|
+
assert.deepStrictEqual(
|
|
51
|
+
getDockerNetworks({ network: 'bridge', networks: [] }),
|
|
52
|
+
['bridge']
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps verbose output switched off by default', () => {
|
|
57
|
+
const previous = process.env.START_DEBUG;
|
|
58
|
+
delete process.env.START_DEBUG;
|
|
59
|
+
try {
|
|
60
|
+
// The module reads START_DEBUG once at load time, and the test process
|
|
61
|
+
// never sets it, so nothing may be printed.
|
|
62
|
+
assert.deepStrictEqual(
|
|
63
|
+
captureStderr(() => debugLog('should not appear')),
|
|
64
|
+
[]
|
|
65
|
+
);
|
|
66
|
+
} finally {
|
|
67
|
+
if (previous !== undefined) {
|
|
68
|
+
process.env.START_DEBUG = previous;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Tests for Docker runtime options: --volume/-v, --mount, --env/-e,
|
|
3
|
+
* Tests for Docker runtime options: --volume/-v, --mount, --env/-e,
|
|
4
|
+
* --privileged, --network, and --network-alias
|
|
4
5
|
*
|
|
5
6
|
* Reproduces issue #132: callers need to configure bind mounts, volumes,
|
|
6
7
|
* environment variables, and privileged mode for the docker isolation backend
|
|
@@ -90,12 +91,65 @@ describe('Docker runtime options parsing', () => {
|
|
|
90
91
|
assert.strictEqual(result.wrapperOptions.privileged, true);
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
it('should parse repeatable --network forms without consuming child args', () => {
|
|
95
|
+
const spaced = parseArgs([
|
|
96
|
+
'-i',
|
|
97
|
+
'docker',
|
|
98
|
+
'--network',
|
|
99
|
+
'hive-formal-ai',
|
|
100
|
+
'--network=public-egress',
|
|
101
|
+
'--',
|
|
102
|
+
'--network',
|
|
103
|
+
'child-network',
|
|
104
|
+
]);
|
|
105
|
+
const equals = parseArgs([
|
|
106
|
+
'-i',
|
|
107
|
+
'docker',
|
|
108
|
+
'--network=hive-formal-ai',
|
|
109
|
+
'--',
|
|
110
|
+
'echo',
|
|
111
|
+
'ok',
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
assert.strictEqual(spaced.wrapperOptions.network, 'hive-formal-ai');
|
|
115
|
+
assert.deepStrictEqual(spaced.wrapperOptions.networks, [
|
|
116
|
+
'hive-formal-ai',
|
|
117
|
+
'public-egress',
|
|
118
|
+
]);
|
|
119
|
+
assert.deepStrictEqual(spaced.rawCommand, ['--network', 'child-network']);
|
|
120
|
+
assert.strictEqual(equals.wrapperOptions.network, 'hive-formal-ai');
|
|
121
|
+
assert.deepStrictEqual(equals.wrapperOptions.networks, ['hive-formal-ai']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should parse repeatable --network-alias value and equals forms', () => {
|
|
125
|
+
const result = parseArgs([
|
|
126
|
+
'-i',
|
|
127
|
+
'docker',
|
|
128
|
+
'--network',
|
|
129
|
+
'hive-formal-ai',
|
|
130
|
+
'--network-alias',
|
|
131
|
+
'formal-ai',
|
|
132
|
+
'--network-alias=checker',
|
|
133
|
+
'--',
|
|
134
|
+
'echo',
|
|
135
|
+
'ok',
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
assert.deepStrictEqual(result.wrapperOptions.networkAliases, [
|
|
139
|
+
'formal-ai',
|
|
140
|
+
'checker',
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
93
144
|
it('should default runtime options to empty/false', () => {
|
|
94
145
|
const result = parseArgs(['-i', 'docker', '--', 'ls']);
|
|
95
146
|
assert.deepStrictEqual(result.wrapperOptions.volumes, []);
|
|
96
147
|
assert.deepStrictEqual(result.wrapperOptions.mounts, []);
|
|
97
148
|
assert.deepStrictEqual(result.wrapperOptions.env, []);
|
|
98
149
|
assert.strictEqual(result.wrapperOptions.privileged, false);
|
|
150
|
+
assert.strictEqual(result.wrapperOptions.network, null);
|
|
151
|
+
assert.deepStrictEqual(result.wrapperOptions.networks, []);
|
|
152
|
+
assert.deepStrictEqual(result.wrapperOptions.networkAliases, []);
|
|
99
153
|
});
|
|
100
154
|
|
|
101
155
|
it('should throw when --volume requires an argument', () => {
|
|
@@ -136,6 +190,15 @@ describe('Docker runtime options validation', () => {
|
|
|
136
190
|
}, /--privileged option is only valid when isolation stack includes docker/);
|
|
137
191
|
});
|
|
138
192
|
|
|
193
|
+
it('should reject network options without docker', () => {
|
|
194
|
+
assert.throws(() => {
|
|
195
|
+
parseArgs(['-i', 'tmux', '--network', 'private', '--', 'ls']);
|
|
196
|
+
}, /--network option is only valid when isolation stack includes docker/);
|
|
197
|
+
assert.throws(() => {
|
|
198
|
+
parseArgs(['--network-alias', 'service', '--', 'ls']);
|
|
199
|
+
}, /--network-alias option is only valid when isolation stack includes docker/);
|
|
200
|
+
});
|
|
201
|
+
|
|
139
202
|
it('should accept runtime options when stack includes docker', () => {
|
|
140
203
|
const result = parseArgs([
|
|
141
204
|
'-i',
|
|
@@ -171,6 +234,9 @@ describe('buildDockerRuntimeArgs', () => {
|
|
|
171
234
|
env: ['FOO=bar', 'GH_TOKEN=secret'],
|
|
172
235
|
volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
|
|
173
236
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
237
|
+
network: 'hive-formal-ai',
|
|
238
|
+
networks: ['hive-formal-ai', 'public-egress'],
|
|
239
|
+
networkAliases: ['formal-ai', 'checker'],
|
|
174
240
|
});
|
|
175
241
|
assert.deepStrictEqual(args, [
|
|
176
242
|
'--privileged',
|
|
@@ -184,6 +250,12 @@ describe('buildDockerRuntimeArgs', () => {
|
|
|
184
250
|
'/h/b:/c/b:ro',
|
|
185
251
|
'--mount',
|
|
186
252
|
'type=bind,src=/h,dst=/c',
|
|
253
|
+
'--network',
|
|
254
|
+
'hive-formal-ai',
|
|
255
|
+
'--network-alias',
|
|
256
|
+
'formal-ai',
|
|
257
|
+
'--network-alias',
|
|
258
|
+
'checker',
|
|
187
259
|
]);
|
|
188
260
|
});
|
|
189
261
|
});
|
|
@@ -199,12 +271,18 @@ describe('buildDockerRuntimeStatusLines', () => {
|
|
|
199
271
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
200
272
|
env: ['FOO=bar'],
|
|
201
273
|
privileged: true,
|
|
274
|
+
network: 'hive-formal-ai',
|
|
275
|
+
networks: ['hive-formal-ai', 'public-egress'],
|
|
276
|
+
networkAliases: ['formal-ai', 'checker'],
|
|
202
277
|
});
|
|
203
278
|
assert.deepStrictEqual(lines, [
|
|
204
279
|
'[Isolation] Volumes: /h:/c:ro',
|
|
205
280
|
'[Isolation] Mounts: type=bind,src=/h,dst=/c',
|
|
206
281
|
'[Isolation] Env: FOO=bar',
|
|
207
282
|
'[Isolation] Privileged: true',
|
|
283
|
+
'[Isolation] Network: hive-formal-ai',
|
|
284
|
+
'[Isolation] Networks: hive-formal-ai, public-egress',
|
|
285
|
+
'[Isolation] Network aliases: formal-ai, checker',
|
|
208
286
|
]);
|
|
209
287
|
});
|
|
210
288
|
|
|
@@ -223,6 +301,9 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
223
301
|
mounts: null,
|
|
224
302
|
env: null,
|
|
225
303
|
privileged: null,
|
|
304
|
+
network: null,
|
|
305
|
+
networks: null,
|
|
306
|
+
networkAliases: null,
|
|
226
307
|
});
|
|
227
308
|
});
|
|
228
309
|
|
|
@@ -233,12 +314,18 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
233
314
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
234
315
|
env: ['FOO=bar'],
|
|
235
316
|
privileged: true,
|
|
317
|
+
network: 'hive-formal-ai',
|
|
318
|
+
networks: ['hive-formal-ai', 'public-egress'],
|
|
319
|
+
networkAliases: ['formal-ai', 'checker'],
|
|
236
320
|
}),
|
|
237
321
|
{
|
|
238
322
|
volumes: ['/h:/c'],
|
|
239
323
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
240
324
|
env: ['FOO=bar'],
|
|
241
325
|
privileged: true,
|
|
326
|
+
network: 'hive-formal-ai',
|
|
327
|
+
networks: ['hive-formal-ai', 'public-egress'],
|
|
328
|
+
networkAliases: ['formal-ai', 'checker'],
|
|
242
329
|
}
|
|
243
330
|
);
|
|
244
331
|
});
|