use boxlite::runtime::options::{PortProtocol, PortSpec, VolumeSpec};
use boxlite::{BoxCommand, BoxOptions, BoxliteOptions, BoxliteRestOptions, BoxliteRuntime};
use clap::{Args, Command, Parser, Subcommand, ValueEnum};
use clap_complete::shells::{Bash, Fish, Zsh};
use std::io::{IsTerminal, Write};
use std::path::Path;
pub fn apply_env_vars(env: &[String], opts: &mut BoxOptions) {
apply_env_vars_with_lookup(env, opts, |k| std::env::var(k).ok())
}
pub fn apply_env_vars_with_lookup<F>(env: &[String], opts: &mut BoxOptions, lookup: F)
where
F: Fn(&str) -> Option<String>,
{
for env_str in env {
if let Some((k, v)) = env_str.split_once('=') {
opts.env.push((k.to_string(), v.to_string()));
} else if let Some(val) = lookup(env_str) {
opts.env.push((env_str.to_string(), val));
} else {
tracing::warn!(
"Environment variable '{}' not found on host, skipping",
env_str
);
}
}
}
#[derive(Parser, Debug)]
#[command(name = "boxlite", author, version, about = "BoxLite CLI")]
pub struct Cli {
#[command(flatten)]
pub global: GlobalFlags,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
#[non_exhaustive]
pub enum Commands {
Run(crate::commands::run::RunArgs),
Exec(crate::commands::exec::ExecArgs),
Create(crate::commands::create::CreateArgs),
#[command(visible_alias = "ls", visible_alias = "ps")]
List(crate::commands::list::ListArgs),
Rm(crate::commands::rm::RmArgs),
Start(crate::commands::start::StartArgs),
Stop(crate::commands::stop::StopArgs),
Restart(crate::commands::restart::RestartArgs),
Pull(crate::commands::pull::PullArgs),
Images(crate::commands::images::ImagesArgs),
Inspect(crate::commands::inspect::InspectArgs),
Cp(crate::commands::cp::CpArgs),
Info(crate::commands::info::InfoArgs),
Logs(crate::commands::logs::LogsArgs),
Stats(crate::commands::stats::StatsArgs),
Serve(crate::commands::serve::ServeArgs),
#[command(hide = true)]
Completion(CompletionArgs),
}
#[derive(ValueEnum, Clone, Debug)]
#[value(rename_all = "lower")]
pub enum Shell {
Bash,
Zsh,
Fish,
}
#[derive(Args, Debug)]
pub struct CompletionArgs {
pub shell: Shell,
}
pub fn generate_completion(shell: &Shell, cmd: &mut Command, name: &str, out: &mut dyn Write) {
match shell {
Shell::Bash => clap_complete::generate(Bash, cmd, name, out),
Shell::Zsh => clap_complete::generate(Zsh, cmd, name, out),
Shell::Fish => clap_complete::generate(Fish, cmd, name, out),
}
}
#[derive(Args, Debug, Clone)]
pub struct GlobalFlags {
#[arg(long, global = true)]
pub debug: bool,
#[arg(long, global = true, env = "BOXLITE_HOME")]
pub home: Option<std::path::PathBuf>,
#[arg(long, global = true, value_name = "REGISTRY")]
pub registry: Vec<String>,
#[arg(long, global = true)]
pub config: Option<String>,
#[arg(long, global = true, env = "BOXLITE_REST_URL")]
pub url: Option<String>,
}
impl GlobalFlags {
pub fn resolve_runtime_options(&self) -> anyhow::Result<BoxliteOptions> {
let mut options = if let Some(config_path) = &self.config {
crate::config::load_config(Path::new(config_path))?
} else {
BoxliteOptions::default()
};
if let Some(cli_home) = &self.home {
options.home_dir = cli_home.clone();
}
if !self.registry.is_empty() {
options.image_registries = self
.registry
.iter()
.cloned()
.chain(options.image_registries)
.collect();
}
Ok(options)
}
pub fn create_runtime_with_options(
&self,
options: BoxliteOptions,
) -> anyhow::Result<BoxliteRuntime> {
BoxliteRuntime::new(options).map_err(Into::into)
}
pub fn create_runtime(&self) -> anyhow::Result<BoxliteRuntime> {
if let Some(ref url) = self.url {
let opts = BoxliteRestOptions::new(url);
return BoxliteRuntime::rest(opts).map_err(Into::into);
}
let options = self.resolve_runtime_options()?;
self.create_runtime_with_options(options)
}
}
#[derive(Args, Debug, Clone)]
pub struct ProcessFlags {
#[arg(short, long)]
pub interactive: bool,
#[arg(short, long)]
pub tty: bool,
#[arg(short = 'e', long = "env")]
pub env: Vec<String>,
#[arg(short = 'w', long = "workdir")]
pub workdir: Option<String>,
#[arg(short = 'u', long = "user")]
pub user: Option<String>,
}
impl ProcessFlags {
pub fn apply_to(&self, opts: &mut BoxOptions) -> anyhow::Result<()> {
self.apply_to_with_lookup(opts, |k| std::env::var(k).ok())
}
fn apply_to_with_lookup<F>(&self, opts: &mut BoxOptions, lookup: F) -> anyhow::Result<()>
where
F: Fn(&str) -> Option<String>,
{
opts.working_dir = self.workdir.clone();
apply_env_vars_with_lookup(&self.env, opts, lookup);
Ok(())
}
pub fn validate(&self, detach: bool) -> anyhow::Result<()> {
if !detach && self.tty && !std::io::stdin().is_terminal() {
anyhow::bail!("the input device is not a TTY.");
}
Ok(())
}
pub fn configure_command(&self, mut cmd: BoxCommand) -> BoxCommand {
for env_str in &self.env {
if let Some((k, v)) = env_str.split_once('=') {
cmd = cmd.env(k, v);
} else if let Ok(val) = std::env::var(env_str) {
cmd = cmd.env(env_str, val);
}
}
if let Some(ref w) = self.workdir {
cmd = cmd.working_dir(w);
}
if self.tty {
cmd = cmd.tty(true);
}
if let Some(ref user) = self.user {
cmd = cmd.user(user);
}
cmd
}
}
#[derive(Args, Debug, Clone)]
pub struct ResourceFlags {
#[arg(long)]
pub cpus: Option<u32>,
#[arg(long)]
pub memory: Option<u32>,
}
impl ResourceFlags {
pub fn apply_to(&self, opts: &mut BoxOptions) {
if let Some(cpus) = self.cpus {
if cpus > 255 {
tracing::warn!("CPU limit capped at 255 (requested {})", cpus);
}
opts.cpus = Some(cpus.min(255) as u8);
}
if let Some(mem) = self.memory {
opts.memory_mib = Some(mem);
}
}
}
#[derive(Args, Debug, Clone)]
pub struct PublishFlags {
#[arg(short = 'p', long = "publish", value_name = "PORT")]
pub publish: Vec<String>,
}
impl PublishFlags {
pub fn apply_to(&self, opts: &mut BoxOptions) -> anyhow::Result<()> {
for s in &self.publish {
let spec = parse_publish_spec(s)?;
if matches!(spec.protocol, PortProtocol::Udp) {
eprintln!(
"Warning: UDP port forwarding is not yet implemented; {} will be forwarded as TCP",
s
);
}
opts.ports.push(spec);
}
Ok(())
}
}
fn parse_publish_spec(s: &str) -> anyhow::Result<PortSpec> {
let s = s.trim();
if s.is_empty() {
anyhow::bail!("empty port spec");
}
let (rest, protocol) = match s.split_once('/') {
Some((r, proto)) => {
let p = if proto.eq_ignore_ascii_case("tcp") {
PortProtocol::Tcp
} else if proto.eq_ignore_ascii_case("udp") {
PortProtocol::Udp
} else {
anyhow::bail!("invalid protocol {:?}; use tcp or udp", proto)
};
(r.trim(), p)
}
None => (s, PortProtocol::Tcp),
};
let parts: Vec<&str> = rest.splitn(2, ':').map(str::trim).collect();
let (host_port, guest_port) = match parts.as_slice() {
[guest] => {
let g = parse_port(guest)?;
(None, g)
}
[host, guest] => {
let h = parse_port(host)?;
let g = parse_port(guest)?;
(Some(h), g)
}
_ => anyhow::bail!(
"invalid port spec {:?}; use hostPort:boxPort or boxPort[/tcp]",
s
),
};
Ok(PortSpec {
host_port,
guest_port,
protocol,
host_ip: None,
})
}
fn parse_port(s: &str) -> anyhow::Result<u16> {
let n: u16 = s
.parse()
.map_err(|_| anyhow::anyhow!("invalid port number {:?}", s))?;
if n == 0 {
anyhow::bail!("port must be 1-65535");
}
Ok(n)
}
struct ParsedVolumeSpec {
host_path: Option<String>,
guest_path: String,
read_only: bool,
}
#[derive(Args, Debug, Clone)]
pub struct VolumeFlags {
#[arg(short = 'v', long = "volume", value_name = "VOLUME")]
pub volume: Vec<String>,
}
fn is_windows_drive(segment: &str) -> bool {
let s = segment.trim();
s.len() == 1
&& s.chars()
.next()
.map(|c| c.is_ascii_alphabetic())
.unwrap_or(false)
}
fn is_windows_absolute_path(path: &str) -> bool {
let b = path.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
}
fn parse_volume_read_only(opts: &str) -> bool {
opts.split(',').any(|o| o.trim().eq_ignore_ascii_case("ro"))
}
fn parse_volume_spec(s: &str) -> anyhow::Result<ParsedVolumeSpec> {
let s = s.trim();
if s.is_empty() {
anyhow::bail!("empty volume spec");
}
let parts: Vec<&str> = s.split(':').map(str::trim).collect();
let (host_path, guest_path, read_only) = match parts.len() {
1 => {
let guest = parts[0].to_string();
if guest.is_empty() {
anyhow::bail!("volume box path must be non-empty");
}
if !guest.starts_with('/') && !is_windows_drive(parts[0]) {
anyhow::bail!(
"anonymous volume box path must be absolute (e.g. /data), got {:?}",
guest
);
}
(None, guest, false)
}
2 => {
let second = parts[1];
if second.eq_ignore_ascii_case("ro") || second.eq_ignore_ascii_case("rw") {
let guest = parts[0].to_string();
if guest.is_empty() {
anyhow::bail!("volume box path must be non-empty");
}
(None, guest, second.eq_ignore_ascii_case("ro"))
} else {
(Some(parts[0].to_string()), parts[1].to_string(), false)
}
}
3 => {
if is_windows_drive(parts[0]) {
let host = format!("{}:{}", parts[0], parts[1]);
(Some(host), parts[2].to_string(), false)
} else {
let ro = parse_volume_read_only(parts[2]);
(Some(parts[0].to_string()), parts[1].to_string(), ro)
}
}
4.. => {
if is_windows_drive(parts[0]) {
let host = format!("{}:{}", parts[0], parts[1]);
let ro = parse_volume_read_only(parts[3]);
(Some(host), parts[2].to_string(), ro)
} else {
anyhow::bail!(
"invalid volume spec {:?}; use hostPath:boxPath[:options] (e.g. /data:/app/data or C:\\data:/app/data:ro)",
s
);
}
}
_ => {
anyhow::bail!(
"invalid volume spec {:?}; use hostPath:boxPath[:options] or boxPath[:options] for anonymous volume",
s
);
}
};
if let Some(ref host) = host_path
&& host.is_empty()
{
anyhow::bail!("volume host path must be non-empty");
}
if guest_path.is_empty() {
anyhow::bail!("volume box path must be non-empty");
}
Ok(ParsedVolumeSpec {
host_path,
guest_path,
read_only,
})
}
fn anonymous_volume_base(home: Option<&std::path::Path>) -> std::path::PathBuf {
home.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var("BOXLITE_HOME")
.ok()
.map(std::path::PathBuf::from)
})
.or_else(|| {
dirs::home_dir().map(|mut p| {
p.push(".boxlite");
p
})
})
.unwrap_or_else(std::env::temp_dir)
}
impl VolumeFlags {
pub fn apply_to(
&self,
opts: &mut BoxOptions,
home: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let base = anonymous_volume_base(home);
for s in self.volume.iter() {
let spec = parse_volume_spec(s)?;
let host_path = match spec.host_path {
Some(host) => {
let mut path = host;
if std::path::Path::new(&path).is_relative() && !is_windows_absolute_path(&path)
{
let abs = std::fs::canonicalize(&path)
.map_err(|e| anyhow::anyhow!("volume host path {:?}: {}", path, e))?;
path = abs.to_string_lossy().into_owned();
}
path
}
None => {
let unique = ulid::Ulid::new().to_string();
let dir = base.join("volumes").join("anonymous").join(unique);
std::fs::create_dir_all(&dir).map_err(|e| {
anyhow::anyhow!("failed to create anonymous volume dir {:?}: {}", dir, e)
})?;
dir.to_string_lossy().into_owned()
}
};
opts.volumes.push(VolumeSpec {
host_path,
guest_path: spec.guest_path,
read_only: spec.read_only,
});
}
Ok(())
}
}
#[derive(Args, Debug, Clone)]
pub struct ManagementFlags {
#[arg(long)]
pub name: Option<String>,
#[arg(short = 'd', long)]
pub detach: bool,
#[arg(long)]
pub rm: bool,
}
impl ManagementFlags {
pub fn apply_to(&self, opts: &mut BoxOptions) {
opts.detach = self.detach;
opts.auto_remove = self.rm;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_env_vars_with_lookup() {
let mut opts = BoxOptions::default();
let current_env = vec![
"TEST_VAR=test_value".to_string(),
"TEST_HOST_VAR".to_string(),
"NON_EXISTENT_VAR".to_string(),
];
apply_env_vars_with_lookup(¤t_env, &mut opts, |k| {
if k == "TEST_HOST_VAR" {
Some("host_value".to_string())
} else {
None
}
});
assert!(
opts.env
.contains(&("TEST_VAR".to_string(), "test_value".to_string()))
);
assert!(
opts.env
.contains(&("TEST_HOST_VAR".to_string(), "host_value".to_string()))
);
assert!(!opts.env.iter().any(|(k, _)| k == "NON_EXISTENT_VAR"));
}
#[test]
fn test_resource_flags_cpu_cap() {
let flags = ResourceFlags {
cpus: Some(1000),
memory: None,
};
let mut opts = BoxOptions::default();
flags.apply_to(&mut opts);
assert_eq!(opts.cpus, Some(255));
}
#[test]
fn test_parse_publish_spec_host_box() {
let spec = super::parse_publish_spec("18789:18789").unwrap();
assert_eq!(spec.host_port, Some(18789));
assert_eq!(spec.guest_port, 18789);
assert!(matches!(spec.protocol, PortProtocol::Tcp));
}
#[test]
fn test_parse_publish_spec_host_box_tcp() {
let spec = super::parse_publish_spec("8080:80/tcp").unwrap();
assert_eq!(spec.host_port, Some(8080));
assert_eq!(spec.guest_port, 80);
assert!(matches!(spec.protocol, PortProtocol::Tcp));
}
#[test]
fn test_parse_publish_spec_box_only() {
let spec = super::parse_publish_spec("80").unwrap();
assert_eq!(spec.host_port, None);
assert_eq!(spec.guest_port, 80);
}
#[test]
fn test_parse_publish_spec_udp() {
let spec = super::parse_publish_spec("53:53/udp").unwrap();
assert_eq!(spec.host_port, Some(53));
assert_eq!(spec.guest_port, 53);
assert!(matches!(spec.protocol, PortProtocol::Udp));
}
#[test]
fn test_parse_publish_spec_invalid_protocol() {
assert!(super::parse_publish_spec("80:80/sctp").is_err());
}
#[test]
fn test_parse_publish_spec_invalid_port() {
assert!(super::parse_publish_spec("0:80").is_err());
assert!(super::parse_publish_spec("99999:80").is_err());
}
#[test]
fn test_publish_flags_apply_to() {
let flags = PublishFlags {
publish: vec!["18789:18789".to_string(), "8080:80/tcp".to_string()],
};
let mut opts = BoxOptions::default();
flags.apply_to(&mut opts).unwrap();
assert_eq!(opts.ports.len(), 2);
assert_eq!(opts.ports[0].host_port, Some(18789));
assert_eq!(opts.ports[0].guest_port, 18789);
assert_eq!(opts.ports[1].host_port, Some(8080));
assert_eq!(opts.ports[1].guest_port, 80);
}
#[test]
fn test_parse_volume_spec_host_guest() {
let spec = super::parse_volume_spec("/data:/app/data").unwrap();
assert_eq!(spec.host_path.as_deref(), Some("/data"));
assert_eq!(spec.guest_path, "/app/data");
assert!(!spec.read_only);
}
#[test]
fn test_parse_volume_spec_read_only() {
let spec = super::parse_volume_spec("/data:/app/data:ro").unwrap();
assert_eq!(spec.host_path.as_deref(), Some("/data"));
assert_eq!(spec.guest_path, "/app/data");
assert!(spec.read_only);
}
#[test]
fn test_parse_volume_spec_rw_explicit() {
let spec = super::parse_volume_spec("/data:/app/data:rw").unwrap();
assert_eq!(spec.host_path.as_deref(), Some("/data"));
assert_eq!(spec.guest_path, "/app/data");
assert!(!spec.read_only);
}
#[test]
fn test_parse_volume_spec_anonymous() {
let spec = super::parse_volume_spec("/data").unwrap();
assert!(spec.host_path.is_none());
assert_eq!(spec.guest_path, "/data");
assert!(!spec.read_only);
}
#[test]
fn test_parse_volume_spec_anonymous_ro() {
let spec = super::parse_volume_spec("/data:ro").unwrap();
assert!(spec.host_path.is_none());
assert_eq!(spec.guest_path, "/data");
assert!(spec.read_only);
}
#[test]
fn test_parse_volume_spec_anonymous_relative_invalid() {
assert!(super::parse_volume_spec("data").is_err());
}
#[test]
fn test_parse_volume_spec_invalid_empty_parts() {
assert!(super::parse_volume_spec(":/app").is_err());
assert!(super::parse_volume_spec("/data:").is_err());
}
#[test]
fn test_parse_volume_spec_windows_drive_two_parts() {
let spec = super::parse_volume_spec(r"C:\data:/app/data").unwrap();
assert_eq!(spec.host_path.as_deref(), Some(r"C:\data"));
assert_eq!(spec.guest_path, "/app/data");
assert!(!spec.read_only);
}
#[test]
fn test_parse_volume_spec_windows_drive_with_ro() {
let spec = super::parse_volume_spec(r"C:\data:/app/data:ro").unwrap();
assert_eq!(spec.host_path.as_deref(), Some(r"C:\data"));
assert_eq!(spec.guest_path, "/app/data");
assert!(spec.read_only);
}
#[test]
fn test_parse_volume_spec_windows_drive_with_rw() {
let spec = super::parse_volume_spec(r"D:\path:/mnt:rw").unwrap();
assert_eq!(spec.host_path.as_deref(), Some(r"D:\path"));
assert_eq!(spec.guest_path, "/mnt");
assert!(!spec.read_only);
}
#[test]
fn test_parse_volume_spec_windows_drive_long_path() {
let spec = super::parse_volume_spec(r"D:\host\path:/app").unwrap();
assert_eq!(spec.host_path.as_deref(), Some(r"D:\host\path"));
assert_eq!(spec.guest_path, "/app");
}
#[test]
fn test_parse_volume_spec_unix_three_colons_invalid() {
assert!(super::parse_volume_spec("/a:b:c:d").is_err());
}
#[test]
fn test_parse_volume_spec_linux_unchanged() {
let spec = super::parse_volume_spec("/data:/app/data").unwrap();
assert_eq!(spec.host_path.as_deref(), Some("/data"));
assert_eq!(spec.guest_path, "/app/data");
let spec2 = super::parse_volume_spec("/data:/app/data:ro").unwrap();
assert_eq!(spec2.host_path.as_deref(), Some("/data"));
assert_eq!(spec2.guest_path, "/app/data");
assert!(spec2.read_only);
}
#[test]
fn test_volume_flags_apply_to() {
let flags = VolumeFlags {
volume: vec![
"/host/data:/guest/data".to_string(),
"/readonly:/ro:ro".to_string(),
],
};
let mut opts = BoxOptions::default();
flags.apply_to(&mut opts, None).unwrap();
assert_eq!(opts.volumes.len(), 2);
assert_eq!(opts.volumes[0].host_path, "/host/data");
assert_eq!(opts.volumes[0].guest_path, "/guest/data");
assert!(!opts.volumes[0].read_only);
assert_eq!(opts.volumes[1].host_path, "/readonly");
assert_eq!(opts.volumes[1].guest_path, "/ro");
assert!(opts.volumes[1].read_only);
}
#[test]
fn test_volume_flags_apply_to_windows_style() {
let flags = VolumeFlags {
volume: vec![
r"C:\host\data:/guest/data".to_string(),
r"D:\readonly:/ro:ro".to_string(),
],
};
let mut opts = BoxOptions::default();
flags.apply_to(&mut opts, None).unwrap();
assert_eq!(opts.volumes.len(), 2);
assert_eq!(opts.volumes[0].host_path, r"C:\host\data");
assert_eq!(opts.volumes[0].guest_path, "/guest/data");
assert!(!opts.volumes[0].read_only);
assert_eq!(opts.volumes[1].host_path, r"D:\readonly");
assert_eq!(opts.volumes[1].guest_path, "/ro");
assert!(opts.volumes[1].read_only);
}
#[test]
fn test_volume_flags_apply_to_anonymous() {
let base = std::env::temp_dir();
let flags = VolumeFlags {
volume: vec!["/data".to_string(), "/cache:ro".to_string()],
};
let mut opts = BoxOptions::default();
flags.apply_to(&mut opts, Some(&base)).unwrap();
assert_eq!(opts.volumes.len(), 2);
assert_eq!(opts.volumes[0].guest_path, "/data");
assert!(
opts.volumes[0].host_path.contains("anonymous"),
"anonymous volume host_path should contain 'anonymous': {}",
opts.volumes[0].host_path
);
assert!(std::path::Path::new(&opts.volumes[0].host_path).exists());
assert_eq!(opts.volumes[1].guest_path, "/cache");
assert!(opts.volumes[1].read_only);
assert!(opts.volumes[1].host_path.contains("anonymous"));
}
}