use clap::Parser;
use nucleus::checkpoint::CriuRuntime;
use nucleus::container::{
parse_signal, validate_container_name, validate_hostname, Container, ContainerConfig,
ContainerLifecycle, ContainerState, ContainerStateManager, ContainerStateParams, HealthCheck,
KernelLockdownMode, NetworkModeArg, OciStatus, ProcessIdentity, ReadinessProbe,
RuntimeSelection, SeccompMode, SecretMount, ServiceMode, TrustLevel, VolumeMount, VolumeSource,
};
use nucleus::error::{NucleusError, Result};
use nucleus::filesystem::ContextMode;
use nucleus::isolation::{ContainerAttach, NamespaceConfig};
use nucleus::network::{BridgeConfig, EgressPolicy, NatBackend, NetworkMode, PortForward};
use nucleus::resources::{IoDeviceLimit, ResourceLimits, ResourceStats};
use nucleus::security::GVisorPlatform;
use nucleus::topology::{
execute_reconcile, plan_reconcile, DependencyGraph, ReconcileAction, TopologyConfig,
};
use std::path::PathBuf;
use tracing::info;
fn validate_systemd_credential_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(NucleusError::ConfigError(
"Systemd credential name cannot be empty".to_string(),
));
}
if name.contains('/') || name.contains('\0') || name == "." || name == ".." {
return Err(NucleusError::ConfigError(format!(
"Invalid systemd credential name '{}'",
name
)));
}
Ok(())
}
fn resolve_systemd_credential_source(name: &str) -> Result<PathBuf> {
validate_systemd_credential_name(name)?;
let credentials_dir = std::env::var_os("CREDENTIALS_DIRECTORY").ok_or_else(|| {
NucleusError::ConfigError(
"--systemd-credential requires CREDENTIALS_DIRECTORY from systemd".to_string(),
)
})?;
let credentials_dir = PathBuf::from(credentials_dir);
let canonical_dir = std::fs::canonicalize(&credentials_dir).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to resolve CREDENTIALS_DIRECTORY {:?}: {}",
credentials_dir, e
))
})?;
let source = std::fs::canonicalize(credentials_dir.join(name)).map_err(|e| {
NucleusError::ConfigError(format!(
"Systemd credential '{}' cannot be resolved under {:?}: {}",
name, credentials_dir, e
))
})?;
if !source.starts_with(&canonical_dir) {
return Err(NucleusError::ConfigError(format!(
"Systemd credential '{}' resolved outside CREDENTIALS_DIRECTORY",
name
)));
}
Ok(source)
}
fn resolve_uid_spec(spec: &str) -> Result<(u32, Option<u32>)> {
if let Ok(uid) = spec.parse::<u32>() {
return Ok((uid, None));
}
let user = nix::unistd::User::from_name(spec)
.map_err(|e| {
NucleusError::ConfigError(format!("Failed to resolve user '{}': {}", spec, e))
})?
.ok_or_else(|| NucleusError::ConfigError(format!("Unknown user '{}'", spec)))?;
Ok((user.uid.as_raw(), Some(user.gid.as_raw())))
}
fn resolve_gid_spec(spec: &str) -> Result<u32> {
if let Ok(gid) = spec.parse::<u32>() {
return Ok(gid);
}
let group = nix::unistd::Group::from_name(spec)
.map_err(|e| {
NucleusError::ConfigError(format!("Failed to resolve group '{}': {}", spec, e))
})?
.ok_or_else(|| NucleusError::ConfigError(format!("Unknown group '{}'", spec)))?;
Ok(group.gid.as_raw())
}
fn resolve_process_identity(
user: Option<&str>,
group: Option<&str>,
additional_groups: &[String],
) -> Result<Option<ProcessIdentity>> {
if user.is_none() && group.is_none() && additional_groups.is_empty() {
return Ok(None);
}
let user = user.ok_or_else(|| {
NucleusError::ConfigError(
"--group/--additional-group require --user to be set as well".to_string(),
)
})?;
let (uid, default_gid) = resolve_uid_spec(user)?;
let gid = match group {
Some(group) => resolve_gid_spec(group)?,
None => default_gid.ok_or_else(|| {
NucleusError::ConfigError(
"Numeric --user values require an explicit --group".to_string(),
)
})?,
};
let mut resolved_additional_gids = Vec::new();
for group in additional_groups {
let resolved = resolve_gid_spec(group)?;
if resolved != gid && !resolved_additional_gids.contains(&resolved) {
resolved_additional_gids.push(resolved);
}
}
Ok(Some(ProcessIdentity {
uid,
gid,
additional_gids: resolved_additional_gids,
}))
}
#[derive(Parser, Debug)]
#[command(name = "nucleus")]
#[command(about = "Extremely lightweight Docker alternative for agents", long_about = None)]
struct Cli {
#[arg(long, global = true)]
root: Option<PathBuf>,
#[arg(long, global = true)]
log: Option<PathBuf>,
#[arg(long, global = true, default_value = "text")]
log_format: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Parser, Debug)]
#[allow(clippy::large_enum_variant)]
enum Commands {
Create {
#[arg(long)]
name: Option<String>,
#[arg(long)]
context: Option<String>,
#[arg(long)]
memory: Option<String>,
#[arg(long)]
cpus: Option<f64>,
#[arg(long)]
hostname: Option<String>,
#[arg(long)]
cpu_weight: Option<u64>,
#[arg(long = "io-limit")]
io_limits: Vec<String>,
#[arg(long)]
pids: Option<u64>,
#[arg(long)]
memlock: Option<String>,
#[arg(long)]
swap: bool,
#[arg(long, default_value = "gvisor")]
runtime: RuntimeSelection,
#[arg(short = 'd', long)]
detach: bool,
#[arg(long, hide = true)]
quiet_id: bool,
#[arg(long, hide = true)]
preset_id: Option<String>,
#[arg(long)]
rootless: bool,
#[arg(long)]
user: Option<String>,
#[arg(long)]
group: Option<String>,
#[arg(long = "additional-group")]
additional_groups: Vec<String>,
#[arg(long)]
bundle: Option<PathBuf>,
#[arg(long)]
pid_file: Option<PathBuf>,
#[arg(long)]
console_socket: Option<PathBuf>,
#[arg(long, default_value = "none")]
network: NetworkModeArg,
#[arg(long)]
allow_host_network: bool,
#[arg(long)]
allow_degraded_security: bool,
#[arg(long)]
allow_chroot_fallback: bool,
#[arg(long, default_value = "untrusted")]
trust_level: TrustLevel,
#[arg(long)]
proc_rw: bool,
#[arg(short = 'p', long = "publish")]
publish: Vec<String>,
#[arg(long = "context-mode", default_value = "copy")]
context_mode: ContextMode,
#[arg(long, default_value = "agent")]
service_mode: ServiceMode,
#[arg(long)]
rootfs: Option<String>,
#[arg(long = "egress-allow")]
egress_allow: Vec<String>,
#[arg(long = "egress-tcp-port")]
egress_tcp_ports: Vec<u16>,
#[arg(long = "egress-udp-port")]
egress_udp_ports: Vec<u16>,
#[arg(long)]
dns: Vec<String>,
#[arg(long = "nat-backend", default_value = "auto")]
nat_backend: NatBackend,
#[arg(long = "health-cmd")]
health_cmd: Option<String>,
#[arg(long = "health-interval")]
health_interval: Option<u64>,
#[arg(long = "health-retries")]
health_retries: Option<u32>,
#[arg(long = "health-start-period")]
health_start_period: Option<u64>,
#[arg(long = "secret")]
secrets: Vec<String>,
#[arg(long = "systemd-credential")]
systemd_credentials: Vec<String>,
#[arg(long = "volume")]
volumes: Vec<String>,
#[arg(long = "tmpfs")]
tmpfs: Vec<String>,
#[arg(short = 'e', long = "env")]
env_vars: Vec<String>,
#[arg(long)]
sd_notify: bool,
#[arg(long = "readiness-exec")]
readiness_exec: Option<String>,
#[arg(long = "readiness-tcp")]
readiness_tcp: Option<u16>,
#[arg(long = "readiness-sd-notify")]
readiness_sd_notify: bool,
#[arg(long = "seccomp-profile")]
seccomp_profile: Option<String>,
#[arg(long = "seccomp-profile-sha256")]
seccomp_profile_sha256: Option<String>,
#[arg(long = "seccomp-mode", default_value = "enforce")]
seccomp_mode: SeccompMode,
#[arg(long = "seccomp-log")]
seccomp_log: Option<String>,
#[arg(long = "seccomp-log-denied")]
seccomp_log_denied: bool,
#[arg(long = "seccomp-allow")]
seccomp_allow: Vec<String>,
#[arg(long = "caps-policy")]
caps_policy: Option<String>,
#[arg(long = "caps-policy-sha256")]
caps_policy_sha256: Option<String>,
#[arg(long = "landlock-policy")]
landlock_policy: Option<String>,
#[arg(long = "landlock-policy-sha256")]
landlock_policy_sha256: Option<String>,
#[arg(long = "verify-context-integrity")]
verify_context_integrity: bool,
#[arg(long = "verify-rootfs-attestation")]
verify_rootfs_attestation: bool,
#[arg(long = "require-kernel-lockdown")]
require_kernel_lockdown: Option<KernelLockdownMode>,
#[arg(long = "gvisor-platform", default_value = "systrap")]
gvisor_platform: GVisorPlatform,
#[arg(long = "time-namespace")]
time_namespace: bool,
#[arg(long = "disable-cgroup-namespace")]
disable_cgroup_namespace: bool,
#[arg(long = "hooks")]
hooks: Option<String>,
#[arg(long = "topology-config-hash", hide = true)]
topology_config_hash: Option<u64>,
#[arg(last = true, required = true)]
command: Vec<String>,
},
State {
container: Option<String>,
#[arg(short, long)]
all: bool,
},
Stats {
container_id: Option<String>,
},
Stop {
container: String,
#[arg(short, long, default_value = "10")]
timeout: u64,
},
Start {
container: String,
},
Delete {
container: String,
#[arg(short, long)]
force: bool,
},
Kill {
container: String,
#[arg(default_value = "KILL")]
signal: String,
},
Attach {
container: String,
#[arg(last = true)]
command: Vec<String>,
},
Checkpoint {
container: String,
#[arg(short, long)]
output: String,
#[arg(long)]
leave_running: bool,
},
Restore {
#[arg(short, long)]
input: String,
},
Logs {
container: String,
#[arg(short, long)]
follow: bool,
#[arg(short = 'n', long)]
lines: Option<u64>,
},
#[command(subcommand)]
Compose(ComposeCommands),
#[command(subcommand)]
Seccomp(SeccompCommands),
}
#[derive(Parser, Debug)]
enum SeccompCommands {
Generate {
trace_file: String,
#[arg(short, long)]
output: Option<String>,
},
}
#[derive(Parser, Debug)]
enum ComposeCommands {
Up {
#[arg(short, long)]
file: String,
#[arg(long, default_value = "10")]
timeout: u64,
},
Down {
#[arg(short, long)]
file: String,
#[arg(long, default_value = "10")]
timeout: u64,
},
State {
#[arg(short, long)]
file: String,
},
Plan {
#[arg(short, long)]
file: String,
},
Validate {
#[arg(short, long)]
file: String,
},
}
fn truncate_id(id: &str) -> &str {
if id.len() > 12 {
&id[..12]
} else {
id
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
let state_root = cli.root.clone();
nucleus::telemetry::init_tracing();
match cli.command {
Commands::State { container, all } => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
if let Some(ref id) = container {
let state = state_mgr.resolve_container(id)?;
println!("{}", serde_json::to_string_pretty(&state.oci_state())?);
return Ok(());
}
let states = if all {
state_mgr.list_states()?
} else {
state_mgr.list_running()?
};
if states.is_empty() {
println!("No containers found");
return Ok(());
}
println!(
"{:<15} {:<20} {:<10} {:<10} {:<10} {:<10} COMMAND",
"CONTAINER ID", "NAME", "PID", "STATUS", "RUNTIME", "ROOTLESS"
);
for state in states {
let status = if state.is_running() {
state.status.to_string()
} else {
OciStatus::Stopped.to_string()
};
let runtime = if state.using_gvisor {
"gvisor"
} else {
"native"
};
let rootless = if state.rootless { "yes" } else { "no" };
let command = state.command.join(" ");
let command_display = if command.len() > 40 {
format!("{}...", &command[..37])
} else {
command
};
let id_display = truncate_id(&state.id);
let name_display = if state.name.len() > 18 {
format!("{}...", &state.name[..15])
} else {
state.name.clone()
};
println!(
"{:<15} {:<20} {:<10} {:<10} {:<10} {:<10} {}",
id_display, name_display, state.pid, status, runtime, rootless, command_display
);
}
Ok(())
}
Commands::Stats { container_id } => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let states = if let Some(ref id) = container_id {
vec![state_mgr.resolve_container(id)?]
} else {
state_mgr.list_running()?
};
if states.is_empty() {
println!("No running containers found");
return Ok(());
}
println!(
"{:<15} {:<10} {:<15} {:<15} {:<10} {:<10} {:<10}",
"CONTAINER ID", "CPU TIME", "MEM USAGE", "MEM LIMIT", "MEM %", "SWAP", "PIDS"
);
for state in states {
if !state.is_running() {
continue;
}
if let Some(cgroup_path) = &state.cgroup_path {
match ResourceStats::from_cgroup(cgroup_path) {
Ok(stats) => {
let mem_usage = ResourceStats::format_memory(stats.memory_usage);
let mem_limit = if stats.memory_limit > 0 {
ResourceStats::format_memory(stats.memory_limit)
} else {
"unlimited".to_string()
};
let cpu_time = ResourceStats::format_cpu_time(stats.cpu_usage_ns);
let swap_usage = ResourceStats::format_memory(stats.memory_swap_usage);
let id_display = if state.id.len() > 12 {
&state.id[..12]
} else {
&state.id
};
println!(
"{:<15} {:<10} {:<15} {:<15} {:<10.2} {:<10} {:<10}",
id_display,
cpu_time,
mem_usage,
mem_limit,
stats.memory_percent,
swap_usage,
stats.pid_count
);
}
Err(e) => {
eprintln!("Failed to read stats for {}: {}", state.id, e);
}
}
} else {
eprintln!("No cgroup path for container {}", state.id);
}
}
Ok(())
}
Commands::Stop { container, timeout } => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let state = state_mgr.resolve_container(&container)?;
ContainerLifecycle::stop(&state, timeout)?;
println!("{}", state.id);
Ok(())
}
Commands::Start { container } => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let state = state_mgr.resolve_container(&container)?;
if state.status != OciStatus::Created {
return Err(NucleusError::InvalidStateTransition {
from: state.status.to_string(),
to: "created".to_string(),
});
}
Container::trigger_start(&state.id, state_root.clone())?;
println!("{}", state.id);
Ok(())
}
Commands::Delete { container, force } => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let state = state_mgr.resolve_container(&container)?;
ContainerLifecycle::remove(&state_mgr, &state, force)?;
println!("{}", state.id);
Ok(())
}
Commands::Kill { container, signal } => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let state = state_mgr.resolve_container(&container)?;
let sig = parse_signal(&signal)?;
ContainerLifecycle::kill_container(&state, sig)?;
println!("{}", state.id);
Ok(())
}
Commands::Attach { container, command } => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let state = state_mgr.resolve_container(&container)?;
let cmd = if command.is_empty() {
vec!["/bin/sh".to_string()]
} else {
command
};
let exit_code = ContainerAttach::attach(&state, cmd)?;
std::process::exit(exit_code);
}
Commands::Logs {
container,
follow,
lines,
} => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let state = state_mgr.resolve_container(&container)?;
let unit_name = format!("nucleus-{}", &state.id[..12.min(state.id.len())]);
let mut cmd = std::process::Command::new("journalctl");
cmd.arg("--unit").arg(&unit_name).arg("--no-pager");
if follow {
cmd.arg("--follow");
}
if let Some(n) = lines {
cmd.arg("-n").arg(n.to_string());
}
let status = cmd.status().map_err(|e| {
NucleusError::ExecError(format!(
"Failed to run journalctl: {}. Is systemd available?",
e
))
})?;
if !status.success() {
return Err(NucleusError::ExecError(format!(
"journalctl exited with status {}",
status
)));
}
Ok(())
}
Commands::Checkpoint {
container,
output,
leave_running,
} => {
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let state = state_mgr.resolve_container(&container)?;
if state.using_gvisor {
return Err(NucleusError::CheckpointError(format!(
"Container {} uses gVisor runtime; CRIU checkpoint is not supported \
(gVisor manages its own sandbox state)",
state.id
)));
}
let mut criu = CriuRuntime::new()?;
criu.checkpoint(&state, &PathBuf::from(&output), leave_running)?;
println!("Checkpoint saved to {}", output);
Ok(())
}
Commands::Restore { input } => {
let mut criu = CriuRuntime::new()?;
let input_path = PathBuf::from(&input);
let pid = criu.restore(&input_path)?;
let metadata = nucleus::checkpoint::CheckpointMetadata::load(&input_path)?;
let new_id = nucleus::container::generate_container_id()?;
let new_name = format!("{}-restored", metadata.container_name);
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let state = ContainerState::new(ContainerStateParams {
id: new_id.clone(),
name: new_name,
pid,
command: metadata.command,
memory_limit: None, cpu_limit: None, using_gvisor: metadata.using_gvisor,
rootless: metadata.rootless,
cgroup_path: None, process_uid: 0,
process_gid: 0,
additional_gids: Vec::new(),
});
state_mgr.save_state(&state)?;
info!(
"Registered restored container {} (was {}, PID {})",
new_id, metadata.container_id, pid
);
println!("{}", new_id);
Ok(())
}
Commands::Create {
name,
context,
memory,
cpus,
cpu_weight,
io_limits,
pids,
memlock,
swap,
hostname,
runtime,
detach,
quiet_id,
preset_id,
rootless,
user,
group,
additional_groups,
bundle,
pid_file,
console_socket,
network,
allow_host_network,
allow_degraded_security,
allow_chroot_fallback,
trust_level,
proc_rw,
publish,
context_mode,
service_mode,
rootfs,
egress_allow,
egress_tcp_ports,
egress_udp_ports,
dns,
nat_backend,
health_cmd,
health_interval,
health_retries,
health_start_period,
secrets,
systemd_credentials,
volumes,
tmpfs,
env_vars,
sd_notify,
readiness_exec,
readiness_tcp,
readiness_sd_notify,
seccomp_profile,
seccomp_profile_sha256,
seccomp_mode,
seccomp_log,
seccomp_log_denied,
seccomp_allow,
caps_policy,
caps_policy_sha256,
landlock_policy,
landlock_policy_sha256,
verify_context_integrity,
verify_rootfs_attestation,
require_kernel_lockdown,
gvisor_platform,
time_namespace,
disable_cgroup_namespace,
hooks,
topology_config_hash,
command,
} => {
if command.is_empty() {
return Err(NucleusError::ConfigError(
"No command specified".to_string(),
));
}
if detach {
let id = nucleus::container::generate_container_id()?;
let exe = std::env::current_exe().map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to resolve current executable for detach re-exec: {}",
e
))
})?;
let raw_args: Vec<String> = std::env::args().collect();
let mut inner_args: Vec<String> = Vec::with_capacity(raw_args.len() + 2);
let separator_pos = raw_args.iter().position(|a| a == "--");
for (i, arg) in raw_args.iter().enumerate().skip(1) {
if separator_pos.map_or(true, |sep| i < sep)
&& (arg == "--detach" || arg == "-d")
{
continue;
}
inner_args.push(arg.clone());
}
if let Some(create_pos) = inner_args
.iter()
.position(|a| a.eq_ignore_ascii_case("create"))
{
inner_args.insert(create_pos + 1, format!("--preset-id={}", id));
inner_args.insert(create_pos + 1, "--quiet-id".to_string());
}
if let Some(ref root) = state_root {
if !raw_args.iter().any(|a| a.starts_with("--root")) {
inner_args.insert(0, root.display().to_string());
inner_args.insert(0, "--root".to_string());
}
}
let unit_name = format!("nucleus-{}", &id[..12]);
let status = std::process::Command::new("systemd-run")
.arg("--unit")
.arg(&unit_name)
.arg("--collect")
.arg("--quiet")
.arg("-p")
.arg("KillMode=mixed")
.arg("-p")
.arg("KillSignal=SIGTERM")
.arg("-p")
.arg("TimeoutStopSec=30")
.arg("--")
.arg(&exe)
.args(&inner_args)
.status()
.map_err(|e| {
NucleusError::ExecError(format!(
"Failed to launch systemd-run for detach: {}. \
Is systemd available?",
e
))
})?;
if !status.success() {
return Err(NucleusError::ExecError(format!(
"systemd-run exited with status {}",
status
)));
}
println!("{}", id);
return Ok(());
}
if let Some(ref n) = name {
validate_container_name(n)?;
}
let mut limits = ResourceLimits::default();
if let Some(mem_str) = memory {
limits = limits.with_memory(&mem_str)?;
info!("Memory limit: {}", mem_str);
}
if let Some(cores) = cpus {
limits = limits.with_cpu_cores(cores)?;
info!("CPU limit: {} cores", cores);
}
if let Some(weight) = cpu_weight {
limits = limits.with_cpu_weight(weight)?;
info!("CPU weight: {}", weight);
}
if let Some(max_pids) = pids {
if max_pids == 0 {
limits.pids_max = None;
info!("PID limit: unlimited");
} else {
limits = limits.with_pids(max_pids)?;
info!("PID limit: {}", max_pids);
}
}
if let Some(ref memlock_str) = memlock {
limits = limits.with_memlock(memlock_str)?;
}
for io_spec in &io_limits {
let io_limit = IoDeviceLimit::parse(io_spec)?;
limits = limits.with_io_limit(io_limit);
info!("I/O limit: {}", io_spec);
}
if swap {
limits = limits.with_swap_enabled();
info!("Swap enabled");
}
let net_mode = match network {
NetworkModeArg::None => NetworkMode::None,
NetworkModeArg::Host => NetworkMode::Host,
NetworkModeArg::Bridge => {
if service_mode == ServiceMode::Production && dns.is_empty() {
return Err(NucleusError::ConfigError(
"Production mode with bridge networking requires explicit --dns servers".to_string()
));
}
let mut bridge_config = if dns.is_empty() {
BridgeConfig::default().with_public_dns()
} else {
BridgeConfig::default().with_dns(dns.clone())
}
.with_nat_backend(nat_backend);
for spec in &publish {
let pf = PortForward::parse(spec)?;
bridge_config.port_forwards.push(pf);
}
NetworkMode::Bridge(bridge_config)
}
};
let mut namespaces = NamespaceConfig::all();
if time_namespace {
namespaces = namespaces.with_time_namespace(true);
}
if disable_cgroup_namespace {
namespaces = namespaces.with_cgroup_namespace(false);
}
let mut config = ContainerConfig::try_new_with_id(preset_id, name, command)?
.with_limits(limits)
.with_namespaces(namespaces)
.with_network(net_mode)
.with_context_mode(context_mode)
.with_allow_host_network(allow_host_network)
.with_allow_degraded_security(allow_degraded_security)
.with_allow_chroot_fallback(allow_chroot_fallback)
.with_trust_level(trust_level)
.with_proc_readonly(!proc_rw)
.with_service_mode(service_mode)
.with_sd_notify(sd_notify)
.with_seccomp_log_denied(seccomp_log_denied)
.with_verify_context_integrity(verify_context_integrity)
.with_verify_rootfs_attestation(verify_rootfs_attestation)
.with_gvisor_platform(gvisor_platform);
if let Some(mode) = require_kernel_lockdown {
config = config.with_required_kernel_lockdown(mode);
}
if let Some(hash) = topology_config_hash {
config = config.with_config_hash(hash);
}
if let Some(ctx) = context {
let canonical_ctx = std::fs::canonicalize(&ctx).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to canonicalize context path '{}': {}",
ctx, e
))
})?;
config = config.with_context(canonical_ctx);
}
if let Some(host) = hostname {
validate_hostname(&host)?;
config = config.with_hostname(Some(host));
}
config = config.apply_runtime_selection(runtime, bundle.is_some())?;
if let Some(ref bundle_dir) = bundle {
config = config.with_bundle_dir(bundle_dir.clone());
}
if let Some(ref socket_path) = console_socket {
let socket = PathBuf::from(socket_path);
let canonical = if let Some(parent) = socket.parent() {
let canon_parent = std::fs::canonicalize(parent).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to canonicalize console socket parent '{}': {}",
parent.display(),
e
))
})?;
canon_parent.join(socket.file_name().unwrap_or_default())
} else {
socket
};
config = config.with_console_socket(canonical);
}
if rootless {
info!("Enabling rootless mode");
config = config.with_rootless();
}
if let Some(identity) =
resolve_process_identity(user.as_deref(), group.as_deref(), &additional_groups)?
{
info!(
"Running workload as uid={} gid={} supplementary_gids={:?}",
identity.uid, identity.gid, identity.additional_gids
);
config = config.with_process_identity(identity);
}
if let Some(rootfs_dir) = rootfs {
config = config.with_rootfs_path(PathBuf::from(rootfs_dir));
}
if let Some(profile_path) = seccomp_profile {
let canonical = std::fs::canonicalize(&profile_path).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to canonicalize seccomp profile path '{}': {}",
profile_path, e
))
})?;
config = config.with_seccomp_profile(canonical);
}
if let Some(sha256) = seccomp_profile_sha256 {
config = config.with_seccomp_profile_sha256(sha256);
}
match seccomp_mode {
SeccompMode::Enforce => {}
SeccompMode::Trace => {
config = config.with_seccomp_mode(SeccompMode::Trace);
if let Some(log_path) = seccomp_log {
config = config.with_seccomp_trace_log(PathBuf::from(log_path));
} else {
return Err(NucleusError::ConfigError(
"--seccomp-log is required when --seccomp-mode=trace".to_string(),
));
}
}
}
if !seccomp_allow.is_empty() {
config = config.with_seccomp_allow_syscalls(seccomp_allow);
}
if let Some(path) = caps_policy {
let canonical = std::fs::canonicalize(&path).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to canonicalize capability policy path '{}': {}",
path, e
))
})?;
config = config.with_caps_policy(canonical);
}
if let Some(sha256) = caps_policy_sha256 {
config = config.with_caps_policy_sha256(sha256);
}
if let Some(path) = landlock_policy {
let canonical = std::fs::canonicalize(&path).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to canonicalize Landlock policy path '{}': {}",
path, e
))
})?;
config = config.with_landlock_policy(canonical);
}
if let Some(sha256) = landlock_policy_sha256 {
config = config.with_landlock_policy_sha256(sha256);
}
if !egress_allow.is_empty() {
let policy = EgressPolicy::default()
.with_allowed_cidrs(egress_allow)
.with_allowed_tcp_ports(egress_tcp_ports)
.with_allowed_udp_ports(egress_udp_ports);
config = config.with_egress_policy(policy);
} else if service_mode == ServiceMode::Production {
config = config.with_egress_policy(EgressPolicy::deny_all());
}
{
let probe_count = readiness_exec.is_some() as u8
+ readiness_tcp.is_some() as u8
+ readiness_sd_notify as u8;
if probe_count > 1 {
return Err(NucleusError::ConfigError(
"Only one readiness probe type may be set \
(--readiness-exec, --readiness-tcp, --readiness-sd-notify)"
.to_string(),
));
}
if let Some(cmd) = readiness_exec {
config = config.with_readiness_probe(ReadinessProbe::Exec {
command: vec!["/bin/sh".to_string(), "-c".to_string(), cmd],
});
} else if let Some(port) = readiness_tcp {
config = config.with_readiness_probe(ReadinessProbe::TcpPort(port));
} else if readiness_sd_notify {
config = config.with_readiness_probe(ReadinessProbe::SdNotify);
}
}
if let Some(cmd) = health_cmd {
let hc = HealthCheck {
command: vec!["/bin/sh".to_string(), "-c".to_string(), cmd],
interval: std::time::Duration::from_secs(health_interval.unwrap_or(30)),
retries: health_retries.unwrap_or(3),
start_period: std::time::Duration::from_secs(health_start_period.unwrap_or(5)),
timeout: std::time::Duration::from_secs(5),
};
config = config.with_health_check(hc);
}
for spec in &secrets {
let parts: Vec<&str> = spec.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(NucleusError::ConfigError(format!(
"Invalid secret format '{}', expected SOURCE:DEST",
spec
)));
}
let source = std::fs::canonicalize(parts[0]).map_err(|e| {
NucleusError::ConfigError(format!(
"Secret source '{}' cannot be resolved: {}",
parts[0], e
))
})?;
config = config.with_secret(SecretMount {
source,
dest: PathBuf::from(parts[1]),
mode: 0o400,
});
}
for spec in &systemd_credentials {
let parts: Vec<&str> = spec.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(NucleusError::ConfigError(format!(
"Invalid systemd credential format '{}', expected NAME:DEST",
spec
)));
}
let source = resolve_systemd_credential_source(parts[0])?;
config = config.with_secret(SecretMount {
source,
dest: PathBuf::from(parts[1]),
mode: 0o400,
});
}
for spec in &volumes {
let parts: Vec<&str> = spec.split(':').collect();
let (source_raw, dest_raw, read_only) = match parts.as_slice() {
[source, dest] => (*source, *dest, false),
[source, dest, mode] if *mode == "ro" => (*source, *dest, true),
[source, dest, mode] if *mode == "rw" => (*source, *dest, false),
_ => {
return Err(NucleusError::ConfigError(format!(
"Invalid volume format '{}', expected SOURCE:DEST[:ro|rw]",
spec
)));
}
};
let source = std::fs::canonicalize(source_raw).map_err(|e| {
NucleusError::ConfigError(format!(
"Volume source '{}' cannot be resolved: {}",
source_raw, e
))
})?;
config = config.with_volume(VolumeMount {
source: VolumeSource::Bind { source },
dest: PathBuf::from(dest_raw),
read_only,
});
}
for spec in &tmpfs {
let parts: Vec<&str> = spec.split(':').collect();
let is_mode = |s: &str| s == "ro" || s == "rw";
let is_size = |s: &str| {
s.trim_end_matches(|c: char| c.is_ascii_alphabetic())
.parse::<u64>()
.is_ok()
};
let (dest, size, read_only) = match parts.as_slice() {
[dest] => (*dest, None, false),
[dest, flag] if is_mode(flag) => (*dest, None, *flag == "ro"),
[dest, sz] if is_size(sz) => (*dest, Some((*sz).to_string()), false),
[dest, sz, flag] if is_size(sz) && is_mode(flag) => {
(*dest, Some((*sz).to_string()), *flag == "ro")
}
[_dest, bad] => {
return Err(NucleusError::ConfigError(format!(
"Invalid tmpfs second field '{}' in '{}': \
expected a size (e.g. 64M) or mode (ro|rw)",
bad, spec
)));
}
_ => {
return Err(NucleusError::ConfigError(format!(
"Invalid tmpfs format '{}', expected DEST[:SIZE][:ro|rw]",
spec
)));
}
};
if dest.is_empty() {
return Err(NucleusError::ConfigError(format!(
"Invalid tmpfs format '{}', expected DEST[:SIZE][:ro|rw]",
spec
)));
}
config = config.with_volume(VolumeMount {
source: VolumeSource::Tmpfs { size },
dest: PathBuf::from(dest),
read_only,
});
}
const DANGEROUS_ENV_VARS: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"LD_DEBUG",
"LD_PROFILE",
];
for spec in &env_vars {
if let Some((key, value)) = spec.split_once('=') {
if DANGEROUS_ENV_VARS.contains(&key) {
return Err(NucleusError::ConfigError(format!(
"Environment variable '{}' is blocked (dynamic linker injection risk). \
This restriction applies in all modes.",
key
)));
}
config = config.with_env(key.to_string(), value.to_string());
} else {
return Err(NucleusError::ConfigError(format!(
"Invalid env var format '{}', expected KEY=VALUE",
spec
)));
}
}
if let Some(hooks_path) = hooks {
let hooks_path = std::fs::canonicalize(&hooks_path).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to canonicalize hooks path '{}': {}",
hooks_path, e
))
})?;
let hooks_json = std::fs::read_to_string(&hooks_path).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to read hooks file '{}': {}",
hooks_path.display(),
e
))
})?;
let oci_hooks: nucleus::security::OciHooks = serde_json::from_str(&hooks_json)
.map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to parse hooks file '{}': {}",
hooks_path.display(),
e
))
})?;
config.hooks = Some(oci_hooks);
}
if let Some(ref pid_path) = pid_file {
let pid = PathBuf::from(pid_path);
let canonical = if let Some(parent) = pid.parent() {
let canon_parent = std::fs::canonicalize(parent).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to canonicalize PID file parent '{}': {}",
parent.display(),
e
))
})?;
canon_parent.join(pid.file_name().unwrap_or_default())
} else {
pid
};
config = config.with_pid_file(canonical);
}
if let Some(ref root) = state_root {
config = config.with_state_root(root.clone());
}
if !quiet_id {
println!("{}", config.id);
}
let container = Container::new(config);
let exit_code = container.run()?;
std::process::exit(exit_code);
}
Commands::Compose(compose_cmd) => match compose_cmd {
ComposeCommands::Validate { file } => {
let config = TopologyConfig::from_file(&PathBuf::from(&file))?;
config.validate()?;
let graph = DependencyGraph::resolve(&config)?;
println!("Topology '{}' is valid", config.name);
println!(
"Services ({}): {}",
config.services.len(),
config
.services
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
println!("Startup order: {}", graph.startup_order.join(" -> "));
Ok(())
}
ComposeCommands::Plan { file } => {
let config = TopologyConfig::from_file(&PathBuf::from(&file))?;
config.validate()?;
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let plan = plan_reconcile(&config, &state_mgr)?;
println!("Reconciliation plan for topology '{}':", config.name);
for (name, action) in &plan.actions {
let action_str = match action {
ReconcileAction::NoChange => "no change",
ReconcileAction::Start => "start",
ReconcileAction::Restart => "restart",
ReconcileAction::Stop => "stop",
};
println!(" {} -> {}", name, action_str);
}
Ok(())
}
ComposeCommands::Up { file, timeout } => {
let config = TopologyConfig::from_file(&PathBuf::from(&file))?;
config.validate()?;
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let plan = plan_reconcile(&config, &state_mgr)?;
println!("Bringing up topology '{}'...", config.name);
execute_reconcile(&config, &plan, &state_mgr, timeout, state_root.as_deref())?;
println!("Topology '{}' is up", config.name);
Ok(())
}
ComposeCommands::Down { file, timeout } => {
let config = TopologyConfig::from_file(&PathBuf::from(&file))?;
config.validate()?;
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
let graph = DependencyGraph::resolve(&config)?;
println!("Tearing down topology '{}'...", config.name);
for service_name in graph.shutdown_order() {
let container_name = format!("{}-{}", config.name, service_name);
if let Ok(state) = state_mgr.resolve_container(&container_name) {
if state.is_running() {
println!("Stopping {}...", container_name);
ContainerLifecycle::stop(&state, timeout)?;
}
}
}
println!("Topology '{}' is down", config.name);
Ok(())
}
ComposeCommands::State { file } => {
let config = TopologyConfig::from_file(&PathBuf::from(&file))?;
let state_mgr = ContainerStateManager::new_with_root(state_root.clone())?;
println!(
"{:<25} {:<10} {:<10} {:<30}",
"SERVICE", "STATUS", "PID", "COMMAND"
);
for service_name in config.services.keys() {
let container_name = format!("{}-{}", config.name, service_name);
match state_mgr.resolve_container(&container_name) {
Ok(state) => {
let status = if state.is_running() {
state.status.to_string()
} else {
OciStatus::Stopped.to_string()
};
let cmd = state.command.join(" ");
let cmd_display = if cmd.len() > 28 {
format!("{}...", &cmd[..25])
} else {
cmd
};
println!(
"{:<25} {:<10} {:<10} {:<30}",
service_name, status, state.pid, cmd_display
);
}
Err(_) => {
println!(
"{:<25} {:<10} {:<10} {:<30}",
service_name, "Not found", "-", "-"
);
}
}
}
Ok(())
}
},
Commands::Seccomp(seccomp_cmd) => match seccomp_cmd {
SeccompCommands::Generate { trace_file, output } => {
let profile = nucleus::security::generate_from_trace(&PathBuf::from(&trace_file))?;
let json = serde_json::to_string_pretty(&profile)?;
if let Some(out_path) = output {
std::fs::write(&out_path, &json)?;
eprintln!("Wrote seccomp profile to {}", out_path);
} else {
println!("{}", json);
}
eprintln!(
"Profile contains {} syscalls",
profile.syscalls.first().map(|g| g.names.len()).unwrap_or(0)
);
Ok(())
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn test_native_runtime_disables_gvisor() {
let config = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
let config = config
.apply_runtime_selection(RuntimeSelection::Native, false)
.unwrap();
assert!(
!config.use_gvisor,
"native runtime selection must disable gVisor"
);
assert_eq!(
config.trust_level,
TrustLevel::Trusted,
"native runtime must set TrustLevel::Trusted"
);
}
#[test]
fn test_native_runtime_rejects_bundle_flag() {
let config = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
let err = config
.apply_runtime_selection(RuntimeSelection::Native, true)
.unwrap_err();
assert!(
err.to_string().contains("requires gVisor"),
"native runtime with --bundle must be rejected explicitly"
);
}
#[test]
fn test_resolve_systemd_credential_source() {
let _guard = env_lock().lock().unwrap();
let dir = tempfile::TempDir::new().unwrap();
let cred = dir.path().join("db-password");
std::fs::write(&cred, "secret").unwrap();
std::env::set_var("CREDENTIALS_DIRECTORY", dir.path());
let resolved = resolve_systemd_credential_source("db-password").unwrap();
assert_eq!(resolved, std::fs::canonicalize(&cred).unwrap());
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
#[test]
fn test_resolve_systemd_credential_rejects_path_traversal() {
let _guard = env_lock().lock().unwrap();
let dir = tempfile::TempDir::new().unwrap();
std::env::set_var("CREDENTIALS_DIRECTORY", dir.path());
let err = resolve_systemd_credential_source("../db-password").unwrap_err();
assert!(err.to_string().contains("Invalid systemd credential name"));
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
#[test]
fn test_resolve_process_identity_named_user_defaults_primary_group() {
let identity = resolve_process_identity(Some("root"), None, &[])
.unwrap()
.unwrap();
assert_eq!(identity.uid, 0);
assert_eq!(identity.gid, 0);
assert!(identity.additional_gids.is_empty());
}
#[test]
fn test_resolve_process_identity_numeric_user_requires_group() {
let err = resolve_process_identity(Some("1000"), None, &[]).unwrap_err();
assert!(err.to_string().contains("explicit --group"));
}
#[test]
fn test_resolve_process_identity_deduplicates_additional_groups() {
let identity = resolve_process_identity(
Some("123"),
Some("456"),
&["456".to_string(), "789".to_string(), "789".to_string()],
)
.unwrap()
.unwrap();
assert_eq!(identity.uid, 123);
assert_eq!(identity.gid, 456);
assert_eq!(identity.additional_gids, vec![789]);
}
}