Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .gitkeep

This file was deleted.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
5 changes: 5 additions & 0 deletions js/.changeset/issue-154-docker-networks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'start-command': minor
---

Add `--network` and repeatable `--network-alias` options for Docker-isolated commands.
5 changes: 1 addition & 4 deletions js/src/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
14 changes: 12 additions & 2 deletions js/src/lib/args-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* --mount <mount-spec> Docker --mount spec (repeatable, docker only)
* --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
* --privileged Run docker container in privileged mode (docker only)
* --network <name> Connect docker container to a named network (docker only)
* --network-alias <alias> Add a network-scoped alias (repeatable, docker only)
* --endpoint <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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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('-')) {
Expand Down Expand Up @@ -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
*/
Expand All @@ -671,6 +680,7 @@ function validateDockerRuntimeOptionsRequireDocker(options) {
'--privileged option is only valid when isolation stack includes docker'
);
}
dockerNetworkOptions.validateRequireDocker(options);
}

function validateDockerCleanupOptions(options, hasDocker) {
Expand Down
17 changes: 17 additions & 0 deletions js/src/lib/docker-cleanup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -237,6 +253,7 @@ module.exports = {
getDockerContainerCleanupInstructions,
appendDockerContainerCleanupPolicyMessage,
readDockerContainerOomKilled,
readDockerContainerStatus,
removeDockerContainer,
buildDetachedDockerCompletionScript,
startDetachedDockerCompletionWatcher,
Expand Down
44 changes: 44 additions & 0 deletions js/src/lib/docker-network-options.js
Original file line number Diff line number Diff line change
@@ -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,
};
45 changes: 41 additions & 4 deletions js/src/lib/isolation.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const {
getDockerContainerCleanupInstructions,
appendDockerContainerCleanupPolicyMessage,
readDockerContainerOomKilled,
readDockerContainerStatus,
removeDockerContainer,
startDetachedDockerCompletionWatcher,
spawnAttachedDocker,
Expand Down Expand Up @@ -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 = {}) {
Expand All @@ -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;
}

Expand All @@ -542,14 +549,22 @@ 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;
}

/**
* 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 {
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 2 additions & 0 deletions js/src/lib/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Options:
--mount <spec> Docker --mount spec (repeatable, docker only)
--env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
--privileged Run docker container in privileged mode (docker only)
--network <name> Connect docker container to a named network (docker only)
--network-alias <alias> Add network-scoped alias (repeatable, docker only)
--endpoint <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
Expand Down
Loading
Loading