start-command 0.31.0 → 0.32.1
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/lib/args-parser.js +4 -4
- package/src/lib/docker-network-lifecycle.js +68 -0
- package/src/lib/docker-network-options.js +5 -2
- package/src/lib/isolation.js +51 -11
- package/src/lib/usage.js +2 -2
- package/test/check-js-coverage.mjs +61 -0
- package/test/ci-workflow-invariants.js +274 -0
- package/test/docker-network-integration.js +148 -79
- package/test/docker-network-lifecycle.js +72 -0
- package/test/docker-runtime-options.js +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.32.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5568403: Keep Docker multi-network regression coverage hermetic while verifying the default bridge route.
|
|
8
|
+
|
|
9
|
+
## 0.32.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- Allow Docker isolation to join multiple networks before starting the command.
|
|
14
|
+
|
|
15
|
+
Make the Docker multi-network integration test hermetic and add opt-in
|
|
16
|
+
`START_DEBUG` tracing to the Docker network lifecycle helpers.
|
|
17
|
+
|
|
3
18
|
## 0.31.0
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
package/package.json
CHANGED
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,8 +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)
|
|
18
|
-
* --network <name> Connect docker container to a named network (docker only)
|
|
19
|
-
* --network-alias <alias> Add
|
|
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)
|
|
20
19
|
* --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
21
20
|
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
|
|
22
21
|
* --keep-user Keep isolated user after command completes (don't delete)
|
|
@@ -181,7 +180,8 @@ function parseArgs(args) {
|
|
|
181
180
|
mounts: [], // Docker --mount specs, applied to docker levels
|
|
182
181
|
env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
|
|
183
182
|
privileged: false, // Run docker container in privileged mode
|
|
184
|
-
network: null, // Docker network name
|
|
183
|
+
network: null, // First Docker network name (compatibility accessor)
|
|
184
|
+
networks: [], // Ordered Docker network names
|
|
185
185
|
networkAliases: [], // Docker network-scoped aliases
|
|
186
186
|
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
187
187
|
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
@@ -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
|
+
};
|
|
@@ -8,14 +8,17 @@ function parseDockerNetworkOption(args, index, options) {
|
|
|
8
8
|
throw new Error(`Option ${arg} requires a ${value} argument`);
|
|
9
9
|
}
|
|
10
10
|
if (arg === '--network') {
|
|
11
|
-
options.
|
|
11
|
+
options.networks.push(args[index + 1]);
|
|
12
|
+
options.network ??= args[index + 1];
|
|
12
13
|
} else {
|
|
13
14
|
options.networkAliases.push(args[index + 1]);
|
|
14
15
|
}
|
|
15
16
|
return 2;
|
|
16
17
|
}
|
|
17
18
|
if (arg.startsWith('--network=')) {
|
|
18
|
-
|
|
19
|
+
const network = arg.slice('--network='.length);
|
|
20
|
+
options.networks.push(network);
|
|
21
|
+
options.network ??= network;
|
|
19
22
|
return 1;
|
|
20
23
|
}
|
|
21
24
|
if (arg.startsWith('--network-alias=')) {
|
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 =
|
|
@@ -519,8 +524,9 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
519
524
|
for (const mount of options.mounts || []) {
|
|
520
525
|
args.push('--mount', mount);
|
|
521
526
|
}
|
|
522
|
-
|
|
523
|
-
|
|
527
|
+
const [firstNetwork] = getDockerNetworks(options);
|
|
528
|
+
if (firstNetwork) {
|
|
529
|
+
args.push('--network', firstNetwork);
|
|
524
530
|
}
|
|
525
531
|
for (const alias of options.networkAliases || []) {
|
|
526
532
|
args.push('--network-alias', alias);
|
|
@@ -532,7 +538,7 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
532
538
|
* Build the human-readable `[Isolation]` status lines for docker runtime
|
|
533
539
|
* options (volumes, mounts, env, privileged). Empty collections and a falsy
|
|
534
540
|
* privileged flag contribute no lines.
|
|
535
|
-
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
541
|
+
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
|
|
536
542
|
* @returns {string[]} Status lines for the start block / log header
|
|
537
543
|
*/
|
|
538
544
|
function buildDockerRuntimeStatusLines(options = {}) {
|
|
@@ -549,8 +555,12 @@ function buildDockerRuntimeStatusLines(options = {}) {
|
|
|
549
555
|
if (options.privileged) {
|
|
550
556
|
lines.push(`[Isolation] Privileged: true`);
|
|
551
557
|
}
|
|
552
|
-
|
|
553
|
-
|
|
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(', ')}`);
|
|
554
564
|
}
|
|
555
565
|
if (options.networkAliases && options.networkAliases.length > 0) {
|
|
556
566
|
lines.push(
|
|
@@ -563,17 +573,19 @@ function buildDockerRuntimeStatusLines(options = {}) {
|
|
|
563
573
|
/**
|
|
564
574
|
* Build the execution-record metadata for docker runtime options, normalizing
|
|
565
575
|
* empty collections and a falsy privileged flag to `null`.
|
|
566
|
-
* @param {object} options - Options (volumes, mounts, env, privileged)
|
|
567
|
-
* @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networkAliases: ?string[]}}
|
|
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[]}}
|
|
568
578
|
*/
|
|
569
579
|
function buildDockerRuntimeMetadata(options = {}) {
|
|
580
|
+
const networks = getDockerNetworks(options);
|
|
570
581
|
return {
|
|
571
582
|
volumes:
|
|
572
583
|
options.volumes && options.volumes.length > 0 ? options.volumes : null,
|
|
573
584
|
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
574
585
|
env: options.env && options.env.length > 0 ? options.env : null,
|
|
575
586
|
privileged: options.privileged || null,
|
|
576
|
-
network:
|
|
587
|
+
network: networks[0] || null,
|
|
588
|
+
networks: networks.length > 0 ? networks : null,
|
|
577
589
|
networkAliases:
|
|
578
590
|
options.networkAliases && options.networkAliases.length > 0
|
|
579
591
|
? options.networkAliases
|
|
@@ -644,8 +656,9 @@ function runInDocker(command, options = {}) {
|
|
|
644
656
|
console.log();
|
|
645
657
|
|
|
646
658
|
try {
|
|
659
|
+
const needsNetworkSetup = getDockerNetworks(options).length > 1;
|
|
647
660
|
if (options.detached) {
|
|
648
|
-
const dockerArgs = ['run', '-d'];
|
|
661
|
+
const dockerArgs = needsNetworkSetup ? ['create'] : ['run', '-d'];
|
|
649
662
|
if (options.keepAlive) {
|
|
650
663
|
dockerArgs.push('-i', '-t');
|
|
651
664
|
}
|
|
@@ -704,6 +717,18 @@ function runInDocker(command, options = {}) {
|
|
|
704
717
|
|
|
705
718
|
const containerId = dockerResult.stdout.trim();
|
|
706
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
|
+
|
|
707
732
|
startDetachedDockerCompletionWatcher(
|
|
708
733
|
containerName,
|
|
709
734
|
cleanupPolicy,
|
|
@@ -735,7 +760,7 @@ function runInDocker(command, options = {}) {
|
|
|
735
760
|
message,
|
|
736
761
|
});
|
|
737
762
|
} else {
|
|
738
|
-
const dockerArgs = ['run'];
|
|
763
|
+
const dockerArgs = [needsNetworkSetup ? 'create' : 'run'];
|
|
739
764
|
dockerArgs.push(hasTTY() ? '-it' : '-i');
|
|
740
765
|
dockerArgs.push('--name', containerName);
|
|
741
766
|
if (options.user) {
|
|
@@ -769,9 +794,24 @@ function runInDocker(command, options = {}) {
|
|
|
769
794
|
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
770
795
|
}
|
|
771
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
|
+
|
|
772
809
|
return new Promise((resolve) => {
|
|
773
810
|
const startTime = Date.now();
|
|
774
|
-
const
|
|
811
|
+
const launchArgs = needsNetworkSetup
|
|
812
|
+
? ['start', '-a', '-i', containerName]
|
|
813
|
+
: dockerArgs;
|
|
814
|
+
const child = spawnAttachedDocker(launchArgs, options.logPath);
|
|
775
815
|
|
|
776
816
|
child.on('exit', (code) => {
|
|
777
817
|
const durationMs = Date.now() - startTime;
|
package/src/lib/usage.js
CHANGED
|
@@ -14,8 +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
|
|
18
|
-
--network-alias <alias> Add network
|
|
17
|
+
--network <name> Connect to a named network (repeatable, docker only)
|
|
18
|
+
--network-alias <alias> Add alias to the first network (repeatable, docker only)
|
|
19
19
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
20
20
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
21
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
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
/** Real-daemon integration coverage for Docker network isolation (
|
|
2
|
+
/** Real-daemon integration coverage for Docker network isolation (issues #154, #156, #158, #160). */
|
|
3
3
|
|
|
4
4
|
const { after, before, describe, it } = require('node:test');
|
|
5
5
|
const assert = require('assert');
|
|
@@ -7,9 +7,17 @@ const { spawnSync } = require('child_process');
|
|
|
7
7
|
const { randomUUID } = require('crypto');
|
|
8
8
|
const { runInDocker } = require('../src/lib/isolation');
|
|
9
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
|
+
const MULTI_NETWORK_PROBE =
|
|
14
|
+
"ping -c 1 formal-ai && ping -c 1 formal-db && ip route | grep -q '^default '";
|
|
15
|
+
|
|
10
16
|
const suffix = randomUUID().slice(0, 8);
|
|
11
17
|
const network = `start-network-${suffix}`;
|
|
18
|
+
const secondNetwork = `start-network-b-${suffix}`;
|
|
12
19
|
const sidecar = `start-sidecar-${suffix}`;
|
|
20
|
+
const secondSidecar = `start-sidecar-b-${suffix}`;
|
|
13
21
|
const connected = `start-connected-${suffix}`;
|
|
14
22
|
const control = `start-control-${suffix}`;
|
|
15
23
|
const missing = `start-missing-${suffix}`;
|
|
@@ -20,7 +28,36 @@ function docker(args) {
|
|
|
20
28
|
return spawnSync('docker', args, { encoding: 'utf8' });
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Container output, folded into assertion messages so a CI failure carries the
|
|
33
|
+
* evidence needed to diagnose it instead of only reporting the exit code.
|
|
34
|
+
*/
|
|
35
|
+
function containerDiagnostics(name) {
|
|
36
|
+
const logs = docker(['logs', name]);
|
|
37
|
+
const output = `${logs.stdout || ''}${logs.stderr || ''}`.trim();
|
|
38
|
+
return `\n--- docker logs ${name} ---\n${output || '<no output>'}\n---`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createInternalNetworkWithSidecar(networkName, containerName, alias) {
|
|
42
|
+
const created = docker(['network', 'create', '--internal', networkName]);
|
|
43
|
+
assert.strictEqual(created.status, 0, created.stderr);
|
|
44
|
+
const started = docker([
|
|
45
|
+
'run',
|
|
46
|
+
'-d',
|
|
47
|
+
'--name',
|
|
48
|
+
containerName,
|
|
49
|
+
'--network',
|
|
50
|
+
networkName,
|
|
51
|
+
'--network-alias',
|
|
52
|
+
alias,
|
|
53
|
+
'alpine:3.23',
|
|
54
|
+
'sleep',
|
|
55
|
+
'300',
|
|
56
|
+
]);
|
|
57
|
+
assert.strictEqual(started.status, 0, started.stderr);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('Docker named network integration', () => {
|
|
24
61
|
before(() => {
|
|
25
62
|
dockerAvailable =
|
|
26
63
|
process.platform === 'linux' && docker(['info']).status === 0;
|
|
@@ -28,93 +65,125 @@ describe('Docker named network integration', { timeout: 60000 }, () => {
|
|
|
28
65
|
return;
|
|
29
66
|
}
|
|
30
67
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const started = docker([
|
|
34
|
-
'run',
|
|
35
|
-
'-d',
|
|
36
|
-
'--name',
|
|
37
|
-
sidecar,
|
|
38
|
-
'--network',
|
|
39
|
-
network,
|
|
40
|
-
'--network-alias',
|
|
41
|
-
'formal-ai',
|
|
42
|
-
'alpine:3.23',
|
|
43
|
-
'sleep',
|
|
44
|
-
'60',
|
|
45
|
-
]);
|
|
46
|
-
assert.strictEqual(started.status, 0, started.stderr);
|
|
68
|
+
createInternalNetworkWithSidecar(network, sidecar, 'formal-ai');
|
|
69
|
+
createInternalNetworkWithSidecar(secondNetwork, secondSidecar, 'formal-db');
|
|
47
70
|
});
|
|
48
71
|
|
|
49
72
|
after(() => {
|
|
50
73
|
if (!dockerAvailable) {
|
|
51
74
|
return;
|
|
52
75
|
}
|
|
53
|
-
docker([
|
|
54
|
-
|
|
76
|
+
docker([
|
|
77
|
+
'rm',
|
|
78
|
+
'-f',
|
|
79
|
+
sidecar,
|
|
80
|
+
secondSidecar,
|
|
81
|
+
connected,
|
|
82
|
+
control,
|
|
83
|
+
missing,
|
|
84
|
+
missingAttached,
|
|
85
|
+
]);
|
|
86
|
+
docker(['network', 'rm', network, secondNetwork]);
|
|
55
87
|
});
|
|
56
88
|
|
|
57
|
-
it('
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const joined = await runInDocker('ping -c 1 formal-ai', {
|
|
63
|
-
image: 'alpine:3.23',
|
|
64
|
-
session: connected,
|
|
65
|
-
network,
|
|
66
|
-
networkAliases: ['task'],
|
|
67
|
-
detached: true,
|
|
68
|
-
shell: 'sh',
|
|
69
|
-
keepContainer: true,
|
|
70
|
-
});
|
|
71
|
-
assert.strictEqual(joined.success, true, joined.message);
|
|
72
|
-
const joinedExit = docker(['wait', connected]);
|
|
73
|
-
assert.strictEqual(joinedExit.status, 0, joinedExit.stderr);
|
|
74
|
-
assert.strictEqual(joinedExit.stdout.trim(), '0');
|
|
75
|
-
|
|
76
|
-
const unconnected = await runInDocker('ping -c 1 formal-ai', {
|
|
77
|
-
image: 'alpine:3.23',
|
|
78
|
-
session: control,
|
|
79
|
-
shell: 'sh',
|
|
80
|
-
alwaysCleanupContainer: true,
|
|
81
|
-
});
|
|
82
|
-
assert.strictEqual(unconnected.success, false);
|
|
89
|
+
it('keeps the multi-network probe hermetic', () => {
|
|
90
|
+
assert.doesNotMatch(MULTI_NETWORK_PROBE, /https?:\/\//);
|
|
91
|
+
assert.match(MULTI_NETWORK_PROBE, /formal-ai/);
|
|
92
|
+
assert.match(MULTI_NETWORK_PROBE, /formal-db/);
|
|
93
|
+
assert.match(MULTI_NETWORK_PROBE, /ip route/);
|
|
83
94
|
});
|
|
84
95
|
|
|
85
|
-
it(
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
it(
|
|
97
|
+
'reaches a private sidecar on each of two networks',
|
|
98
|
+
{ timeout: TEST_TIMEOUT },
|
|
99
|
+
async () => {
|
|
100
|
+
if (!dockerAvailable) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Both endpoints are local, user-defined networks on purpose: probing a
|
|
105
|
+
// public endpoint (previously `https://api.github.com`) made this test
|
|
106
|
+
// fail whenever the shared runner IP hit the 60 requests/hour
|
|
107
|
+
// unauthenticated GitHub API rate limit. Checking the route table keeps
|
|
108
|
+
// the original assertion that the default bridge route survived without
|
|
109
|
+
// making the test depend on any internet service (issues #158, #160).
|
|
110
|
+
const joined = await runInDocker(MULTI_NETWORK_PROBE, {
|
|
111
|
+
image: 'alpine:3.23',
|
|
112
|
+
session: connected,
|
|
113
|
+
network: 'bridge',
|
|
114
|
+
networks: ['bridge', network, secondNetwork],
|
|
115
|
+
detached: true,
|
|
116
|
+
shell: 'sh',
|
|
117
|
+
keepContainer: true,
|
|
118
|
+
});
|
|
119
|
+
assert.strictEqual(joined.success, true, joined.message);
|
|
120
|
+
const joinedExit = docker(['wait', connected]);
|
|
121
|
+
assert.strictEqual(joinedExit.status, 0, joinedExit.stderr);
|
|
122
|
+
assert.strictEqual(
|
|
123
|
+
joinedExit.stdout.trim(),
|
|
124
|
+
'0',
|
|
125
|
+
`container did not exit cleanly${containerDiagnostics(connected)}`
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const unconnected = await runInDocker('ping -c 1 formal-ai', {
|
|
129
|
+
image: 'alpine:3.23',
|
|
130
|
+
session: control,
|
|
131
|
+
shell: 'sh',
|
|
132
|
+
alwaysCleanupContainer: true,
|
|
133
|
+
});
|
|
134
|
+
assert.strictEqual(unconnected.success, false, unconnected.message);
|
|
88
135
|
}
|
|
136
|
+
);
|
|
89
137
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
it(
|
|
139
|
+
'fails for a missing network without orphaning a container',
|
|
140
|
+
{ timeout: TEST_TIMEOUT },
|
|
141
|
+
async () => {
|
|
142
|
+
if (!dockerAvailable) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = await runInDocker('echo should-not-run', {
|
|
147
|
+
image: 'alpine:3.23',
|
|
148
|
+
session: missing,
|
|
149
|
+
network: `${network}-absent`,
|
|
150
|
+
detached: true,
|
|
151
|
+
shell: 'sh',
|
|
152
|
+
});
|
|
153
|
+
assert.strictEqual(result.success, false);
|
|
154
|
+
assert.match(result.message, /network .* not found/i);
|
|
155
|
+
assert.notStrictEqual(docker(['inspect', missing]).status, 0);
|
|
156
|
+
|
|
157
|
+
const missingSecond = await runInDocker('echo should-not-run', {
|
|
158
|
+
image: 'alpine:3.23',
|
|
159
|
+
session: missing,
|
|
160
|
+
network,
|
|
161
|
+
networks: [network, `${network}-absent`],
|
|
162
|
+
detached: true,
|
|
163
|
+
shell: 'sh',
|
|
164
|
+
});
|
|
165
|
+
assert.strictEqual(missingSecond.success, false);
|
|
166
|
+
assert.match(missingSecond.message, /network .* not found/i);
|
|
167
|
+
assert.notStrictEqual(docker(['inspect', missing]).status, 0);
|
|
168
|
+
|
|
169
|
+
const attachedResult = await runInDocker('echo should-not-run', {
|
|
170
|
+
image: 'alpine:3.23',
|
|
171
|
+
session: missingAttached,
|
|
172
|
+
network: `${network}-absent`,
|
|
173
|
+
shell: 'sh',
|
|
174
|
+
});
|
|
175
|
+
assert.strictEqual(attachedResult.success, false);
|
|
176
|
+
assert.notStrictEqual(docker(['inspect', missingAttached]).status, 0);
|
|
177
|
+
|
|
178
|
+
const conflict = await runInDocker('echo should-not-run', {
|
|
179
|
+
image: 'alpine:3.23',
|
|
180
|
+
session: sidecar,
|
|
181
|
+
network: `${network}-absent`,
|
|
182
|
+
detached: true,
|
|
183
|
+
shell: 'sh',
|
|
184
|
+
});
|
|
185
|
+
assert.strictEqual(conflict.success, false);
|
|
186
|
+
assert.strictEqual(docker(['inspect', sidecar]).status, 0);
|
|
187
|
+
}
|
|
188
|
+
);
|
|
120
189
|
});
|
|
@@ -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
|
+
});
|
|
@@ -91,12 +91,13 @@ describe('Docker runtime options parsing', () => {
|
|
|
91
91
|
assert.strictEqual(result.wrapperOptions.privileged, true);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
it('should parse --network
|
|
94
|
+
it('should parse repeatable --network forms without consuming child args', () => {
|
|
95
95
|
const spaced = parseArgs([
|
|
96
96
|
'-i',
|
|
97
97
|
'docker',
|
|
98
98
|
'--network',
|
|
99
99
|
'hive-formal-ai',
|
|
100
|
+
'--network=public-egress',
|
|
100
101
|
'--',
|
|
101
102
|
'--network',
|
|
102
103
|
'child-network',
|
|
@@ -111,8 +112,13 @@ describe('Docker runtime options parsing', () => {
|
|
|
111
112
|
]);
|
|
112
113
|
|
|
113
114
|
assert.strictEqual(spaced.wrapperOptions.network, 'hive-formal-ai');
|
|
115
|
+
assert.deepStrictEqual(spaced.wrapperOptions.networks, [
|
|
116
|
+
'hive-formal-ai',
|
|
117
|
+
'public-egress',
|
|
118
|
+
]);
|
|
114
119
|
assert.deepStrictEqual(spaced.rawCommand, ['--network', 'child-network']);
|
|
115
120
|
assert.strictEqual(equals.wrapperOptions.network, 'hive-formal-ai');
|
|
121
|
+
assert.deepStrictEqual(equals.wrapperOptions.networks, ['hive-formal-ai']);
|
|
116
122
|
});
|
|
117
123
|
|
|
118
124
|
it('should parse repeatable --network-alias value and equals forms', () => {
|
|
@@ -142,6 +148,7 @@ describe('Docker runtime options parsing', () => {
|
|
|
142
148
|
assert.deepStrictEqual(result.wrapperOptions.env, []);
|
|
143
149
|
assert.strictEqual(result.wrapperOptions.privileged, false);
|
|
144
150
|
assert.strictEqual(result.wrapperOptions.network, null);
|
|
151
|
+
assert.deepStrictEqual(result.wrapperOptions.networks, []);
|
|
145
152
|
assert.deepStrictEqual(result.wrapperOptions.networkAliases, []);
|
|
146
153
|
});
|
|
147
154
|
|
|
@@ -228,6 +235,7 @@ describe('buildDockerRuntimeArgs', () => {
|
|
|
228
235
|
volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
|
|
229
236
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
230
237
|
network: 'hive-formal-ai',
|
|
238
|
+
networks: ['hive-formal-ai', 'public-egress'],
|
|
231
239
|
networkAliases: ['formal-ai', 'checker'],
|
|
232
240
|
});
|
|
233
241
|
assert.deepStrictEqual(args, [
|
|
@@ -264,6 +272,7 @@ describe('buildDockerRuntimeStatusLines', () => {
|
|
|
264
272
|
env: ['FOO=bar'],
|
|
265
273
|
privileged: true,
|
|
266
274
|
network: 'hive-formal-ai',
|
|
275
|
+
networks: ['hive-formal-ai', 'public-egress'],
|
|
267
276
|
networkAliases: ['formal-ai', 'checker'],
|
|
268
277
|
});
|
|
269
278
|
assert.deepStrictEqual(lines, [
|
|
@@ -272,6 +281,7 @@ describe('buildDockerRuntimeStatusLines', () => {
|
|
|
272
281
|
'[Isolation] Env: FOO=bar',
|
|
273
282
|
'[Isolation] Privileged: true',
|
|
274
283
|
'[Isolation] Network: hive-formal-ai',
|
|
284
|
+
'[Isolation] Networks: hive-formal-ai, public-egress',
|
|
275
285
|
'[Isolation] Network aliases: formal-ai, checker',
|
|
276
286
|
]);
|
|
277
287
|
});
|
|
@@ -292,6 +302,7 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
292
302
|
env: null,
|
|
293
303
|
privileged: null,
|
|
294
304
|
network: null,
|
|
305
|
+
networks: null,
|
|
295
306
|
networkAliases: null,
|
|
296
307
|
});
|
|
297
308
|
});
|
|
@@ -304,6 +315,7 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
304
315
|
env: ['FOO=bar'],
|
|
305
316
|
privileged: true,
|
|
306
317
|
network: 'hive-formal-ai',
|
|
318
|
+
networks: ['hive-formal-ai', 'public-egress'],
|
|
307
319
|
networkAliases: ['formal-ai', 'checker'],
|
|
308
320
|
}),
|
|
309
321
|
{
|
|
@@ -312,6 +324,7 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
312
324
|
env: ['FOO=bar'],
|
|
313
325
|
privileged: true,
|
|
314
326
|
network: 'hive-formal-ai',
|
|
327
|
+
networks: ['hive-formal-ai', 'public-egress'],
|
|
315
328
|
networkAliases: ['formal-ai', 'checker'],
|
|
316
329
|
}
|
|
317
330
|
);
|