use std::sync::atomic::{AtomicI32, Ordering};
static WATCHER_CONTAINER_PID: AtomicI32 = AtomicI32::new(0);
#[allow(dead_code)] extern "C" fn watcher_forward_signal(signum: libc::c_int) {
let pid = WATCHER_CONTAINER_PID.load(Ordering::Relaxed);
if pid > 0 {
unsafe { libc::kill(pid, signum) };
}
}
use super::{
check_liveness, container_dir, containers_dir, generate_name, parse_capability, parse_cpus,
parse_memory, parse_ulimit, parse_user, parse_user_in_layers, rootfs_path, write_state,
ContainerState, ContainerStatus, HealthConfig, HealthStatus,
};
use pelagos::container::{Capability, Command, Namespace, Stdio, Volume};
use pelagos::network::NetworkMode;
use pelagos::wasm::WasmRuntime;
use std::io::{self, Write};
use std::path::PathBuf;
#[derive(Debug, clap::Args)]
#[clap(trailing_var_arg = true)]
pub struct RunArgs {
#[clap(long)]
pub name: Option<String>,
#[clap(long, short = 'd')]
pub detach: bool,
#[clap(long)]
pub rm: bool,
#[clap(long, short = 'i')]
pub interactive: bool,
#[clap(long)]
pub network: Vec<String>,
#[clap(long = "publish", short = 'p')]
pub publish: Vec<String>,
#[clap(long)]
pub nat: bool,
#[clap(long)]
pub dns: Vec<String>,
#[clap(long = "volume", short = 'v')]
pub volume: Vec<String>,
#[clap(long = "bind")]
pub bind: Vec<String>,
#[clap(long = "bind-ro")]
pub bind_ro: Vec<String>,
#[clap(long = "tmpfs")]
pub tmpfs: Vec<String>,
#[clap(long = "read-only")]
pub read_only: bool,
#[clap(long = "env", short = 'e')]
pub env: Vec<String>,
#[clap(long = "env-file")]
pub env_file: Option<PathBuf>,
#[clap(long = "workdir", short = 'w')]
pub workdir: Option<String>,
#[clap(long = "user", short = 'u')]
pub user: Option<String>,
#[clap(long)]
pub hostname: Option<String>,
#[clap(long)]
pub memory: Option<String>,
#[clap(long)]
pub cpus: Option<String>,
#[clap(long = "cpu-shares")]
pub cpu_shares: Option<u64>,
#[clap(long = "pids-limit")]
pub pids_limit: Option<u64>,
#[clap(long = "ulimit")]
pub ulimit: Vec<String>,
#[clap(long = "cap-drop")]
pub cap_drop: Vec<String>,
#[clap(long = "cap-add")]
pub cap_add: Vec<String>,
#[clap(long = "security-opt")]
pub security_opt: Vec<String>,
#[clap(long = "link")]
pub link: Vec<String>,
#[clap(long = "sysctl")]
pub sysctl: Vec<String>,
#[clap(long = "masked-path")]
pub masked_path: Vec<String>,
#[clap(long = "dns-backend", value_name = "BACKEND")]
pub dns_backend: Option<String>,
#[clap(long)]
pub rootfs: Option<String>,
#[clap(multiple_values = true)]
pub args: Vec<String>,
}
pub fn cmd_run(args: RunArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.detach && args.interactive {
return Err("--detach and --interactive are mutually exclusive".into());
}
if let Some(ref backend) = args.dns_backend {
unsafe { std::env::set_var("PELAGOS_DNS_BACKEND", backend) };
}
let port_forwards = parse_port_forwards(&args.publish)?;
let primary_network_str = args.network.first().map(|s| s.as_str()).unwrap_or("none");
let network_mode = parse_network_mode(primary_network_str)?;
let additional_networks: Vec<String> = args.network.iter().skip(1).cloned().collect();
if let Some(msg) = super::check_rootless_bridge(
pelagos::paths::is_rootless(),
&network_mode,
args.nat,
!args.publish.is_empty(),
) {
eprintln!("{}", msg);
std::process::exit(1);
}
let name = match args.name {
Some(ref n) => n.clone(),
None => generate_name()?,
};
if container_dir(&name).exists() {
let state = super::read_state(&name).ok();
if let Some(s) = state {
if s.status == ContainerStatus::Running && check_liveness(s.pid) {
return Err(format!("container '{}' already exists and is running", name).into());
}
}
}
for net_name in &additional_networks {
let config = pelagos::paths::network_config_dir(net_name).join("config.json");
if !config.exists() {
return Err(format!(
"additional network '{}' not found — create it first: pelagos network create {} --subnet CIDR",
net_name, net_name
).into());
}
}
let (rootfs_label, exe_and_args, cmd, health_config) =
if let Some(ref rootfs_name) = args.rootfs {
let exe_and_args: Vec<String> = if args.args.is_empty() {
vec!["/bin/sh".to_string()]
} else {
args.args.clone()
};
let rootfs_dir = rootfs_path(rootfs_name)?;
let cmd = build_command(
&args,
&rootfs_dir,
&exe_and_args,
&port_forwards,
network_mode,
&additional_networks,
&name,
)?;
(rootfs_name.clone(), exe_and_args, cmd, None)
} else {
if args.args.is_empty() {
return Err("an image name is required".into());
}
let image_ref = &args.args[0];
let cmd_args: Vec<String> = args.args[1..].to_vec();
build_image_run(
&args,
image_ref,
&cmd_args,
&port_forwards,
network_mode,
&additional_networks,
&name,
)?
};
if args.detach {
run_detached(name, rootfs_label, exe_and_args, cmd, health_config)
} else if args.interactive {
run_interactive(cmd)
} else {
run_foreground(name, rootfs_label, exe_and_args, cmd, args.rm)
}
}
type ImageRunResult = (String, Vec<String>, Command, Option<HealthConfig>);
fn build_image_run(
args: &RunArgs,
image_ref: &str,
cmd_args: &[String],
port_forwards: &[(u16, u16, pelagos::network::PortProto)],
network_mode: NetworkMode,
additional_networks: &[String],
container_name: &str,
) -> Result<ImageRunResult, Box<dyn std::error::Error>> {
use pelagos::image;
let (full_ref, manifest) = if let Ok(m) = image::load_image(image_ref) {
(image_ref.to_string(), m)
} else {
let normalised = normalise_image_reference(image_ref);
let m = image::load_image(&normalised).map_err(|e| {
format!(
"image '{}' not found locally (run 'pelagos image pull {}'): {}",
image_ref, image_ref, e
)
})?;
(normalised, m)
};
if manifest.is_wasm_image() {
let wasm_path = manifest
.wasm_module_path()
.ok_or("Wasm image has no module.wasm layer — re-pull the image")?;
let exe_and_args: Vec<String> = if !cmd_args.is_empty() {
cmd_args.to_vec()
} else {
vec![wasm_path.to_string_lossy().into_owned()]
};
let wasm_str = wasm_path.to_string_lossy().into_owned();
let extra_args = &exe_and_args[1..];
let mut cmd = Command::new(&wasm_str)
.args(extra_args)
.with_wasm_runtime(WasmRuntime::Auto);
for env_str in &manifest.config.env {
if let Some((k, v)) = env_str.split_once('=') {
cmd = cmd.with_wasi_env(k, v);
}
}
for env_str in &args.env {
if let Some((k, v)) = env_str.split_once('=') {
cmd = cmd.with_wasi_env(k, v);
}
}
for bind_str in &args.bind {
if let Some((host, guest)) = bind_str.split_once(':') {
cmd = cmd.with_wasi_preopened_dir_mapped(host, guest);
}
}
let health_config = manifest.config.healthcheck.clone();
return Ok((full_ref, exe_and_args, cmd, health_config));
}
let layers = image::layer_dirs(&manifest);
if layers.is_empty() {
return Err("image has no layers".into());
}
let layer_dirs = layers.clone();
let exe_and_args = if !cmd_args.is_empty() {
cmd_args.to_vec()
} else {
let mut cmd_vec = manifest.config.entrypoint.clone();
cmd_vec.extend(manifest.config.cmd.clone());
if cmd_vec.is_empty() {
vec!["/bin/sh".to_string()]
} else {
cmd_vec
}
};
let exe = &exe_and_args[0];
let rest = &exe_and_args[1..];
let mut cmd = Command::new(exe)
.args(rest)
.with_image_layers(layers)
.add_namespaces(Namespace::UTS | Namespace::PID);
for env_str in &manifest.config.env {
if let Some((k, v)) = env_str.split_once('=') {
cmd = cmd.env(k, v);
}
}
if !manifest.config.working_dir.is_empty() && args.workdir.is_none() {
cmd = cmd.with_cwd(&manifest.config.working_dir);
}
if args.user.is_none() && !manifest.config.user.is_empty() {
let (uid, gid) = parse_user_in_layers(&manifest.config.user, &layer_dirs)?;
cmd = cmd.with_uid(uid);
if let Some(g) = gid {
cmd = cmd.with_gid(g);
}
}
cmd = apply_cli_options(
cmd,
args,
port_forwards,
network_mode,
additional_networks,
container_name,
)?;
let health_config = manifest.config.healthcheck.clone();
Ok((full_ref, exe_and_args, cmd, health_config))
}
fn normalise_image_reference(reference: &str) -> String {
let r = reference.to_string();
let r = if !r.contains(':') && !r.contains('@') {
format!("{}:latest", r)
} else {
r
};
if !r.contains('/') {
format!("docker.io/library/{}", r)
} else {
r
}
}
fn build_command(
args: &RunArgs,
rootfs_dir: &std::path::Path,
exe_and_args: &[String],
port_forwards: &[(u16, u16, pelagos::network::PortProto)],
network_mode: NetworkMode,
additional_networks: &[String],
container_name: &str,
) -> Result<Command, Box<dyn std::error::Error>> {
let exe = &exe_and_args[0];
let rest = &exe_and_args[1..];
let mut cmd = Command::new(exe)
.args(rest)
.with_chroot(rootfs_dir)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::PID)
.with_proc_mount()
.with_dev_mount();
cmd = apply_cli_options(
cmd,
args,
port_forwards,
network_mode,
additional_networks,
container_name,
)?;
Ok(cmd)
}
fn apply_cli_options(
mut cmd: Command,
args: &RunArgs,
port_forwards: &[(u16, u16, pelagos::network::PortProto)],
network_mode: NetworkMode,
additional_networks: &[String],
container_name: &str,
) -> Result<Command, Box<dyn std::error::Error>> {
if network_mode != NetworkMode::None {
cmd = cmd.with_network(network_mode);
}
for net_name in additional_networks {
cmd = cmd.with_additional_network(net_name);
}
for &(host, container, proto) in port_forwards {
use pelagos::network::PortProto;
cmd = match proto {
PortProto::Tcp => cmd.with_port_forward(host, container),
PortProto::Udp => cmd.with_port_forward_udp(host, container),
PortProto::Both => cmd.with_port_forward_both(host, container),
};
}
if args.nat {
cmd = cmd.with_nat();
}
if !args.dns.is_empty() {
cmd = cmd.with_dns(&args.dns.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
for link_spec in &args.link {
if let Some((name, alias)) = link_spec.split_once(':') {
cmd = cmd.with_link_alias(name, alias);
} else {
cmd = cmd.with_link(link_spec);
}
}
if args.read_only {
cmd = cmd.with_readonly_rootfs(true);
}
for v in &args.volume {
if let Some((src, rest)) = v.split_once(':') {
let (tgt, readonly) = match rest.rsplit_once(':') {
Some((t, "ro")) => (t, true),
Some((t, "rw")) => (t, false),
_ => (rest, false),
};
if src.starts_with('/') {
if readonly {
cmd = cmd.with_bind_mount_ro(src, tgt);
} else {
cmd = cmd.with_bind_mount(src, tgt);
}
} else {
let vol = Volume::open(src).or_else(|_| Volume::create(src))?;
cmd = cmd.with_volume(&vol, tgt);
}
} else {
return Err(format!(
"invalid --volume '{}': expected NAME:/path or /host:/path[:ro|:rw]",
v
)
.into());
}
}
for b in &args.bind {
let (src, tgt) = split_mount_spec(b, "--bind")?;
cmd = cmd.with_bind_mount(src, tgt);
}
for b in &args.bind_ro {
let (src, tgt) = split_mount_spec(b, "--bind-ro")?;
cmd = cmd.with_bind_mount_ro(src, tgt);
}
for t in &args.tmpfs {
let (path, opts) = t.split_once(':').unwrap_or((t.as_str(), ""));
cmd = cmd.with_tmpfs(path, opts);
}
if let Some(ref ef) = args.env_file {
let content = std::fs::read_to_string(ef)
.map_err(|e| format!("--env-file {}: {}", ef.display(), e))?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
cmd = cmd.env(k, v);
}
}
}
for e in &args.env {
if let Some((k, v)) = e.split_once('=') {
cmd = cmd.env(k, v);
} else if let Ok(v) = std::env::var(e) {
cmd = cmd.env(e, v);
}
}
cmd = cmd.env(
"PATH",
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
);
if let Some(ref u) = args.user {
let (uid, gid) = parse_user(u)?;
cmd = cmd.with_uid(uid);
if let Some(g) = gid {
cmd = cmd.with_gid(g);
}
}
if let Some(ref w) = args.workdir {
cmd = cmd.with_cwd(w);
}
let hostname = args.hostname.as_deref().unwrap_or(container_name);
cmd = cmd.with_hostname(hostname);
if let Some(ref m) = args.memory {
let bytes = parse_memory(m)?;
cmd = cmd.with_cgroup_memory(bytes);
cmd = cmd.with_cgroup_memory_swap(0);
}
if let Some(ref c) = args.cpus {
let (quota, period) = parse_cpus(c)?;
cmd = cmd.with_cgroup_cpu_quota(quota, period);
}
if let Some(shares) = args.cpu_shares {
cmd = cmd.with_cgroup_cpu_shares(shares);
}
if let Some(pids) = args.pids_limit {
cmd = cmd.with_cgroup_pids_limit(pids);
}
for u in &args.ulimit {
let (res, soft, hard) = parse_ulimit(u)?;
cmd = cmd.with_rlimit(res, soft, hard);
}
if !args.cap_drop.is_empty() || !args.cap_add.is_empty() {
let drop_all = args.cap_drop.iter().any(|c| c.eq_ignore_ascii_case("ALL"));
let mut effective = if drop_all {
Capability::empty()
} else {
Capability::DEFAULT_CAPS
};
if !drop_all {
for cap_name in &args.cap_drop {
effective &= !parse_capability(cap_name)?;
}
}
for cap_name in &args.cap_add {
effective |= parse_capability(cap_name)?;
}
cmd = cmd.with_capabilities(effective);
}
for opt in &args.security_opt {
let (key, val) = opt.split_once('=').unwrap_or((opt.as_str(), ""));
match key {
"seccomp" => match val {
"default" | "" => cmd = cmd.with_seccomp_default(),
"minimal" => cmd = cmd.with_seccomp_minimal(),
"none" => {}
other => {
return Err(format!(
"unknown seccomp profile '{}' (use: default, minimal, none)",
other
)
.into())
}
},
"no-new-privileges" => cmd = cmd.with_no_new_privileges(true),
other => return Err(format!("unknown --security-opt '{}'", other).into()),
}
}
for s in &args.sysctl {
if let Some((k, v)) = s.split_once('=') {
cmd = cmd.with_sysctl(k, v);
} else {
return Err(format!("invalid --sysctl '{}': expected KEY=VALUE", s).into());
}
}
if !args.masked_path.is_empty() {
let paths: Vec<&str> = args.masked_path.iter().map(|s| s.as_str()).collect();
cmd = cmd.with_masked_paths(&paths);
}
Ok(cmd)
}
fn parse_network_mode(s: &str) -> Result<NetworkMode, Box<dyn std::error::Error>> {
match s.to_ascii_lowercase().as_str() {
"none" | "" => Ok(NetworkMode::None),
"loopback" => Ok(NetworkMode::Loopback),
"bridge" => Ok(NetworkMode::Bridge),
"pasta" => Ok(NetworkMode::Pasta),
name => {
let config = pelagos::paths::network_config_dir(name).join("config.json");
if config.exists() {
Ok(NetworkMode::BridgeNamed(name.to_string()))
} else {
Err(format!(
"unknown network '{}' — use a mode (none, loopback, bridge, pasta) \
or create it first: pelagos network create {} --subnet CIDR",
name, name
)
.into())
}
}
}
}
#[allow(clippy::type_complexity)]
fn parse_port_forwards(
specs: &[String],
) -> Result<Vec<(u16, u16, pelagos::network::PortProto)>, Box<dyn std::error::Error>> {
use pelagos::network::PortProto;
let mut out = Vec::new();
for s in specs {
let (ports_part, proto_str) = match s.rsplit_once('/') {
Some((p, pr)) => (p, pr),
None => (s.as_str(), "tcp"),
};
let (h, c) = ports_part
.split_once(':')
.ok_or_else(|| format!("invalid --publish '{}': expected HOST:CONTAINER[/PROTO]", s))?;
let host = h
.trim()
.parse::<u16>()
.map_err(|e| format!("invalid host port '{}': {}", h, e))?;
let container = c
.trim()
.parse::<u16>()
.map_err(|e| format!("invalid container port '{}': {}", c, e))?;
let proto = PortProto::parse(proto_str);
out.push((host, container, proto));
}
Ok(out)
}
fn split_mount_spec<'a>(
s: &'a str,
flag: &str,
) -> Result<(&'a str, &'a str), Box<dyn std::error::Error>> {
s.split_once(':')
.ok_or_else(|| format!("invalid {} '{}': expected /host:/container", flag, s).into())
}
fn run_foreground(
name: String,
rootfs: String,
command: Vec<String>,
mut cmd: Command,
auto_remove: bool,
) -> Result<(), Box<dyn std::error::Error>> {
cmd = cmd
.stdin(Stdio::Inherit)
.stdout(Stdio::Inherit)
.stderr(Stdio::Inherit);
std::fs::create_dir_all(containers_dir())?;
let state = ContainerState {
name: name.clone(),
rootfs,
status: ContainerStatus::Running,
pid: 0,
watcher_pid: 0,
started_at: super::now_iso8601(),
exit_code: None,
command: command.clone(),
stdout_log: None,
stderr_log: None,
bridge_ip: None,
network_ips: std::collections::HashMap::new(),
health: None,
health_config: None,
};
write_state(&state)?;
let mut child = cmd.spawn().map_err(|e| format!("spawn failed: {}", e))?;
let pid = child.pid();
let mut state2 = state;
state2.pid = pid;
state2.bridge_ip = child.container_ip();
let all_ips: Vec<(String, String)> = child
.container_ips()
.into_iter()
.map(|(name, ip)| (name.to_string(), ip))
.collect();
state2.network_ips = all_ips.iter().cloned().collect();
write_state(&state2)?;
register_dns(&name, &all_ips);
let exit = child.wait().map_err(|e| format!("wait failed: {}", e))?;
let code = exit.code().unwrap_or(1);
deregister_dns(&name, &all_ips);
if auto_remove {
let dir = super::container_dir(&name);
let _ = std::fs::remove_dir_all(&dir);
} else {
state2.status = ContainerStatus::Exited;
state2.exit_code = Some(code);
write_state(&state2)?;
}
std::process::exit(code);
}
fn run_interactive(cmd: Command) -> Result<(), Box<dyn std::error::Error>> {
let session = cmd
.spawn_interactive()
.map_err(|e| format!("spawn_interactive failed: {}", e))?;
match session.run() {
Ok(status) => {
let code = status.code().unwrap_or(0);
std::process::exit(code);
}
Err(e) => Err(format!("interactive session failed: {}", e).into()),
}
}
fn run_detached(
name: String,
rootfs: String,
command: Vec<String>,
mut cmd: Command,
health_config: Option<HealthConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
std::fs::create_dir_all(containers_dir())?;
let dir = container_dir(&name);
std::fs::create_dir_all(&dir)?;
let stdout_log = dir.join("stdout.log");
let stderr_log = dir.join("stderr.log");
let state = ContainerState {
name: name.clone(),
rootfs,
status: ContainerStatus::Running,
pid: 0,
watcher_pid: 0,
started_at: super::now_iso8601(),
exit_code: None,
command: command.clone(),
stdout_log: Some(stdout_log.to_string_lossy().into_owned()),
stderr_log: Some(stderr_log.to_string_lossy().into_owned()),
bridge_ip: None,
network_ips: std::collections::HashMap::new(),
health: None,
health_config: None,
};
write_state(&state)?;
let fork_result = unsafe { libc::fork() };
match fork_result {
-1 => {
return Err(io::Error::last_os_error().into());
}
0 => {
unsafe { libc::setsid() };
unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) };
let watcher_pid = unsafe { libc::getpid() };
{
let mut early = state.clone();
early.watcher_pid = watcher_pid;
let _ = write_state(&early);
}
cmd = cmd
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
eprintln!("pelagos watcher: spawn failed: {}", e);
unsafe { libc::_exit(1) };
}
};
let pid = child.pid();
WATCHER_CONTAINER_PID.store(pid as i32, Ordering::Relaxed);
unsafe {
libc::signal(
libc::SIGTERM,
watcher_forward_signal as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGINT,
watcher_forward_signal as *const () as libc::sighandler_t,
);
}
let mut updated = state;
updated.pid = pid;
updated.watcher_pid = watcher_pid;
updated.bridge_ip = child.container_ip();
let all_ips: Vec<(String, String)> = child
.container_ips()
.into_iter()
.map(|(name, ip)| (name.to_string(), ip))
.collect();
updated.network_ips = all_ips.iter().cloned().collect();
updated.health_config = health_config.clone();
if health_config.is_some() {
updated.health = Some(HealthStatus::Starting);
}
let _ = write_state(&updated);
register_dns(&name, &all_ips);
let health_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let health_thread = health_config.map(|hc| {
let stop = std::sync::Arc::clone(&health_stop);
let name2 = name.clone();
std::thread::spawn(move || super::health::run_health_monitor(name2, pid, hc, stop))
});
let t_relay = super::relay::start_log_relay(
child.take_stdout(),
child.take_stderr(),
stdout_log.clone(),
stderr_log.clone(),
);
let exit = match child.wait() {
Ok(e) => e,
Err(e) => {
eprintln!("pelagos watcher: wait failed: {}", e);
unsafe { libc::_exit(1) };
}
};
health_stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = t_relay.join();
if let Some(t) = health_thread {
let _ = t.join();
}
deregister_dns(&name, &all_ips);
updated.status = ContainerStatus::Exited;
updated.exit_code = exit.code();
let _ = write_state(&updated);
unsafe { libc::_exit(0) };
}
_child_pid => {
println!("{}", name);
}
}
Ok(())
}
fn register_dns(container_name: &str, network_ips: &[(String, String)]) {
for (net_name, ip_str) in network_ips {
let ip: std::net::Ipv4Addr = match ip_str.parse() {
Ok(ip) => ip,
Err(_) => continue,
};
let net_def = match pelagos::network::load_network_def(net_name) {
Ok(d) => d,
Err(_) => continue,
};
if let Err(e) = pelagos::dns::dns_add_entry(
net_name,
container_name,
ip,
net_def.gateway,
&["8.8.8.8".to_string(), "1.1.1.1".to_string()],
) {
log::warn!(
"dns: failed to register '{}' on {}: {}",
container_name,
net_name,
e
);
}
}
}
fn deregister_dns(container_name: &str, network_ips: &[(String, String)]) {
for (net_name, _ip_str) in network_ips {
if let Err(e) = pelagos::dns::dns_remove_entry(net_name, container_name) {
log::warn!(
"dns: failed to deregister '{}' from {}: {}",
container_name,
net_name,
e
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_port_forwards_tcp_default() {
let specs = vec!["8080:80".to_string()];
let fwds = parse_port_forwards(&specs).unwrap();
assert_eq!(fwds.len(), 1);
assert_eq!(fwds[0], (8080, 80, pelagos::network::PortProto::Tcp));
}
#[test]
fn test_parse_port_forwards_explicit_tcp() {
let specs = vec!["8080:80/tcp".to_string()];
let fwds = parse_port_forwards(&specs).unwrap();
assert_eq!(fwds[0], (8080, 80, pelagos::network::PortProto::Tcp));
}
#[test]
fn test_parse_port_forwards_udp() {
let specs = vec!["5353:53/udp".to_string()];
let fwds = parse_port_forwards(&specs).unwrap();
assert_eq!(fwds[0], (5353, 53, pelagos::network::PortProto::Udp));
}
#[test]
fn test_parse_port_forwards_both() {
let specs = vec!["53:53/both".to_string()];
let fwds = parse_port_forwards(&specs).unwrap();
assert_eq!(fwds[0], (53, 53, pelagos::network::PortProto::Both));
}
#[test]
fn test_parse_port_forwards_multiple() {
let specs = vec!["80:80/tcp".to_string(), "5353:53/udp".to_string()];
let fwds = parse_port_forwards(&specs).unwrap();
assert_eq!(fwds.len(), 2);
assert_eq!(fwds[0].2, pelagos::network::PortProto::Tcp);
assert_eq!(fwds[1].2, pelagos::network::PortProto::Udp);
}
#[test]
fn test_parse_port_forwards_invalid() {
assert!(parse_port_forwards(&["notaport".to_string()]).is_err());
assert!(parse_port_forwards(&["abc:80".to_string()]).is_err());
}
}