use clap::Parser;
use nucleus::checkpoint::{CheckpointMetadata, 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::{Cgroup, IoDeviceLimit, ResourceLimits, ResourceStats};
use nucleus::security::GVisorPlatform;
use nucleus::topology::{
execute_reconcile, plan_reconcile, DependencyGraph, ReconcileAction, TopologyConfig,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::ExitCode;
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,
}))
}
fn exit_code_from_i32(code: i32) -> ExitCode {
u8::try_from(code)
.map(ExitCode::from)
.unwrap_or(ExitCode::FAILURE)
}
fn restore_checkpoint_cgroup(
container_id: &str,
pid: u32,
metadata: &CheckpointMetadata,
) -> Result<(Option<String>, Option<u64>, Option<u64>)> {
let Some(resource_limits) = metadata.resource_limits.as_ref() else {
return Ok((None, None, None));
};
let cgroup_name = format!("nucleus-{}", container_id);
let mut cgroup = Cgroup::create(&cgroup_name).map_err(|e| {
NucleusError::CheckpointError(format!("Failed to recreate cgroup on restore: {}", e))
})?;
let limits = resource_limits.to_resource_limits();
if let Err(e) = cgroup.set_limits(&limits) {
let _ = cgroup.cleanup();
return Err(NucleusError::CheckpointError(format!(
"Failed to restore cgroup limits for restored container: {}",
e
)));
}
if let Err(e) = cgroup.attach_process(pid) {
let _ = cgroup.cleanup();
return Err(NucleusError::CheckpointError(format!(
"Failed to attach restored process {} to cgroup: {}",
pid, e
)));
}
Ok((
Some(cgroup.path().display().to_string()),
resource_limits.memory_bytes,
resource_limits.cpu_limit_millicores(),
))
}
#[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, hide = true)]
detached_config_json: 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_unless_present = "detached_config_json")]
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,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DetachedCreateRequest {
name: Option<String>,
context: Option<String>,
memory: Option<String>,
cpus: Option<f64>,
hostname: Option<String>,
cpu_weight: Option<u64>,
io_limits: Vec<String>,
pids: Option<u64>,
memlock: Option<String>,
swap: bool,
runtime: RuntimeSelection,
detach: bool,
quiet_id: bool,
preset_id: Option<String>,
rootless: bool,
user: Option<String>,
group: Option<String>,
additional_groups: Vec<String>,
bundle: Option<PathBuf>,
pid_file: Option<PathBuf>,
console_socket: Option<PathBuf>,
network: NetworkModeArg,
allow_host_network: bool,
allow_degraded_security: bool,
allow_chroot_fallback: bool,
trust_level: TrustLevel,
proc_rw: bool,
publish: Vec<String>,
context_mode: ContextMode,
service_mode: ServiceMode,
rootfs: Option<String>,
egress_allow: Vec<String>,
egress_tcp_ports: Vec<u16>,
egress_udp_ports: Vec<u16>,
dns: Vec<String>,
nat_backend: NatBackend,
health_cmd: Option<String>,
health_interval: Option<u64>,
health_retries: Option<u32>,
health_start_period: Option<u64>,
secrets: Vec<String>,
systemd_credentials: Vec<String>,
volumes: Vec<String>,
tmpfs: Vec<String>,
env_vars: Vec<String>,
sd_notify: bool,
readiness_exec: Option<String>,
readiness_tcp: Option<u16>,
readiness_sd_notify: bool,
seccomp_profile: Option<String>,
seccomp_profile_sha256: Option<String>,
seccomp_mode: SeccompMode,
seccomp_log: Option<String>,
seccomp_log_denied: bool,
seccomp_allow: Vec<String>,
caps_policy: Option<String>,
caps_policy_sha256: Option<String>,
landlock_policy: Option<String>,
landlock_policy_sha256: Option<String>,
verify_context_integrity: bool,
verify_rootfs_attestation: bool,
require_kernel_lockdown: Option<KernelLockdownMode>,
gvisor_platform: GVisorPlatform,
time_namespace: bool,
disable_cgroup_namespace: bool,
hooks: Option<String>,
topology_config_hash: Option<u64>,
command: Vec<String>,
}
fn truncate_id(id: &str) -> &str {
if id.len() > 12 {
&id[..12]
} else {
id
}
}
fn main() -> ExitCode {
match try_main() {
Ok(code) => exit_code_from_i32(code),
Err(err) => {
eprintln!("Error: {}", err);
ExitCode::FAILURE
}
}
}
fn try_main() -> Result<i32> {
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(0);
}
let states = if all {
state_mgr.list_states()?
} else {
state_mgr.list_running()?
};
if states.is_empty() {
println!("No containers found");
return Ok(0);
}
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(0)
}
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(0);
}
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(0)
}
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(0)
}
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(0)
}
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(0)
}
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(0)
}
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)?;
Ok(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(0)
}
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(0)
}
Commands::Restore { input } => {
let mut criu = CriuRuntime::new()?;
let input_path = PathBuf::from(&input);
let metadata = CheckpointMetadata::load(&input_path)?;
let pid = criu.restore(&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 (cgroup_path, memory_limit, cpu_limit) =
match restore_checkpoint_cgroup(&new_id, pid, &metadata) {
Ok(restored) => restored,
Err(err) => {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(pid as i32),
nix::sys::signal::Signal::SIGKILL,
);
return Err(err);
}
};
let state = ContainerState::new(ContainerStateParams {
id: new_id.clone(),
name: new_name,
pid,
command: metadata.command,
memory_limit,
cpu_limit,
using_gvisor: metadata.using_gvisor,
rootless: metadata.rootless,
cgroup_path,
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(0)
}
Commands::Create {
name,
context,
memory,
cpus,
cpu_weight,
io_limits,
pids,
memlock,
swap,
hostname,
runtime,
detach,
quiet_id,
preset_id,
detached_config_json,
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,
} => {
let create_request = if let Some(serialized) = detached_config_json {
let request: DetachedCreateRequest =
serde_json::from_str(&serialized).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to deserialize detached create request: {}",
e
))
})?;
if request.detach {
return Err(NucleusError::ConfigError(
"Detached create request must clear --detach before re-exec".to_string(),
));
}
request
} else {
DetachedCreateRequest {
name,
context,
memory,
cpus,
hostname,
cpu_weight,
io_limits,
pids,
memlock,
swap,
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 create_request.command.is_empty() {
return Err(NucleusError::ConfigError(
"No command specified".to_string(),
));
}
if create_request.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 mut inner_request = create_request.clone();
inner_request.detach = false;
inner_request.quiet_id = true;
inner_request.preset_id = Some(id.clone());
let serialized = serde_json::to_string(&inner_request).map_err(|e| {
NucleusError::ConfigError(format!(
"Failed to serialize detached create request: {}",
e
))
})?;
let mut inner_args: Vec<String> = Vec::new();
if let Some(ref root) = state_root {
inner_args.push("--root".to_string());
inner_args.push(root.display().to_string());
}
inner_args.push("create".to_string());
inner_args.push("--detached-config-json".to_string());
inner_args.push(serialized);
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(0);
}
let DetachedCreateRequest {
name,
context,
memory,
cpus,
hostname,
cpu_weight,
io_limits,
pids,
memlock,
swap,
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,
} = create_request;
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()?;
Ok(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(0)
}
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(0)
}
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(0)
}
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(0)
}
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(0)
}
},
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(0)
}
},
}
}
#[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_detached_create_request_roundtrips_command_tokens() {
let request = DetachedCreateRequest {
name: Some("svc".to_string()),
context: Some("/tmp/context".to_string()),
memory: Some("512M".to_string()),
cpus: Some(1.5),
hostname: Some("svc".to_string()),
cpu_weight: Some(100),
io_limits: vec!["8:0 riops=1000".to_string()],
pids: Some(64),
memlock: Some("8M".to_string()),
swap: true,
runtime: RuntimeSelection::Native,
detach: false,
quiet_id: true,
preset_id: Some("0123456789abcdef0123456789abcdef".to_string()),
rootless: true,
user: Some("1000".to_string()),
group: Some("1000".to_string()),
additional_groups: vec!["27".to_string()],
bundle: Some(PathBuf::from("/tmp/bundle")),
pid_file: Some(PathBuf::from("/tmp/pid")),
console_socket: Some(PathBuf::from("/tmp/console.sock")),
network: NetworkModeArg::Bridge,
allow_host_network: false,
allow_degraded_security: true,
allow_chroot_fallback: true,
trust_level: TrustLevel::Trusted,
proc_rw: true,
publish: vec!["127.0.0.1:8080:80/tcp".to_string()],
context_mode: ContextMode::BindMount,
service_mode: ServiceMode::Production,
rootfs: Some("/nix/store/rootfs".to_string()),
egress_allow: vec!["10.0.0.0/8".to_string()],
egress_tcp_ports: vec![443],
egress_udp_ports: vec![53],
dns: vec!["8.8.8.8".to_string()],
nat_backend: NatBackend::Kernel,
health_cmd: Some("curl -f http://127.0.0.1/health".to_string()),
health_interval: Some(5),
health_retries: Some(2),
health_start_period: Some(1),
secrets: vec!["/tmp/src:/run/secrets/token".to_string()],
systemd_credentials: vec!["db-password:/run/secrets/db".to_string()],
volumes: vec!["/tmp/data:/data:ro".to_string()],
tmpfs: vec!["/cache:64M:rw".to_string()],
env_vars: vec!["A=B".to_string()],
sd_notify: true,
readiness_exec: Some("test -f /tmp/ready".to_string()),
readiness_tcp: None,
readiness_sd_notify: false,
seccomp_profile: Some("/tmp/seccomp.json".to_string()),
seccomp_profile_sha256: Some("abcd".to_string()),
seccomp_mode: SeccompMode::Enforce,
seccomp_log: None,
seccomp_log_denied: false,
seccomp_allow: vec!["io_uring_setup".to_string(), "--detach".to_string()],
caps_policy: Some("/tmp/caps.toml".to_string()),
caps_policy_sha256: Some("efgh".to_string()),
landlock_policy: Some("/tmp/landlock.toml".to_string()),
landlock_policy_sha256: Some("ijkl".to_string()),
verify_context_integrity: true,
verify_rootfs_attestation: true,
require_kernel_lockdown: Some(KernelLockdownMode::Integrity),
gvisor_platform: GVisorPlatform::Ptrace,
time_namespace: true,
disable_cgroup_namespace: true,
hooks: Some("/tmp/hooks.json".to_string()),
topology_config_hash: Some(42),
command: vec![
"/bin/sh".to_string(),
"-ceu".to_string(),
"printf '%s\n' create --detach".to_string(),
"--".to_string(),
"-d".to_string(),
],
};
let encoded = serde_json::to_string(&request).unwrap();
let decoded: DetachedCreateRequest = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.command, request.command);
assert_eq!(decoded.publish, request.publish);
assert_eq!(decoded.seccomp_allow, request.seccomp_allow);
}
#[test]
fn test_create_hidden_detached_config_skips_command_requirement() {
let encoded = serde_json::to_string(&DetachedCreateRequest {
name: None,
context: None,
memory: None,
cpus: None,
hostname: None,
cpu_weight: None,
io_limits: Vec::new(),
pids: None,
memlock: None,
swap: false,
runtime: RuntimeSelection::GVisor,
detach: false,
quiet_id: false,
preset_id: None,
rootless: false,
user: None,
group: None,
additional_groups: Vec::new(),
bundle: None,
pid_file: None,
console_socket: None,
network: NetworkModeArg::None,
allow_host_network: false,
allow_degraded_security: false,
allow_chroot_fallback: false,
trust_level: TrustLevel::Untrusted,
proc_rw: false,
publish: Vec::new(),
context_mode: ContextMode::Copy,
service_mode: ServiceMode::Agent,
rootfs: None,
egress_allow: Vec::new(),
egress_tcp_ports: Vec::new(),
egress_udp_ports: Vec::new(),
dns: Vec::new(),
nat_backend: NatBackend::Auto,
health_cmd: None,
health_interval: None,
health_retries: None,
health_start_period: None,
secrets: Vec::new(),
systemd_credentials: Vec::new(),
volumes: Vec::new(),
tmpfs: Vec::new(),
env_vars: Vec::new(),
sd_notify: false,
readiness_exec: None,
readiness_tcp: None,
readiness_sd_notify: false,
seccomp_profile: None,
seccomp_profile_sha256: None,
seccomp_mode: SeccompMode::Enforce,
seccomp_log: None,
seccomp_log_denied: false,
seccomp_allow: Vec::new(),
caps_policy: None,
caps_policy_sha256: None,
landlock_policy: None,
landlock_policy_sha256: None,
verify_context_integrity: false,
verify_rootfs_attestation: false,
require_kernel_lockdown: None,
gvisor_platform: GVisorPlatform::Systrap,
time_namespace: false,
disable_cgroup_namespace: false,
hooks: None,
topology_config_hash: None,
command: vec!["/bin/true".to_string()],
})
.unwrap();
let cli =
Cli::try_parse_from(["nucleus", "create", "--detached-config-json", &encoded]).unwrap();
match cli.command {
Commands::Create {
detached_config_json,
command,
..
} => {
assert!(command.is_empty());
assert_eq!(detached_config_json, Some(encoded));
}
_ => panic!("expected create command"),
}
}
#[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]);
}
}