From 57f4c466f125a49250b0746ccf4eb57f1541406a Mon Sep 17 00:00:00 2001 From: gamnaansong Date: Sat, 23 May 2026 09:28:01 +0000 Subject: [PATCH 1/2] feat(cli): --disk-size flag on ResourceFlags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `--disk-size ` CLI flag for `boxlite run` (and any subcommand that flattens `ResourceFlags`), wired straight into `BoxOptions.disk_size_gb`. The field already existed end-to-end in the runtime (`src/boxlite/src/runtime/options.rs:324` → `src/boxlite/src/litebox/init/tasks/container_rootfs.rs::create_cow_disk` sizes the COW overlay to `max(user_size, base_image_size)`) but was only reachable via the REST API. CLI users had no way to grow the writable rootfs past the base image, which is fine for one-shot workloads but immediately ENOSPCs for anything that pulls multiple images, runs `apt install`, builds wheels, or otherwise writes more than a few hundred MB inside the box. The motivating consumer is the agent-workflow dind integration tests added in a follow-up commit — they need 5-10 GB of in-box disk for sequential `docker pull`s, named-volume writes, and container-lifecycle scratch. Without this flag those tests would silently fail mid-pull and the breakage would look like a flaky registry, not a missing size declaration. Two new lib unit tests pin: - `--disk-size 10` reaches `BoxOptions.disk_size_gb` verbatim through `ResourceFlags::apply_to` - omitting the flag leaves `disk_size_gb = None`, preserving the documented "size to base image" default and guarding against a refactor that injects a fallback (`unwrap_or(N)`) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/src/cli.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/cli/src/cli.rs b/src/cli/src/cli.rs index 955cbef4f..8d4e669b6 100644 --- a/src/cli/src/cli.rs +++ b/src/cli/src/cli.rs @@ -367,6 +367,16 @@ pub struct ResourceFlags { /// Memory limit (in MiB) #[arg(long)] pub memory: Option, + + /// Container rootfs disk size (in GB). The COW overlay is sparse — + /// actual on-disk usage grows as the workload writes. The virtual + /// size is `max(this, base image size)`; smaller values are ignored. + /// Default (unset) sizes the overlay to exactly the base image, + /// leaving no headroom — set this for workloads that write + /// significant data (in-box `docker pull`, `apt install`, `npm + /// install`, build caches, etc.). + #[arg(long = "disk-size", value_name = "GB")] + pub disk_size_gb: Option, } impl ResourceFlags { @@ -380,6 +390,9 @@ impl ResourceFlags { if let Some(mem) = self.memory { opts.memory_mib = Some(mem); } + if let Some(gb) = self.disk_size_gb { + opts.disk_size_gb = Some(gb); + } } } @@ -758,6 +771,7 @@ mod tests { let flags = ResourceFlags { cpus: Some(1000), memory: None, + disk_size_gb: None, }; let mut opts = BoxOptions::default(); @@ -766,6 +780,44 @@ mod tests { assert_eq!(opts.cpus, Some(255)); } + #[test] + fn test_resource_flags_disk_size_plumbed() { + // --disk-size must reach BoxOptions.disk_size_gb verbatim so the + // COW overlay in container_rootfs::create_cow_disk picks up + // max(user_size, base_image_size). A regression that drops this + // flag would leave agent-workflow tests at base-image size and + // they'd silently ENOSPC mid-`docker pull`. + let flags = ResourceFlags { + cpus: None, + memory: None, + disk_size_gb: Some(10), + }; + + let mut opts = BoxOptions::default(); + flags.apply_to(&mut opts); + + assert_eq!(opts.disk_size_gb, Some(10)); + } + + #[test] + fn test_resource_flags_disk_size_default_unset() { + // No --disk-size on the command line means BoxOptions.disk_size_gb + // stays None — container_rootfs::create_cow_disk's `if let Some` + // branch is skipped and the COW disk is exactly the base image + // size. This is the documented default; the test pins it so a + // refactor that injects a fallback (`unwrap_or(N)`) would fail. + let flags = ResourceFlags { + cpus: None, + memory: None, + disk_size_gb: None, + }; + + let mut opts = BoxOptions::default(); + flags.apply_to(&mut opts); + + assert_eq!(opts.disk_size_gb, None); + } + #[test] fn test_parse_publish_spec_host_box() { let spec = super::parse_publish_spec("18789:18789").unwrap(); From 32697d4b071188e6fb1c509d469d90a33c7d0de7 Mon Sep 17 00:00:00 2001 From: gamnaansong Date: Thu, 28 May 2026 13:19:47 +0000 Subject: [PATCH 2/2] feat(cli): --network/--allow-net + --entrypoint flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more runtime knobs that were reachable over REST/SDK but had no CLI flag, wired the same plumbing-only way as --disk-size: - `--network ` + `--allow-net ` (repeatable): builds NetworkConfig{mode, allow_net} → NetworkSpec::try_from, exactly mirroring the REST build_box_options mapping (single source of truth, including the disabled+allow_net rejection). `--network disabled` gives a no-eth0 box; `--allow-net` sets an egress allowlist (implies enabled). - `--entrypoint `: overrides the image entrypoint as a single-token argv → BoxOptions.entrypoint, which container_rootfs applies as config.entrypoint. Mirrors `docker run --entrypoint`. Both run and create get the flags (NetworkFlags flattened in each; entrypoint via ProcessFlags for run and a standalone arg for create). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/src/cli.rs | 158 ++++++++++++++++++++++++++++++++- src/cli/src/commands/create.rs | 14 ++- src/cli/src/commands/run.rs | 7 +- 3 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/cli/src/cli.rs b/src/cli/src/cli.rs index 8d4e669b6..f5910eb81 100644 --- a/src/cli/src/cli.rs +++ b/src/cli/src/cli.rs @@ -2,9 +2,10 @@ //! This module contains all CLI-related code including the main CLI structure, //! subcommands, and flag definitions. -use boxlite::runtime::options::{PortProtocol, PortSpec, VolumeSpec}; +use boxlite::runtime::options::{NetworkConfig, NetworkMode, PortProtocol, PortSpec, VolumeSpec}; use boxlite::{ BoxCommand, BoxOptions, BoxliteOptions, BoxliteRestOptions, BoxliteRuntime, ImageRegistry, + NetworkSpec, }; use clap::{Args, Command, Parser, Subcommand, ValueEnum}; use clap_complete::shells::{Bash, Fish, Zsh}; @@ -300,6 +301,12 @@ pub struct ProcessFlags { /// User to run the command as (format: [:]) #[arg(short = 'u', long = "user")] pub user: Option, + + /// Override the image entrypoint with a single executable, mirroring + /// `docker run --entrypoint`. Sets the container's configured entrypoint; + /// any trailing command is still exec'd as the foreground process. + #[arg(long = "entrypoint", value_name = "EXEC")] + pub entrypoint: Option, } impl ProcessFlags { @@ -315,6 +322,9 @@ impl ProcessFlags { { opts.working_dir = self.workdir.clone(); apply_env_vars_with_lookup(&self.env, opts, lookup); + if let Some(ref exec) = self.entrypoint { + opts.entrypoint = Some(vec![exec.clone()]); + } Ok(()) } @@ -396,6 +406,44 @@ impl ResourceFlags { } } +// ============================================================================ +// NETWORK FLAGS +// ============================================================================ + +#[derive(Args, Debug, Clone)] +pub struct NetworkFlags { + /// Network mode: "enabled" (default — full or allow-listed egress) or + /// "disabled" (no interface at all; gvproxy is not started and the guest + /// has no eth0). + #[arg(long = "network", value_name = "MODE")] + pub network: Option, + + /// Restrict egress to the listed hosts/IPs (repeatable); everything else + /// is DNS-sinkholed. Implies network=enabled. Patterns: exact host, + /// "*.example.com", IP, or CIDR. Incompatible with `--network disabled`. + #[arg(long = "allow-net", value_name = "HOST")] + pub allow_net: Vec, +} + +impl NetworkFlags { + pub fn apply_to(&self, opts: &mut BoxOptions) -> anyhow::Result<()> { + // Leave BoxOptions::default() (Enabled, full access) untouched when + // neither flag is given, so a bare `run` behaves as before. + if self.network.is_none() && self.allow_net.is_empty() { + return Ok(()); + } + let mode = match self.network.as_deref() { + Some(value) => value.parse::()?, + None => NetworkMode::Enabled, + }; + opts.network = NetworkSpec::try_from(NetworkConfig { + mode, + allow_net: self.allow_net.clone(), + })?; + Ok(()) + } +} + // ============================================================================ // PUBLISH (PORT) FLAGS // ============================================================================ @@ -818,6 +866,114 @@ mod tests { assert_eq!(opts.disk_size_gb, None); } + fn network_flags(network: Option<&str>, allow_net: &[&str]) -> NetworkFlags { + NetworkFlags { + network: network.map(str::to_string), + allow_net: allow_net.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn test_network_flags_default_left_untouched() { + // Neither flag set => BoxOptions::default() network is preserved + // (Enabled, empty allow_net), so a bare `run` keeps full access. + let mut opts = BoxOptions::default(); + network_flags(None, &[]) + .apply_to(&mut opts) + .expect("no-op apply"); + + assert!( + matches!(opts.network, NetworkSpec::Enabled { ref allow_net } if allow_net.is_empty()) + ); + } + + #[test] + fn test_network_flags_disabled() { + // --network disabled => NetworkSpec::Disabled (no eth0, gvproxy off). + let mut opts = BoxOptions::default(); + network_flags(Some("disabled"), &[]) + .apply_to(&mut opts) + .expect("disabled is valid"); + + assert!(matches!(opts.network, NetworkSpec::Disabled)); + } + + #[test] + fn test_network_flags_allow_net_implies_enabled() { + // --allow-net without --network => Enabled with the egress allowlist, + // matching the REST NetworkConfig{mode, allow_net} mapping. + let mut opts = BoxOptions::default(); + network_flags(None, &["api.openai.com", "10.0.0.0/8"]) + .apply_to(&mut opts) + .expect("allow-net implies enabled"); + + match opts.network { + NetworkSpec::Enabled { allow_net } => { + assert_eq!(allow_net, vec!["api.openai.com", "10.0.0.0/8"]); + } + other => panic!("expected Enabled with allowlist, got {other:?}"), + } + } + + #[test] + fn test_network_flags_disabled_with_allow_net_is_rejected() { + // --network disabled + --allow-net is contradictory; the error comes + // from NetworkSpec::try_from (single source of truth), not the CLI. + let mut opts = BoxOptions::default(); + let err = network_flags(Some("disabled"), &["api.openai.com"]) + .apply_to(&mut opts) + .expect_err("disabled + allow-net must error"); + + assert!(err.to_string().contains("allow_net")); + } + + #[test] + fn test_network_flags_invalid_mode_is_rejected() { + // Unknown mode strings surface NetworkMode::from_str's error rather + // than silently defaulting to enabled. + let mut opts = BoxOptions::default(); + let err = network_flags(Some("bridge"), &[]) + .apply_to(&mut opts) + .expect_err("unknown mode must error"); + + assert!(err.to_string().contains("network.mode")); + } + + fn process_flags_with_entrypoint(entrypoint: Option<&str>) -> ProcessFlags { + ProcessFlags { + interactive: false, + tty: false, + env: Vec::new(), + workdir: None, + user: None, + entrypoint: entrypoint.map(str::to_string), + } + } + + #[test] + fn test_process_flags_entrypoint_override() { + // --entrypoint reaches BoxOptions.entrypoint as a single-token + // argv, which container_rootfs applies as config.entrypoint. + let mut opts = BoxOptions::default(); + process_flags_with_entrypoint(Some("/bin/bash")) + .apply_to(&mut opts) + .expect("entrypoint apply"); + + assert_eq!(opts.entrypoint, Some(vec!["/bin/bash".to_string()])); + } + + #[test] + fn test_process_flags_entrypoint_default_none() { + // No --entrypoint leaves BoxOptions.entrypoint None so the image's + // own entrypoint is used unchanged. + let mut opts = BoxOptions::default(); + process_flags_with_entrypoint(None) + .apply_to(&mut opts) + .expect("no-op apply"); + + assert_eq!(opts.entrypoint, None); + } + #[test] fn test_parse_publish_spec_host_box() { let spec = super::parse_publish_spec("18789:18789").unwrap(); diff --git a/src/cli/src/commands/create.rs b/src/cli/src/commands/create.rs index 1f58624a9..dc56256a7 100644 --- a/src/cli/src/commands/create.rs +++ b/src/cli/src/commands/create.rs @@ -1,4 +1,4 @@ -use crate::cli::{GlobalFlags, PublishFlags, ResourceFlags, VolumeFlags}; +use crate::cli::{GlobalFlags, NetworkFlags, PublishFlags, ResourceFlags, VolumeFlags}; use boxlite::{BoxOptions, RootfsSpec}; use clap::Args; @@ -20,6 +20,11 @@ pub struct CreateArgs { #[arg(short = 'w', long = "workdir")] pub workdir: Option, + /// Override the image entrypoint with a single executable, mirroring + /// `docker create --entrypoint`. + #[arg(long = "entrypoint", value_name = "EXEC")] + pub entrypoint: Option, + #[command(flatten)] pub resource: ResourceFlags, @@ -28,6 +33,9 @@ pub struct CreateArgs { #[command(flatten)] pub volume: VolumeFlags, + + #[command(flatten)] + pub network: NetworkFlags, } pub async fn execute(args: CreateArgs, global: &GlobalFlags) -> anyhow::Result<()> { @@ -47,7 +55,11 @@ impl CreateArgs { self.management.apply_to(&mut options); self.publish.apply_to(&mut options)?; self.volume.apply_to(&mut options, global.home.as_deref())?; + self.network.apply_to(&mut options)?; options.working_dir = self.workdir.clone(); + if let Some(ref exec) = self.entrypoint { + options.entrypoint = Some(vec![exec.clone()]); + } crate::cli::apply_env_vars(&self.env, &mut options); options.rootfs = RootfsSpec::Image(self.image.clone()); Ok(options) diff --git a/src/cli/src/commands/run.rs b/src/cli/src/commands/run.rs index 13e81fb6e..7ffe7b151 100644 --- a/src/cli/src/commands/run.rs +++ b/src/cli/src/commands/run.rs @@ -1,5 +1,6 @@ use crate::cli::{ - GlobalFlags, ManagementFlags, ProcessFlags, PublishFlags, ResourceFlags, VolumeFlags, + GlobalFlags, ManagementFlags, NetworkFlags, ProcessFlags, PublishFlags, ResourceFlags, + VolumeFlags, }; use crate::terminal::StreamManager; use crate::util::to_shell_exit_code; @@ -22,6 +23,9 @@ pub struct RunArgs { #[command(flatten)] pub volume: VolumeFlags, + #[command(flatten)] + pub network: NetworkFlags, + #[command(flatten)] pub management: ManagementFlags, @@ -99,6 +103,7 @@ impl BoxRunner { self.args .volume .apply_to(&mut options, self.home.as_deref())?; + self.args.network.apply_to(&mut options)?; self.args.process.apply_to(&mut options)?; // Runtime requires detached boxes to have manual lifecycle control (auto_remove=false)