From ea8d53fba3c7ca2d15f6b8ac5fec0601e2bf2c73 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 9 Aug 2026 02:54:55 +0000 Subject: [PATCH 1/4] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/start/issues/154 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index ec74b41..de1188c 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,3 +1,4 @@ # .gitkeep file auto-generated at 2026-06-26T14:21:03.315Z for PR creation at branch issue-144-d8d3fb7dfd56 for issue https://github.com/link-foundation/start/issues/144 # Updated: 2026-06-26T16:22:21.672Z -# Updated: 2026-08-04T03:47:36.189Z \ No newline at end of file +# Updated: 2026-08-04T03:47:36.189Z +# Updated: 2026-08-09T02:54:55.167Z \ No newline at end of file From b14f11c5d3daa1a1b88f53d338d9900150ec4284 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 9 Aug 2026 03:17:31 +0000 Subject: [PATCH 2/4] feat: add Docker network isolation Apply network settings during docker run so detached commands cannot race network attachment. Clean up containers left in the created state when launch fails. Refs #154 --- .gitkeep | 4 - README.md | 2 + js/.changeset/issue-154-docker-networks.md | 5 + js/src/bin/cli.js | 5 +- js/src/lib/args-parser.js | 14 ++- js/src/lib/docker-cleanup.js | 17 +++ js/src/lib/docker-network-options.js | 44 +++++++ js/src/lib/isolation.js | 45 ++++++- js/src/lib/usage.js | 2 + js/test/docker-network-integration.js | 121 +++++++++++++++++++ js/test/docker-runtime-options.js | 76 +++++++++++- rust/changelog.d/154.md | 5 + rust/src/bin/main.rs | 11 +- rust/src/lib/args_parser.rs | 44 +++++++ rust/src/lib/args_parser_cases.rs | 86 ++++++++++++++ rust/src/lib/docker_cleanup.rs | 68 ++++++++++- rust/src/lib/isolation.rs | 61 ++++------ rust/src/lib/isolation_cases.rs | 8 ++ rust/src/lib/isolation_metadata.rs | 36 ++++++ rust/src/lib/isolation_metadata_cases.rs | 40 ++++++- rust/src/lib/mod.rs | 1 + rust/src/lib/usage.rs | 2 + rust/tests/docker_network.rs | 132 +++++++++++++++++++++ 23 files changed, 765 insertions(+), 64 deletions(-) delete mode 100644 .gitkeep create mode 100644 js/.changeset/issue-154-docker-networks.md create mode 100644 js/src/lib/docker-network-options.js create mode 100644 js/test/docker-network-integration.js create mode 100644 rust/changelog.d/154.md create mode 100644 rust/tests/docker_network.rs diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index de1188c..0000000 --- a/.gitkeep +++ /dev/null @@ -1,4 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-26T14:21:03.315Z for PR creation at branch issue-144-d8d3fb7dfd56 for issue https://github.com/link-foundation/start/issues/144 -# Updated: 2026-06-26T16:22:21.672Z -# Updated: 2026-08-04T03:47:36.189Z -# Updated: 2026-08-09T02:54:55.167Z \ No newline at end of file diff --git a/README.md b/README.md index 2cd5cff..4860357 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,8 @@ This is useful for: | `--mount` | Docker `--mount` spec (repeatable, docker only) | | `--env, -e` | Environment variable `KEY=VALUE` for the container (repeatable, docker only) | | `--privileged` | Run docker container in privileged mode (docker only) | +| `--network` | Connect docker container to a named network (docker only) | +| `--network-alias` | Add a network-scoped alias (repeatable, docker only) | | `--endpoint` | SSH endpoint (required for ssh, e.g., user@host) | | `--isolated-user, -u [name]` | Create isolated user with same permissions (screen/tmux) | | `--keep-user` | Keep isolated user after command completes (don't delete) | diff --git a/js/.changeset/issue-154-docker-networks.md b/js/.changeset/issue-154-docker-networks.md new file mode 100644 index 0000000..74aaf9a --- /dev/null +++ b/js/.changeset/issue-154-docker-networks.md @@ -0,0 +1,5 @@ +--- +'start-command': minor +--- + +Add `--network` and repeatable `--network-alias` options for Docker-isolated commands. diff --git a/js/src/bin/cli.js b/js/src/bin/cli.js index 8b81896..66f70ae 100644 --- a/js/src/bin/cli.js +++ b/js/src/bin/cli.js @@ -581,10 +581,7 @@ async function runWithIsolation( alwaysCleanupContainer: options.alwaysCleanupContainer, keepContainer: options.keepContainer, keepContainerOnFail: options.keepContainerOnFail, - volumes: options.volumes, - mounts: options.mounts, - env: options.env, - privileged: options.privileged, + ...buildDockerRuntimeMetadata(options), shell: options.shell, logPath: logFilePath, }); diff --git a/js/src/lib/args-parser.js b/js/src/lib/args-parser.js index ffb9942..8870581 100644 --- a/js/src/lib/args-parser.js +++ b/js/src/lib/args-parser.js @@ -15,6 +15,8 @@ * --mount Docker --mount spec (repeatable, docker only) * --env, -e Environment variable for docker container (repeatable, docker only) * --privileged Run docker container in privileged mode (docker only) + * --network Connect docker container to a named network (docker only) + * --network-alias Add a network-scoped alias (repeatable, docker only) * --endpoint SSH endpoint (required for ssh isolation, e.g., user@host) * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified) * --keep-user Keep isolated user after command completes (don't delete) @@ -37,6 +39,7 @@ */ const { getDefaultDockerImage } = require('./docker-utils'); +const dockerNetworkOptions = require('./docker-network-options'); const { parseSequence, isSequence } = require('./sequence-parser'); // Debug mode from environment @@ -178,6 +181,8 @@ function parseArgs(args) { mounts: [], // Docker --mount specs, applied to docker levels env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels privileged: false, // Run docker container in privileged mode + network: null, // Docker network name + networkAliases: [], // Docker network-scoped aliases endpoint: null, // SSH endpoint (current level, e.g., user@host) endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels) user: false, // Create isolated user @@ -396,6 +401,11 @@ function parseOption(args, index, options) { return 1; } + const networkOption = dockerNetworkOptions.parse(args, index, options); + if (networkOption) { + return networkOption; + } + // --endpoint (for ssh) - supports sequence for stacked isolation if (arg === '--endpoint') { if (index + 1 < args.length && !args[index + 1].startsWith('-')) { @@ -645,8 +655,7 @@ function parseOption(args, index, options) { } /** - * Throw if docker runtime options (--volume, --mount, --env, --privileged) - * are present but the isolation configuration does not include docker. + * Throw if docker runtime options are present without docker isolation. * @param {object} options - Parsed options * @throws {Error} If a docker-only option is set without docker isolation */ @@ -671,6 +680,7 @@ function validateDockerRuntimeOptionsRequireDocker(options) { '--privileged option is only valid when isolation stack includes docker' ); } + dockerNetworkOptions.validateRequireDocker(options); } function validateDockerCleanupOptions(options, hasDocker) { diff --git a/js/src/lib/docker-cleanup.js b/js/src/lib/docker-cleanup.js index f2c5658..09e0561 100644 --- a/js/src/lib/docker-cleanup.js +++ b/js/src/lib/docker-cleanup.js @@ -109,6 +109,22 @@ function readDockerContainerOomKilled(containerName) { return null; } +function readDockerContainerStatus(containerName) { + const result = spawnSync( + getDockerCommand(), + ['inspect', '-f', '{{.State.Status}}', containerName], + getDockerSpawnOptions({ + encoding: 'utf8', + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + if (result.error || result.status !== 0) { + return null; + } + return String(result.stdout || '').trim() || null; +} + function removeDockerContainer(containerName, logPath = null) { const result = spawnSync( getDockerCommand(), @@ -237,6 +253,7 @@ module.exports = { getDockerContainerCleanupInstructions, appendDockerContainerCleanupPolicyMessage, readDockerContainerOomKilled, + readDockerContainerStatus, removeDockerContainer, buildDetachedDockerCompletionScript, startDetachedDockerCompletionWatcher, diff --git a/js/src/lib/docker-network-options.js b/js/src/lib/docker-network-options.js new file mode 100644 index 0000000..cc21757 --- /dev/null +++ b/js/src/lib/docker-network-options.js @@ -0,0 +1,44 @@ +/** Parse and validate Docker network wrapper options. */ + +function parseDockerNetworkOption(args, index, options) { + const arg = args[index]; + if (arg === '--network' || arg === '--network-alias') { + if (index + 1 >= args.length || args[index + 1].startsWith('-')) { + const value = arg === '--network' ? 'network name' : 'alias'; + throw new Error(`Option ${arg} requires a ${value} argument`); + } + if (arg === '--network') { + options.network = args[index + 1]; + } else { + options.networkAliases.push(args[index + 1]); + } + return 2; + } + if (arg.startsWith('--network=')) { + options.network = arg.slice('--network='.length); + return 1; + } + if (arg.startsWith('--network-alias=')) { + options.networkAliases.push(arg.slice('--network-alias='.length)); + return 1; + } + return 0; +} + +function validateDockerNetworkOptionsRequireDocker(options) { + if (options.network) { + throw new Error( + '--network option is only valid when isolation stack includes docker' + ); + } + if (options.networkAliases?.length > 0) { + throw new Error( + '--network-alias option is only valid when isolation stack includes docker' + ); + } +} + +module.exports = { + parse: parseDockerNetworkOption, + validateRequireDocker: validateDockerNetworkOptionsRequireDocker, +}; diff --git a/js/src/lib/isolation.js b/js/src/lib/isolation.js index 8a7bb89..0ed09a6 100644 --- a/js/src/lib/isolation.js +++ b/js/src/lib/isolation.js @@ -28,6 +28,7 @@ const { getDockerContainerCleanupInstructions, appendDockerContainerCleanupPolicyMessage, readDockerContainerOomKilled, + readDockerContainerStatus, removeDockerContainer, startDetachedDockerCompletionWatcher, spawnAttachedDocker, @@ -499,9 +500,9 @@ const { /** * Build the docker run runtime argument list contributed by configurable * container options: privileged mode, environment variables, volumes/bind - * mounts, and --mount specs. Returned in a stable order so they can be spliced + * mounts, --mount specs, and network configuration. Returned in a stable order so they can be spliced * into the `docker run` argv before the image name. - * @param {object} options - Options (privileged, env, volumes, mounts) + * @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases) * @returns {string[]} Docker CLI arguments */ function buildDockerRuntimeArgs(options = {}) { @@ -518,6 +519,12 @@ function buildDockerRuntimeArgs(options = {}) { for (const mount of options.mounts || []) { args.push('--mount', mount); } + if (options.network) { + args.push('--network', options.network); + } + for (const alias of options.networkAliases || []) { + args.push('--network-alias', alias); + } return args; } @@ -542,6 +549,14 @@ function buildDockerRuntimeStatusLines(options = {}) { if (options.privileged) { lines.push(`[Isolation] Privileged: true`); } + if (options.network) { + lines.push(`[Isolation] Network: ${options.network}`); + } + if (options.networkAliases && options.networkAliases.length > 0) { + lines.push( + `[Isolation] Network aliases: ${options.networkAliases.join(', ')}` + ); + } return lines; } @@ -549,7 +564,7 @@ function buildDockerRuntimeStatusLines(options = {}) { * Build the execution-record metadata for docker runtime options, normalizing * empty collections and a falsy privileged flag to `null`. * @param {object} options - Options (volumes, mounts, env, privileged) - * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}} + * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networkAliases: ?string[]}} */ function buildDockerRuntimeMetadata(options = {}) { return { @@ -558,6 +573,11 @@ function buildDockerRuntimeMetadata(options = {}) { mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null, env: options.env && options.env.length > 0 ? options.env : null, privileged: options.privileged || null, + network: options.network || null, + networkAliases: + options.networkAliases && options.networkAliases.length > 0 + ? options.networkAliases + : null, }; } @@ -597,6 +617,8 @@ function runInDocker(command, options = {}) { } const containerName = options.session || generateSessionName('docker'); + const containerExistedBeforeLaunch = + readDockerContainerStatus(containerName) !== null; const cleanupPolicy = getDockerContainerCleanupPolicy(options); if (!dockerImageExists(options.image)) { // Pass logPath so the image-preparation phase (docker pull) is recorded in @@ -671,6 +693,12 @@ function runInDocker(command, options = {}) { dockerResult.stderr.trim() || dockerResult.stdout.trim() || `docker exited with code ${dockerResult.status}`; + if ( + !containerExistedBeforeLaunch && + readDockerContainerStatus(containerName) === 'created' + ) { + removeDockerContainer(containerName, options.logPath); + } throw new Error(dockerError); } @@ -760,8 +788,17 @@ function runInDocker(command, options = {}) { } const oomKilled = readDockerContainerOomKilled(containerName); + const launchFailed = + !containerExistedBeforeLaunch && + readDockerContainerStatus(containerName) === 'created'; - if ( + if (launchFailed) { + if (removeDockerContainer(containerName, options.logPath)) { + message += `\nContainer removed after launch failure.`; + } else { + message += `\nWarning: failed to remove container after launch failure.`; + } + } else if ( shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled) ) { if (removeDockerContainer(containerName, options.logPath)) { diff --git a/js/src/lib/usage.js b/js/src/lib/usage.js index 195ee4f..6781680 100644 --- a/js/src/lib/usage.js +++ b/js/src/lib/usage.js @@ -14,6 +14,8 @@ Options: --mount Docker --mount spec (repeatable, docker only) --env, -e Environment variable for docker container (repeatable, docker only) --privileged Run docker container in privileged mode (docker only) + --network Connect docker container to a named network (docker only) + --network-alias Add network-scoped alias (repeatable, docker only) --endpoint SSH endpoint (required for ssh isolation, e.g., user@host) --isolated-user, -u [name] Create isolated user with same permissions --keep-user Keep isolated user after command completes diff --git a/js/test/docker-network-integration.js b/js/test/docker-network-integration.js new file mode 100644 index 0000000..7850bf8 --- /dev/null +++ b/js/test/docker-network-integration.js @@ -0,0 +1,121 @@ +#!/usr/bin/env bun +/** Real-daemon integration coverage for Docker network isolation (issue #154). */ + +const { after, before, describe, it } = require('node:test'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { randomUUID } = require('crypto'); +const { runInDocker } = require('../src/lib/isolation'); + +const suffix = randomUUID().slice(0, 8); +const network = `start-network-${suffix}`; +const sidecar = `start-sidecar-${suffix}`; +const connected = `start-connected-${suffix}`; +const control = `start-control-${suffix}`; +const missing = `start-missing-${suffix}`; +const missingAttached = `start-missing-attached-${suffix}`; +let dockerAvailable = false; + +function docker(args) { + return spawnSync('docker', args, { encoding: 'utf8' }); +} + +describe('Docker named network integration', { timeout: 60000 }, () => { + before(() => { + dockerAvailable = docker(['info']).status === 0; + if (!dockerAvailable) { + return; + } + + const created = docker(['network', 'create', '--internal', network]); + assert.strictEqual(created.status, 0, created.stderr); + const started = docker([ + 'run', + '-d', + '--name', + sidecar, + '--network', + network, + '--network-alias', + 'formal-ai', + 'alpine:3.23', + 'sleep', + '60', + ]); + assert.strictEqual(started.status, 0, started.stderr); + }); + + after(() => { + if (!dockerAvailable) { + return; + } + docker(['rm', '-f', sidecar, connected, control, missing, missingAttached]); + docker(['network', 'rm', network]); + }); + + it('resolves an alias only from containers joined to the internal network', async (t) => { + if (!dockerAvailable) { + t.skip('Docker daemon is unavailable'); + return; + } + + const joined = await runInDocker('ping -c 1 formal-ai', { + image: 'alpine:3.23', + session: connected, + network, + networkAliases: ['task'], + detached: true, + shell: 'sh', + keepContainer: true, + }); + assert.strictEqual(joined.success, true, joined.message); + const joinedExit = docker(['wait', connected]); + assert.strictEqual(joinedExit.status, 0, joinedExit.stderr); + assert.strictEqual(joinedExit.stdout.trim(), '0'); + + const unconnected = await runInDocker('ping -c 1 formal-ai', { + image: 'alpine:3.23', + session: control, + shell: 'sh', + alwaysCleanupContainer: true, + }); + assert.strictEqual(unconnected.success, false); + }); + + it('fails for a missing network without orphaning a container', async (t) => { + if (!dockerAvailable) { + t.skip('Docker daemon is unavailable'); + return; + } + + const result = await runInDocker('echo should-not-run', { + image: 'alpine:3.23', + session: missing, + network: `${network}-absent`, + detached: true, + shell: 'sh', + }); + assert.strictEqual(result.success, false); + assert.match(result.message, /network .* not found/i); + assert.notStrictEqual(docker(['inspect', missing]).status, 0); + + const attachedResult = await runInDocker('echo should-not-run', { + image: 'alpine:3.23', + session: missingAttached, + network: `${network}-absent`, + shell: 'sh', + }); + assert.strictEqual(attachedResult.success, false); + assert.notStrictEqual(docker(['inspect', missingAttached]).status, 0); + + const conflict = await runInDocker('echo should-not-run', { + image: 'alpine:3.23', + session: sidecar, + network: `${network}-absent`, + detached: true, + shell: 'sh', + }); + assert.strictEqual(conflict.success, false); + assert.strictEqual(docker(['inspect', sidecar]).status, 0); + }); +}); diff --git a/js/test/docker-runtime-options.js b/js/test/docker-runtime-options.js index cd952c8..06ac5b7 100644 --- a/js/test/docker-runtime-options.js +++ b/js/test/docker-runtime-options.js @@ -1,6 +1,7 @@ #!/usr/bin/env bun /** - * Tests for Docker runtime options: --volume/-v, --mount, --env/-e, --privileged + * Tests for Docker runtime options: --volume/-v, --mount, --env/-e, + * --privileged, --network, and --network-alias * * Reproduces issue #132: callers need to configure bind mounts, volumes, * environment variables, and privileged mode for the docker isolation backend @@ -90,12 +91,58 @@ describe('Docker runtime options parsing', () => { assert.strictEqual(result.wrapperOptions.privileged, true); }); + it('should parse --network value and equals forms without consuming child args', () => { + const spaced = parseArgs([ + '-i', + 'docker', + '--network', + 'hive-formal-ai', + '--', + '--network', + 'child-network', + ]); + const equals = parseArgs([ + '-i', + 'docker', + '--network=hive-formal-ai', + '--', + 'echo', + 'ok', + ]); + + assert.strictEqual(spaced.wrapperOptions.network, 'hive-formal-ai'); + assert.deepStrictEqual(spaced.rawCommand, ['--network', 'child-network']); + assert.strictEqual(equals.wrapperOptions.network, 'hive-formal-ai'); + }); + + it('should parse repeatable --network-alias value and equals forms', () => { + const result = parseArgs([ + '-i', + 'docker', + '--network', + 'hive-formal-ai', + '--network-alias', + 'formal-ai', + '--network-alias=checker', + '--', + 'echo', + 'ok', + ]); + + assert.deepStrictEqual(result.wrapperOptions.networkAliases, [ + 'formal-ai', + 'checker', + ]); + }); + it('should default runtime options to empty/false', () => { const result = parseArgs(['-i', 'docker', '--', 'ls']); assert.deepStrictEqual(result.wrapperOptions.volumes, []); assert.deepStrictEqual(result.wrapperOptions.mounts, []); assert.deepStrictEqual(result.wrapperOptions.env, []); assert.strictEqual(result.wrapperOptions.privileged, false); + assert.strictEqual(result.wrapperOptions.network, null); + assert.deepStrictEqual(result.wrapperOptions.networkAliases, []); }); it('should throw when --volume requires an argument', () => { @@ -136,6 +183,15 @@ describe('Docker runtime options validation', () => { }, /--privileged option is only valid when isolation stack includes docker/); }); + it('should reject network options without docker', () => { + assert.throws(() => { + parseArgs(['-i', 'tmux', '--network', 'private', '--', 'ls']); + }, /--network option is only valid when isolation stack includes docker/); + assert.throws(() => { + parseArgs(['--network-alias', 'service', '--', 'ls']); + }, /--network-alias option is only valid when isolation stack includes docker/); + }); + it('should accept runtime options when stack includes docker', () => { const result = parseArgs([ '-i', @@ -171,6 +227,8 @@ describe('buildDockerRuntimeArgs', () => { env: ['FOO=bar', 'GH_TOKEN=secret'], volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'], mounts: ['type=bind,src=/h,dst=/c'], + network: 'hive-formal-ai', + networkAliases: ['formal-ai', 'checker'], }); assert.deepStrictEqual(args, [ '--privileged', @@ -184,6 +242,12 @@ describe('buildDockerRuntimeArgs', () => { '/h/b:/c/b:ro', '--mount', 'type=bind,src=/h,dst=/c', + '--network', + 'hive-formal-ai', + '--network-alias', + 'formal-ai', + '--network-alias', + 'checker', ]); }); }); @@ -199,12 +263,16 @@ describe('buildDockerRuntimeStatusLines', () => { mounts: ['type=bind,src=/h,dst=/c'], env: ['FOO=bar'], privileged: true, + network: 'hive-formal-ai', + networkAliases: ['formal-ai', 'checker'], }); assert.deepStrictEqual(lines, [ '[Isolation] Volumes: /h:/c:ro', '[Isolation] Mounts: type=bind,src=/h,dst=/c', '[Isolation] Env: FOO=bar', '[Isolation] Privileged: true', + '[Isolation] Network: hive-formal-ai', + '[Isolation] Network aliases: formal-ai, checker', ]); }); @@ -223,6 +291,8 @@ describe('buildDockerRuntimeMetadata', () => { mounts: null, env: null, privileged: null, + network: null, + networkAliases: null, }); }); @@ -233,12 +303,16 @@ describe('buildDockerRuntimeMetadata', () => { mounts: ['type=bind,src=/h,dst=/c'], env: ['FOO=bar'], privileged: true, + network: 'hive-formal-ai', + networkAliases: ['formal-ai', 'checker'], }), { volumes: ['/h:/c'], mounts: ['type=bind,src=/h,dst=/c'], env: ['FOO=bar'], privileged: true, + network: 'hive-formal-ai', + networkAliases: ['formal-ai', 'checker'], } ); }); diff --git a/rust/changelog.d/154.md b/rust/changelog.d/154.md new file mode 100644 index 0000000..10c23d3 --- /dev/null +++ b/rust/changelog.d/154.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Add `--network` and repeatable `--network-alias` options for Docker-isolated commands. diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index 353c4b9..d0baf41 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -18,7 +18,7 @@ use start_command::{ }, build_isolation_options_map, clear_current_execution, create_finish_block, create_log_footer, create_log_header, create_log_path_for_execution, create_start_block, - docker_runtime_status_lines, + docker_runtime_status_lines_for_options, execution_control::{control_execution, ControlAction}, execution_store::{ CleanupOptions, ExecutionRecord, ExecutionRecordOptions, ExecutionStore, @@ -517,12 +517,7 @@ fn run_with_isolation( if let Some(ref image) = effective_image { extra_lines.push(format!("[Isolation] Image: {}", image)); } - extra_lines.extend(docker_runtime_status_lines( - &wrapper_options.volumes, - &wrapper_options.mounts, - &wrapper_options.env, - wrapper_options.privileged, - )); + extra_lines.extend(docker_runtime_status_lines_for_options(wrapper_options)); if let Some(ref endpoint) = wrapper_options.endpoint { extra_lines.push(format!("[Isolation] Endpoint: {}", endpoint)); } @@ -620,6 +615,8 @@ fn run_with_isolation( mounts: wrapper_options.mounts.clone(), env: wrapper_options.env.clone(), privileged: wrapper_options.privileged, + network: wrapper_options.network.clone(), + network_aliases: wrapper_options.network_aliases.clone(), endpoint: wrapper_options.endpoint.clone(), detached: mode == "detached", user: created_user.clone(), diff --git a/rust/src/lib/args_parser.rs b/rust/src/lib/args_parser.rs index 4f6d541..fe65a7d 100644 --- a/rust/src/lib/args_parser.rs +++ b/rust/src/lib/args_parser.rs @@ -14,6 +14,8 @@ //! --mount Docker --mount spec (repeatable, docker only) //! --env, -e Environment variable for docker container (repeatable, docker only) //! --privileged Run docker container in privileged mode (docker only) +//! --network Connect docker container to a named network (docker only) +//! --network-alias Add a network-scoped alias (repeatable, docker only) //! --endpoint SSH endpoint (required for ssh isolation, e.g., user@host) //! --isolated-user, -u [username] Create isolated user with same permissions //! --keep-user Keep isolated user after command completes @@ -80,6 +82,10 @@ pub struct WrapperOptions { pub env: Vec, /// Run docker container in privileged mode pub privileged: bool, + /// Docker network name + pub network: Option, + /// Docker network-scoped aliases + pub network_aliases: Vec, /// SSH endpoint (e.g., user@host) pub endpoint: Option, /// Create isolated user @@ -133,6 +139,8 @@ impl Default for WrapperOptions { mounts: Vec::new(), env: Vec::new(), privileged: false, + network: None, + network_aliases: Vec::new(), endpoint: None, user: false, user_name: None, @@ -367,6 +375,36 @@ fn parse_option( return Ok(1); } + // --network (for docker) + if arg == "--network" { + if index + 1 < args.len() && !args[index + 1].starts_with('-') { + options.network = Some(args[index + 1].clone()); + return Ok(2); + } + return Err(format!("Option {} requires a network name argument", arg)); + } + + // --network= + if let Some(value) = arg.strip_prefix("--network=") { + options.network = Some(value.to_string()); + return Ok(1); + } + + // --network-alias (for docker) - repeatable network-scoped alias + if arg == "--network-alias" { + if index + 1 < args.len() && !args[index + 1].starts_with('-') { + options.network_aliases.push(args[index + 1].clone()); + return Ok(2); + } + return Err(format!("Option {} requires an alias argument", arg)); + } + + // --network-alias= + if let Some(value) = arg.strip_prefix("--network-alias=") { + options.network_aliases.push(value.to_string()); + return Ok(1); + } + // --endpoint (for ssh) if arg == "--endpoint" { if index + 1 < args.len() && !args[index + 1].starts_with('-') { @@ -668,6 +706,12 @@ pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> { if options.privileged && !is_docker { return Err("--privileged option is only valid with --isolated docker".to_string()); } + if options.network.is_some() && !is_docker { + return Err("--network option is only valid with --isolated docker".to_string()); + } + if !options.network_aliases.is_empty() && !is_docker { + return Err("--network-alias option is only valid with --isolated docker".to_string()); + } // Endpoint is only valid with ssh if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") { diff --git a/rust/src/lib/args_parser_cases.rs b/rust/src/lib/args_parser_cases.rs index 824948d..1100ee2 100644 --- a/rust/src/lib/args_parser_cases.rs +++ b/rust/src/lib/args_parser_cases.rs @@ -210,6 +210,72 @@ fn test_docker_privileged() { assert!(result.wrapper_options.privileged); } +#[test] +fn test_docker_network_forms_preserve_child_args() { + let spaced: Vec = vec![ + "-i", + "docker", + "--network", + "hive-formal-ai", + "--", + "--network", + "child-network", + ] + .into_iter() + .map(String::from) + .collect(); + let equals: Vec = vec![ + "-i", + "docker", + "--network=hive-formal-ai", + "--", + "echo", + "ok", + ] + .into_iter() + .map(String::from) + .collect(); + + let spaced_result = parse_args(&spaced).unwrap(); + let equals_result = parse_args(&equals).unwrap(); + assert_eq!( + spaced_result.wrapper_options.network.as_deref(), + Some("hive-formal-ai") + ); + assert_eq!( + spaced_result.raw_command, + vec!["--network", "child-network"] + ); + assert_eq!( + equals_result.wrapper_options.network.as_deref(), + Some("hive-formal-ai") + ); +} + +#[test] +fn test_docker_network_alias_repeatable() { + let args: Vec = vec![ + "-i", + "docker", + "--network", + "hive-formal-ai", + "--network-alias", + "formal-ai", + "--network-alias=checker", + "--", + "echo", + "ok", + ] + .into_iter() + .map(String::from) + .collect(); + let result = parse_args(&args).unwrap(); + assert_eq!( + result.wrapper_options.network_aliases, + vec!["formal-ai", "checker"] + ); +} + #[test] fn test_docker_runtime_options_default_empty() { let args: Vec = vec!["-i", "docker", "--", "ls"] @@ -221,6 +287,26 @@ fn test_docker_runtime_options_default_empty() { assert!(result.wrapper_options.mounts.is_empty()); assert!(result.wrapper_options.env.is_empty()); assert!(!result.wrapper_options.privileged); + assert!(result.wrapper_options.network.is_none()); + assert!(result.wrapper_options.network_aliases.is_empty()); +} + +#[test] +fn test_network_options_rejected_without_docker() { + let network: Vec = vec!["-i", "tmux", "--network", "private", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + let alias: Vec = vec!["--network-alias", "service", "--", "ls"] + .into_iter() + .map(String::from) + .collect(); + assert!(parse_args(&network) + .unwrap_err() + .contains("--network option is only valid")); + assert!(parse_args(&alias) + .unwrap_err() + .contains("--network-alias option is only valid")); } #[test] diff --git a/rust/src/lib/docker_cleanup.rs b/rust/src/lib/docker_cleanup.rs index 352db35..edeb46e 100644 --- a/rust/src/lib/docker_cleanup.rs +++ b/rust/src/lib/docker_cleanup.rs @@ -11,7 +11,8 @@ use crate::isolation::isolation_log::{ use crate::isolation::IsolationOptions; /// Build the extra `docker run` arguments contributed by runtime options -/// (--privileged, --env/-e, --volume/-v, --mount). Returned references borrow +/// (--privileged, --env/-e, --volume/-v, --mount, --network, +/// --network-alias). Returned references borrow /// from `options`, which outlives the `docker run` invocation. pub(crate) fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<&str> { let mut args: Vec<&str> = Vec::new(); @@ -30,6 +31,14 @@ pub(crate) fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<&str> args.push("--mount"); args.push(mount); } + if let Some(network) = &options.network { + args.push("--network"); + args.push(network); + } + for alias in &options.network_aliases { + args.push("--network-alias"); + args.push(alias); + } args } @@ -134,6 +143,63 @@ pub(crate) fn read_docker_container_oom_killed(container_name: &str) -> Option Option { + let output = Command::new(docker_command()) + .args(["inspect", "-f", "{{.State.Status}}", container_name]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let status = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if status.is_empty() { + None + } else { + Some(status) + } +} + +pub(crate) fn append_attached_docker_cleanup_message( + message: &mut String, + container_name: &str, + policy: DockerContainerCleanupPolicy, + exit_code: i32, + log_path: Option<&PathBuf>, + container_existed_before_launch: bool, +) { + let oom_killed = read_docker_container_oom_killed(container_name).unwrap_or(false); + if !container_existed_before_launch + && read_docker_container_status(container_name).as_deref() == Some("created") + { + if remove_docker_container(container_name, log_path) { + message.push_str("\nContainer removed after launch failure."); + } else { + message.push_str("\nWarning: failed to remove container after launch failure."); + } + } else if should_cleanup_docker_container(policy, exit_code, oom_killed) { + if remove_docker_container(container_name, log_path) { + message.push_str("\nContainer removed after completion."); + } else { + message.push_str("\nWarning: failed to remove container automatically."); + message.push_str(&format!( + "\nRemove when done: docker rm -f {container_name}" + )); + } + } else if policy == DockerContainerCleanupPolicy::Keep { + message.push('\n'); + message.push_str(&docker_container_cleanup_instructions(container_name)); + } else { + if oom_killed { + message.push_str("\nContainer kept because Docker reports it was OOM-killed."); + } else { + message.push_str("\nContainer kept because the command failed."); + } + message.push_str(&format!( + "\nRemove when done: docker rm -f {container_name}" + )); + } +} + pub(crate) fn remove_docker_container(container_name: &str, log_path: Option<&PathBuf>) -> bool { let output = Command::new(docker_command()) .args(["rm", "-f", container_name]) diff --git a/rust/src/lib/isolation.rs b/rust/src/lib/isolation.rs index de10955..25ef9f3 100644 --- a/rust/src/lib/isolation.rs +++ b/rust/src/lib/isolation.rs @@ -12,10 +12,9 @@ use std::process::{Command, Stdio}; use crate::args_parser::generate_session_name; use crate::docker_cleanup::{ - append_docker_container_cleanup_policy_message, build_docker_runtime_args, - docker_container_cleanup_instructions, get_docker_container_cleanup_policy, - read_docker_container_oom_killed, remove_docker_container, should_cleanup_docker_container, - spawn_attached_docker, start_detached_docker_completion_watcher, DockerContainerCleanupPolicy, + append_attached_docker_cleanup_message, append_docker_container_cleanup_policy_message, + build_docker_runtime_args, get_docker_container_cleanup_policy, remove_docker_container, + spawn_attached_docker, start_detached_docker_completion_watcher, }; /// Result of an isolation run @@ -50,6 +49,10 @@ pub struct IsolationOptions { pub env: Vec, /// Run docker container in privileged mode pub privileged: bool, + /// Docker network name + pub network: Option, + /// Docker network-scoped aliases + pub network_aliases: Vec, /// SSH endpoint pub endpoint: Option, /// Run in detached mode @@ -81,6 +84,8 @@ impl Default for IsolationOptions { mounts: Vec::new(), env: Vec::new(), privileged: false, + network: None, + network_aliases: Vec::new(), endpoint: None, detached: false, user: None, @@ -736,6 +741,8 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu .session .clone() .unwrap_or_else(|| generate_session_name(Some("docker"))); + let container_existed_before_launch = + crate::docker_cleanup::read_docker_container_status(&container_name).is_some(); let cleanup_policy = get_docker_container_cleanup_policy(options); // Detect the shell to use in the container @@ -825,6 +832,13 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); + if !container_existed_before_launch + && crate::docker_cleanup::read_docker_container_status(&container_name) + .as_deref() + == Some("created") + { + remove_docker_container(&container_name, options.log_path.as_ref()); + } IsolationResult { success: false, session_name: Some(container_name), @@ -873,39 +887,14 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu "Docker container \"{}\" exited with code {}", container_name, exit_code ); - let oom_killed = - read_docker_container_oom_killed(&container_name).unwrap_or(false); - if should_cleanup_docker_container(cleanup_policy, exit_code, oom_killed) { - if remove_docker_container(&container_name, options.log_path.as_ref()) { - message.push_str("\nContainer removed after completion."); - } else { - message - .push_str("\nWarning: failed to remove container automatically."); - message.push_str(&format!( - "\nRemove when done: docker rm -f {}", - container_name - )); - } - } else if cleanup_policy == DockerContainerCleanupPolicy::Keep { - message.push('\n'); - message.push_str(&docker_container_cleanup_instructions(&container_name)); - } else if matches!( + append_attached_docker_cleanup_message( + &mut message, + &container_name, cleanup_policy, - DockerContainerCleanupPolicy::KeepOnFail - | DockerContainerCleanupPolicy::Default - ) { - if oom_killed { - message.push_str( - "\nContainer kept because Docker reports it was OOM-killed.", - ); - } else { - message.push_str("\nContainer kept because the command failed."); - } - message.push_str(&format!( - "\nRemove when done: docker rm -f {}", - container_name - )); - } + exit_code, + options.log_path.as_ref(), + container_existed_before_launch, + ); IsolationResult { success: s.success(), diff --git a/rust/src/lib/isolation_cases.rs b/rust/src/lib/isolation_cases.rs index 7795866..7d6c2e9 100644 --- a/rust/src/lib/isolation_cases.rs +++ b/rust/src/lib/isolation_cases.rs @@ -226,6 +226,8 @@ fn test_build_docker_runtime_args_order() { env: vec!["FOO=bar".to_string(), "GH_TOKEN=secret".to_string()], volumes: vec!["/h/a:/c/a".to_string(), "/h/b:/c/b:ro".to_string()], mounts: vec!["type=bind,src=/h,dst=/c".to_string()], + network: Some("hive-formal-ai".to_string()), + network_aliases: vec!["formal-ai".to_string(), "checker".to_string()], ..Default::default() }; assert_eq!( @@ -242,6 +244,12 @@ fn test_build_docker_runtime_args_order() { "/h/b:/c/b:ro", "--mount", "type=bind,src=/h,dst=/c", + "--network", + "hive-formal-ai", + "--network-alias", + "formal-ai", + "--network-alias", + "checker", ] ); } diff --git a/rust/src/lib/isolation_metadata.rs b/rust/src/lib/isolation_metadata.rs index ee9353d..8ead7c5 100644 --- a/rust/src/lib/isolation_metadata.rs +++ b/rust/src/lib/isolation_metadata.rs @@ -18,6 +18,8 @@ pub fn docker_runtime_status_lines( mounts: &[String], env: &[String], privileged: bool, + network: Option<&str>, + network_aliases: &[String], ) -> Vec { let mut lines = Vec::new(); if !volumes.is_empty() { @@ -32,9 +34,30 @@ pub fn docker_runtime_status_lines( if privileged { lines.push("[Isolation] Privileged: true".to_string()); } + if let Some(network) = network { + lines.push(format!("[Isolation] Network: {}", network)); + } + if !network_aliases.is_empty() { + lines.push(format!( + "[Isolation] Network aliases: {}", + network_aliases.join(", ") + )); + } lines } +/// Build Docker runtime status lines directly from parsed wrapper options. +pub fn docker_runtime_status_lines_for_options(options: &WrapperOptions) -> Vec { + docker_runtime_status_lines( + &options.volumes, + &options.mounts, + &options.env, + options.privileged, + options.network.as_deref(), + &options.network_aliases, + ) +} + /// Build the execution-record metadata entries for docker runtime options. /// Returns `(key, value)` pairs to merge into the options map; empty /// collections and a false `privileged` flag contribute no entries. @@ -43,6 +66,8 @@ pub fn docker_runtime_metadata( mounts: &[String], env: &[String], privileged: bool, + network: Option<&str>, + network_aliases: &[String], ) -> Vec<(String, serde_json::Value)> { let arr = |items: &[String]| { serde_json::Value::Array( @@ -65,6 +90,15 @@ pub fn docker_runtime_metadata( if privileged { entries.push(("privileged".to_string(), serde_json::Value::Bool(true))); } + if let Some(network) = network { + entries.push(( + "network".to_string(), + serde_json::Value::String(network.to_string()), + )); + } + if !network_aliases.is_empty() { + entries.push(("networkAliases".to_string(), arr(network_aliases))); + } entries } @@ -95,6 +129,8 @@ pub fn build_isolation_options_map( &options.mounts, &options.env, options.privileged, + options.network.as_deref(), + &options.network_aliases, ) { opts_map.insert(k, v); } diff --git a/rust/src/lib/isolation_metadata_cases.rs b/rust/src/lib/isolation_metadata_cases.rs index 3049730..8e74b3f 100644 --- a/rust/src/lib/isolation_metadata_cases.rs +++ b/rust/src/lib/isolation_metadata_cases.rs @@ -2,7 +2,7 @@ use super::*; #[test] fn test_docker_runtime_status_lines_empty() { - assert!(docker_runtime_status_lines(&[], &[], &[], false).is_empty()); + assert!(docker_runtime_status_lines(&[], &[], &[], false, None, &[]).is_empty()); } #[test] @@ -12,6 +12,8 @@ fn test_docker_runtime_status_lines_populated() { &["type=bind,src=/h,dst=/c".to_string()], &["FOO=bar".to_string()], true, + Some("hive-formal-ai"), + &["formal-ai".to_string(), "checker".to_string()], ); assert_eq!( lines, @@ -20,20 +22,28 @@ fn test_docker_runtime_status_lines_populated() { "[Isolation] Mounts: type=bind,src=/h,dst=/c".to_string(), "[Isolation] Env: FOO=bar".to_string(), "[Isolation] Privileged: true".to_string(), + "[Isolation] Network: hive-formal-ai".to_string(), + "[Isolation] Network aliases: formal-ai, checker".to_string(), ] ); } #[test] fn test_docker_runtime_status_lines_joins_multiple() { - let lines = - docker_runtime_status_lines(&["/a:/a".to_string(), "/b:/b".to_string()], &[], &[], false); + let lines = docker_runtime_status_lines( + &["/a:/a".to_string(), "/b:/b".to_string()], + &[], + &[], + false, + None, + &[], + ); assert_eq!(lines, vec!["[Isolation] Volumes: /a:/a, /b:/b".to_string()]); } #[test] fn test_docker_runtime_metadata_empty() { - assert!(docker_runtime_metadata(&[], &[], &[], false).is_empty()); + assert!(docker_runtime_metadata(&[], &[], &[], false, None, &[]).is_empty()); } #[test] @@ -43,6 +53,8 @@ fn test_docker_runtime_metadata_populated() { &["type=bind,src=/h,dst=/c".to_string()], &["FOO=bar".to_string()], true, + Some("hive-formal-ai"), + &["formal-ai".to_string(), "checker".to_string()], ); let map: std::collections::HashMap<_, _> = entries.into_iter().collect(); assert_eq!( @@ -58,11 +70,19 @@ fn test_docker_runtime_metadata_populated() { Some(&serde_json::json!(["FOO=bar".to_string()])) ); assert_eq!(map.get("privileged"), Some(&serde_json::Value::Bool(true))); + assert_eq!( + map.get("network"), + Some(&serde_json::json!("hive-formal-ai")) + ); + assert_eq!( + map.get("networkAliases"), + Some(&serde_json::json!(["formal-ai", "checker"])) + ); } #[test] fn test_docker_runtime_metadata_omits_privileged_when_false() { - let entries = docker_runtime_metadata(&["/h:/c".to_string()], &[], &[], false); + let entries = docker_runtime_metadata(&["/h:/c".to_string()], &[], &[], false, None, &[]); let map: std::collections::HashMap<_, _> = entries.into_iter().collect(); assert!(map.contains_key("volumes")); assert!(!map.contains_key("privileged")); @@ -101,6 +121,8 @@ fn test_build_isolation_options_map_includes_runtime_options() { volumes: vec!["/h:/c:ro".to_string()], env: vec!["TOKEN=abc".to_string()], privileged: true, + network: Some("hive-formal-ai".to_string()), + network_aliases: vec!["formal-ai".to_string(), "checker".to_string()], ..Default::default() }; let map = build_isolation_options_map( @@ -120,5 +142,13 @@ fn test_build_isolation_options_map_includes_runtime_options() { Some(&serde_json::json!(["TOKEN=abc".to_string()])) ); assert_eq!(map.get("privileged"), Some(&serde_json::Value::Bool(true))); + assert_eq!( + map.get("network"), + Some(&serde_json::json!("hive-formal-ai")) + ); + assert_eq!( + map.get("networkAliases"), + Some(&serde_json::json!(["formal-ai", "checker"])) + ); assert_eq!(map.get("user"), Some(&serde_json::json!("isolated-user"))); } diff --git a/rust/src/lib/mod.rs b/rust/src/lib/mod.rs index e4b02e5..8753c2d 100644 --- a/rust/src/lib/mod.rs +++ b/rust/src/lib/mod.rs @@ -48,6 +48,7 @@ pub use isolation::{ }; pub use isolation_metadata::{ build_isolation_options_map, docker_runtime_metadata, docker_runtime_status_lines, + docker_runtime_status_lines_for_options, }; pub use log_uploader::upload_execution_log; #[allow(deprecated)] diff --git a/rust/src/lib/usage.rs b/rust/src/lib/usage.rs index 15c15de..ca70d0a 100644 --- a/rust/src/lib/usage.rs +++ b/rust/src/lib/usage.rs @@ -21,6 +21,8 @@ Options: --mount Docker --mount spec (repeatable, docker only) --env, -e Environment variable for docker container (repeatable, docker only) --privileged Run docker container in privileged mode (docker only) + --network Connect docker container to a named network (docker only) + --network-alias Add network-scoped alias (repeatable, docker only) --endpoint SSH endpoint (required for ssh isolation, e.g., user@host) --isolated-user, -u [name] Create isolated user with same permissions --keep-user Keep isolated user after command completes diff --git a/rust/tests/docker_network.rs b/rust/tests/docker_network.rs new file mode 100644 index 0000000..6f7776d --- /dev/null +++ b/rust/tests/docker_network.rs @@ -0,0 +1,132 @@ +//! Real-daemon integration coverage for Docker network isolation (issue #154). + +use start_command::isolation::run_in_docker; +use start_command::IsolationOptions; +use std::process::Command; +use uuid::Uuid; + +fn docker(args: &[&str]) -> std::process::Output { + Command::new("docker").args(args).output().unwrap() +} + +struct DockerResources { + network: String, + containers: Vec, +} + +impl Drop for DockerResources { + fn drop(&mut self) { + let mut rm_args = vec!["rm", "-f"]; + rm_args.extend(self.containers.iter().map(String::as_str)); + let _ = docker(&rm_args); + let _ = docker(&["network", "rm", &self.network]); + } +} + +#[test] +fn named_network_alias_is_private_and_missing_network_leaves_no_orphan() { + if !docker(&["info"]).status.success() { + eprintln!("Skipping: Docker daemon is unavailable"); + return; + } + + let suffix = &Uuid::new_v4().to_string()[..8]; + let network = format!("start-rust-network-{suffix}"); + let sidecar = format!("start-rust-sidecar-{suffix}"); + let connected = format!("start-rust-connected-{suffix}"); + let control = format!("start-rust-control-{suffix}"); + let missing = format!("start-rust-missing-{suffix}"); + let _resources = DockerResources { + network: network.clone(), + containers: vec![ + sidecar.clone(), + connected.clone(), + control.clone(), + missing.clone(), + ], + }; + + let created = docker(&["network", "create", "--internal", &network]); + assert!( + created.status.success(), + "{}", + String::from_utf8_lossy(&created.stderr) + ); + let started = docker(&[ + "run", + "-d", + "--name", + &sidecar, + "--network", + &network, + "--network-alias", + "formal-ai", + "alpine:3.23", + "sleep", + "60", + ]); + assert!( + started.status.success(), + "{}", + String::from_utf8_lossy(&started.stderr) + ); + + let joined = run_in_docker( + "ping -c 1 formal-ai", + &IsolationOptions { + image: Some("alpine:3.23".to_string()), + session: Some(connected), + network: Some(network.clone()), + network_aliases: vec!["task".to_string()], + detached: true, + shell: "sh".to_string(), + keep_container: true, + ..Default::default() + }, + ); + assert!(joined.success, "{}", joined.message); + let joined_exit = docker(&["wait", joined.session_name.as_deref().unwrap()]); + assert!(joined_exit.status.success()); + assert_eq!(String::from_utf8_lossy(&joined_exit.stdout).trim(), "0"); + + let unconnected = run_in_docker( + "ping -c 1 formal-ai", + &IsolationOptions { + image: Some("alpine:3.23".to_string()), + session: Some(control), + shell: "sh".to_string(), + always_cleanup_container: true, + ..Default::default() + }, + ); + assert!(!unconnected.success); + + let absent_network = format!("{network}-absent"); + let failed = run_in_docker( + "echo should-not-run", + &IsolationOptions { + image: Some("alpine:3.23".to_string()), + session: Some(missing.clone()), + network: Some(absent_network), + detached: true, + shell: "sh".to_string(), + ..Default::default() + }, + ); + assert!(!failed.success); + assert!(!docker(&["inspect", &missing]).status.success()); + + let conflict = run_in_docker( + "echo should-not-run", + &IsolationOptions { + image: Some("alpine:3.23".to_string()), + session: Some(sidecar.clone()), + network: Some(format!("{network}-absent")), + detached: true, + shell: "sh".to_string(), + ..Default::default() + }, + ); + assert!(!conflict.success); + assert!(docker(&["inspect", &sidecar]).status.success()); +} From c89c8f9cabaf9e10922875b79afd3cfa5f219367 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 9 Aug 2026 03:20:45 +0000 Subject: [PATCH 3/4] test: handle unavailable Docker in CI --- js/test/docker-network-integration.js | 6 ++---- rust/tests/docker_network.rs | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/js/test/docker-network-integration.js b/js/test/docker-network-integration.js index 7850bf8..339f712 100644 --- a/js/test/docker-network-integration.js +++ b/js/test/docker-network-integration.js @@ -53,9 +53,8 @@ describe('Docker named network integration', { timeout: 60000 }, () => { docker(['network', 'rm', network]); }); - it('resolves an alias only from containers joined to the internal network', async (t) => { + it('resolves an alias only from containers joined to the internal network', async () => { if (!dockerAvailable) { - t.skip('Docker daemon is unavailable'); return; } @@ -82,9 +81,8 @@ describe('Docker named network integration', { timeout: 60000 }, () => { assert.strictEqual(unconnected.success, false); }); - it('fails for a missing network without orphaning a container', async (t) => { + it('fails for a missing network without orphaning a container', async () => { if (!dockerAvailable) { - t.skip('Docker daemon is unavailable'); return; } diff --git a/rust/tests/docker_network.rs b/rust/tests/docker_network.rs index 6f7776d..96abb5b 100644 --- a/rust/tests/docker_network.rs +++ b/rust/tests/docker_network.rs @@ -25,7 +25,8 @@ impl Drop for DockerResources { #[test] fn named_network_alias_is_private_and_missing_network_leaves_no_orphan() { - if !docker(&["info"]).status.success() { + let docker_info = Command::new("docker").arg("info").output(); + if !matches!(docker_info, Ok(output) if output.status.success()) { eprintln!("Skipping: Docker daemon is unavailable"); return; } From 0030a13194c52bbe2791a34728f17716bf5dac6d Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 9 Aug 2026 03:22:06 +0000 Subject: [PATCH 4/4] test: limit Docker network test to Linux --- js/test/docker-network-integration.js | 3 ++- rust/tests/docker_network.rs | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/js/test/docker-network-integration.js b/js/test/docker-network-integration.js index 339f712..93c154e 100644 --- a/js/test/docker-network-integration.js +++ b/js/test/docker-network-integration.js @@ -22,7 +22,8 @@ function docker(args) { describe('Docker named network integration', { timeout: 60000 }, () => { before(() => { - dockerAvailable = docker(['info']).status === 0; + dockerAvailable = + process.platform === 'linux' && docker(['info']).status === 0; if (!dockerAvailable) { return; } diff --git a/rust/tests/docker_network.rs b/rust/tests/docker_network.rs index 96abb5b..817b954 100644 --- a/rust/tests/docker_network.rs +++ b/rust/tests/docker_network.rs @@ -25,6 +25,10 @@ impl Drop for DockerResources { #[test] fn named_network_alias_is_private_and_missing_network_leaves_no_orphan() { + if !cfg!(target_os = "linux") { + eprintln!("Skipping: this integration test requires Linux containers"); + return; + } let docker_info = Command::new("docker").arg("info").output(); if !matches!(docker_info, Ok(output) if output.status.success()) { eprintln!("Skipping: Docker daemon is unavailable");