use std::{path::PathBuf, process::Stdio, sync::Arc};
use tokio::process::Command;
use tracing::debug;
use crate::{
config::{EffectiveConfig, data_dir, sanitize_package_name},
error::NpxcError,
paths::{SessionState, validate_path},
};
use super::{network::ManagedNetwork, proc::ContainerProcess};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountMode {
Ro,
Rw,
}
impl MountMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
MountMode::Ro => "ro",
MountMode::Rw => "rw",
}
}
#[must_use]
pub fn from_config_str(s: &str) -> Self {
if s == "rw" { Self::Rw } else { Self::Ro }
}
}
#[derive(Debug, Clone)]
pub struct Mount {
pub host: PathBuf,
pub container: String,
pub mode: MountMode,
}
#[derive(Debug, Default)]
pub struct LaunchPlan {
pub mounts: Vec<Mount>,
pub env_literal: Vec<(String, String)>,
pub env_passthrough: Vec<String>,
pub args: Vec<String>,
pub cap_add: Vec<String>,
}
impl LaunchPlan {
pub fn build(
pkg_name: &str,
effective: &EffectiveConfig,
cwd: &std::path::Path,
args: Vec<String>,
no_isolate: bool,
) -> Result<Self, NpxcError> {
let mut plan = Self {
args,
env_literal: effective
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
env_passthrough: effective.env_passthrough.clone(),
mounts: Vec::new(),
cap_add: Vec::new(),
};
for mc in &effective.mounts {
let host_path = std::path::Path::new(&mc.host);
let abs_host = if host_path.is_absolute() {
host_path.to_path_buf()
} else {
cwd.join(host_path)
};
let canonical = validate_path(abs_host.to_str().unwrap_or(&mc.host), cwd)?;
plan.mounts.push(Mount {
host: canonical,
container: mc.container.clone(),
mode: MountMode::from_config_str(&mc.mode),
});
}
if effective.storage.as_ref().is_some_and(|s| s.persist) {
let sanitized = sanitize_package_name(pkg_name);
let host_dir = data_dir().join("packages").join(&sanitized);
std::fs::create_dir_all(&host_dir)?;
plan.mounts.push(Mount {
host: host_dir,
container: "/data".to_string(),
mode: MountMode::Rw,
});
}
if no_isolate {
let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
plan.mounts.push(Mount {
container: canonical.to_string_lossy().into_owned(),
host: canonical,
mode: MountMode::Ro,
});
}
Ok(plan)
}
}
pub struct Session {
pub state: Arc<SessionState>,
pub session_dir: PathBuf,
container: Option<ContainerProcess>,
network: Option<ManagedNetwork>,
container_cli: String,
container_name: String,
cleaned: bool,
}
impl Session {
pub fn start(
pkg_name: &str,
image_tag: &str,
config: &EffectiveConfig,
network_arg: &str,
plan: &LaunchPlan,
session_dir_parent: Option<&std::path::Path>,
) -> Result<Self, NpxcError> {
let sanitized = sanitize_package_name(pkg_name);
let parent =
session_dir_parent.map_or_else(std::env::temp_dir, std::path::Path::to_path_buf);
let session_dir = tempfile::Builder::new()
.prefix(&format!("npxc-{sanitized}-"))
.tempdir_in(&parent)
.map_err(NpxcError::Io)?
.keep();
let container_name = format!("npxc-{sanitized}-{}", std::process::id());
let mut cmd = Command::new(&config.container_cli);
cmd.args([
"run",
"--rm",
"-i",
"--progress",
"none",
"--name",
&container_name,
"--network",
network_arg,
"--read-only",
"--tmpfs",
"/tmp",
"--tmpfs",
"/run",
"--cap-drop",
"ALL",
"-m",
&config.memory,
"-c",
&config.cpus,
]);
let workspace_spec = format!("{}:/workspace:{}", session_dir.display(), config.mount_mode);
cmd.args(["-v", &workspace_spec]);
for mount in &plan.mounts {
let spec = format!(
"{}:{}:{}",
mount.host.display(),
mount.container,
mount.mode.as_str(),
);
cmd.args(["-v", &spec]);
}
for (k, v) in &plan.env_literal {
cmd.args(["-e", &format!("{k}={v}")]);
}
for k in &plan.env_passthrough {
cmd.args(["-e", k]);
}
for cap in &plan.cap_add {
cmd.args(["--cap-add", cap]);
}
cmd.arg(image_tag);
for arg in &plan.args {
cmd.arg(arg);
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
debug!(cmd = ?cmd, "running container command");
let child = cmd.spawn().map_err(|e| {
NpxcError::RuntimeNotAvailable(format!(
"failed to spawn '{}': {e}",
config.container_cli
))
})?;
let container = ContainerProcess::from_child(child);
Ok(Session {
state: Arc::new(SessionState::new()),
session_dir,
container: Some(container),
network: None,
container_cli: config.container_cli.clone(),
container_name,
cleaned: false,
})
}
pub fn attach_network(&mut self, network: Option<ManagedNetwork>) {
self.network = network;
}
pub fn take_container(&mut self) -> Option<ContainerProcess> {
self.container.take()
}
pub async fn teardown(mut self) {
if let Some(ref mut c) = self.container {
c.kill_and_wait().await;
}
force_remove_container(&self.container_cli, &self.container_name).await;
let _ = tokio::fs::remove_dir_all(&self.session_dir).await;
if let Some(net) = self.network.take() {
net.delete_with_retry().await;
}
self.cleaned = true;
}
}
async fn force_remove_container(container_cli: &str, name: &str) {
let _ = Command::new(container_cli)
.args(["rm", "--force", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
impl Drop for Session {
fn drop(&mut self) {
if self.cleaned {
return;
}
if let Some(ref mut c) = self.container {
c.kill_now();
}
let _ = std::process::Command::new(&self.container_cli)
.args(["rm", "--force", &self.container_name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let _ = std::fs::remove_dir_all(&self.session_dir);
if let Some(net) = &self.network {
net.delete_blocking();
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use tempfile::TempDir;
use crate::{
config::{
EffectiveConfig, NetworkPolicy,
package::{MountConfig, StorageConfig},
},
error::NpxcError,
};
use super::{LaunchPlan, MountMode};
fn base_config() -> EffectiveConfig {
EffectiveConfig {
node_image: String::new(),
container_cli: String::new(),
network: NetworkPolicy::None,
memory: String::new(),
cpus: String::new(),
mount_mode: String::new(),
log_level: String::new(),
strategies: vec![],
heuristic_absolute_prefix: false,
heuristic_home_prefix: false,
heuristic_uri_prefix: vec![],
version: None,
path_arguments: HashMap::new(),
non_path_arguments: HashMap::new(),
env: HashMap::new(),
env_passthrough: vec![],
storage: None,
mounts: vec![],
}
}
fn tmp() -> TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn env_literal_populated_from_config() {
let cwd = tmp();
let mut config = base_config();
config.env.insert("FOO".into(), "bar".into());
config.env.insert("BAZ".into(), "qux".into());
let plan = LaunchPlan::build("pkg", &config, cwd.path(), vec![], false).unwrap();
let mut got: Vec<_> = plan.env_literal;
got.sort();
assert_eq!(
got,
[("BAZ".into(), "qux".into()), ("FOO".into(), "bar".into())]
);
}
#[test]
fn env_passthrough_populated_from_config() {
let cwd = tmp();
let mut config = base_config();
config.env_passthrough = vec!["SECRET".into(), "TOKEN".into()];
let plan = LaunchPlan::build("pkg", &config, cwd.path(), vec![], false).unwrap();
assert_eq!(plan.env_passthrough, ["SECRET", "TOKEN"]);
}
#[test]
fn args_forwarded_verbatim() {
let cwd = tmp();
let args = vec!["--port".into(), "3000".into()];
let plan =
LaunchPlan::build("pkg", &base_config(), cwd.path(), args.clone(), false).unwrap();
assert_eq!(plan.args, args);
}
#[test]
fn config_mount_absolute_ro_validated_and_added() {
let cwd = tmp();
let subdir = cwd.path().join("data");
std::fs::create_dir(&subdir).unwrap();
let mut config = base_config();
config.mounts = vec![MountConfig {
host: subdir.to_str().unwrap().into(),
container: "/data".into(),
mode: "ro".into(),
}];
let plan = LaunchPlan::build("pkg", &config, cwd.path(), vec![], false).unwrap();
let canonical_cwd = cwd.path().canonicalize().unwrap();
assert_eq!(plan.mounts.len(), 1);
assert_eq!(plan.mounts[0].container, "/data");
assert_eq!(plan.mounts[0].mode, MountMode::Ro);
assert!(plan.mounts[0].host.starts_with(&canonical_cwd));
}
#[test]
fn config_mount_relative_path_resolved_against_cwd() {
let cwd = tmp();
std::fs::create_dir(cwd.path().join("sub")).unwrap();
let mut config = base_config();
config.mounts = vec![MountConfig {
host: "sub".into(),
container: "/sub".into(),
mode: "ro".into(),
}];
let plan = LaunchPlan::build("pkg", &config, cwd.path(), vec![], false).unwrap();
assert_eq!(plan.mounts.len(), 1);
assert_eq!(
plan.mounts[0].host,
cwd.path().join("sub").canonicalize().unwrap()
);
}
#[test]
fn config_mount_rw_mode_parsed_correctly() {
let cwd = tmp();
std::fs::create_dir(cwd.path().join("rw")).unwrap();
let mut config = base_config();
config.mounts = vec![MountConfig {
host: cwd.path().join("rw").to_str().unwrap().into(),
container: "/rw".into(),
mode: "rw".into(),
}];
let plan = LaunchPlan::build("pkg", &config, cwd.path(), vec![], false).unwrap();
assert_eq!(plan.mounts[0].mode, MountMode::Rw);
}
#[test]
fn config_mount_outside_cwd_is_rejected() {
let cwd = tmp();
let outside = tmp();
let mut config = base_config();
config.mounts = vec![MountConfig {
host: outside.path().to_str().unwrap().into(),
container: "/outside".into(),
mode: "ro".into(),
}];
let result = LaunchPlan::build("pkg", &config, cwd.path(), vec![], false);
assert!(matches!(result, Err(NpxcError::PathOutOfScope { .. })));
}
#[test]
fn config_mount_nonexistent_path_is_rejected() {
let cwd = tmp();
let mut config = base_config();
config.mounts = vec![MountConfig {
host: cwd.path().join("no-such-dir").to_str().unwrap().into(),
container: "/missing".into(),
mode: "ro".into(),
}];
let result = LaunchPlan::build("pkg", &config, cwd.path(), vec![], false);
assert!(matches!(result, Err(NpxcError::PathNotFound(_))));
}
#[test]
fn no_isolate_appends_cwd_ro_mount() {
let cwd = tmp();
let canonical_cwd = cwd.path().canonicalize().unwrap();
let plan = LaunchPlan::build("pkg", &base_config(), cwd.path(), vec![], true).unwrap();
assert_eq!(plan.mounts.len(), 1);
let m = &plan.mounts[0];
assert_eq!(m.mode, MountMode::Ro);
assert_eq!(m.host, canonical_cwd);
assert_eq!(m.container, canonical_cwd.to_string_lossy());
}
#[test]
fn no_isolate_false_adds_no_cwd_mount() {
let cwd = tmp();
let plan = LaunchPlan::build("pkg", &base_config(), cwd.path(), vec![], false).unwrap();
assert!(plan.mounts.is_empty());
}
#[test]
fn persist_storage_mounts_data_dir_rw() {
let cwd = tmp();
let mut config = base_config();
config.storage = Some(StorageConfig {
persist: true,
writable: vec![],
});
let plan = LaunchPlan::build("@scope/my-pkg", &config, cwd.path(), vec![], false).unwrap();
assert_eq!(plan.mounts.len(), 1);
let m = &plan.mounts[0];
assert_eq!(m.container, "/data");
assert_eq!(m.mode, MountMode::Rw);
assert!(m.host.is_dir());
assert!(m.host.to_string_lossy().contains("scope-my-pkg"));
}
#[test]
fn no_persist_adds_no_mount() {
let cwd = tmp();
let mut config = base_config();
config.storage = Some(StorageConfig {
persist: false,
writable: vec![],
});
let plan = LaunchPlan::build("pkg", &config, cwd.path(), vec![], false).unwrap();
assert!(plan.mounts.is_empty());
}
}