use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tracing::debug;
use crate::{config::NetworkPolicy, error::NpxcError};
pub struct ManagedNetwork {
name: String,
container_cli: String,
pub subnet: String,
pub gateway: String,
}
#[derive(serde::Deserialize)]
struct NetworkInspect {
status: NetworkInspectStatus,
}
#[derive(serde::Deserialize)]
struct NetworkInspectStatus {
#[serde(rename = "ipv4Subnet")]
ipv4_subnet: String,
#[serde(rename = "ipv4Gateway")]
ipv4_gateway: String,
}
impl ManagedNetwork {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub async fn provision(
policy: &NetworkPolicy,
container_cli: &str,
) -> Result<(String, Option<Self>), NpxcError> {
match policy {
NetworkPolicy::None => Ok(("none".to_string(), None)),
NetworkPolicy::Named(name) => Ok((name.clone(), None)),
NetworkPolicy::Allowlist { .. } => {
let net = Self::create_internal(container_cli).await?;
Ok((net.name.clone(), Some(net)))
}
}
}
async fn create_internal(container_cli: &str) -> Result<Self, NpxcError> {
let name = format!("npxc-{}", uuid::Uuid::new_v4().simple());
let mut cmd = Command::new(container_cli);
cmd.args(["network", "create", "--internal", &name]);
debug!(cmd = ?cmd, "creating per-session network");
let output = cmd.output().await.map_err(|e| {
NpxcError::RuntimeNotAvailable(format!("failed to spawn '{container_cli}': {e}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(NpxcError::Runtime(format!(
"`{container_cli} network create --internal {name}` failed: {}",
stderr.trim()
)));
}
match Self::inspect(container_cli, &name).await {
Ok((subnet, gateway)) => Ok(Self {
name,
container_cli: container_cli.to_string(),
subnet,
gateway,
}),
Err(e) => {
let stale = Self {
name,
container_cli: container_cli.to_string(),
subnet: String::new(),
gateway: String::new(),
};
stale.delete_blocking();
Err(e)
}
}
}
async fn inspect(container_cli: &str, name: &str) -> Result<(String, String), NpxcError> {
let mut cmd = Command::new(container_cli);
cmd.args(["network", "inspect", name]);
debug!(cmd = ?cmd, "inspecting network");
let output = cmd.output().await.map_err(|e| {
NpxcError::RuntimeNotAvailable(format!("failed to spawn '{container_cli}': {e}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(NpxcError::Runtime(format!(
"`{container_cli} network inspect {name}` failed: {}",
stderr.trim()
)));
}
parse_inspect(&output.stdout)
}
pub async fn delete(&self) -> Result<(), NpxcError> {
let status = Command::new(&self.container_cli)
.args(["network", "delete", &self.name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map_err(|e| {
NpxcError::RuntimeNotAvailable(format!(
"failed to spawn '{}': {e}",
self.container_cli
))
})?;
if status.success() {
Ok(())
} else {
Err(NpxcError::Runtime(format!(
"failed to delete network '{}' (exit code: {:?})",
self.name,
status.code()
)))
}
}
pub async fn delete_with_retry(&self) {
const ATTEMPTS: u32 = 5;
for attempt in 1..=ATTEMPTS {
match self.delete().await {
Ok(()) => return,
Err(e) => {
if attempt == ATTEMPTS {
tracing::warn!(
network = %self.name,
error = %e,
"failed to delete per-session network after {ATTEMPTS} attempts",
);
return;
}
tokio::time::sleep(Duration::from_millis(150 * u64::from(attempt))).await;
}
}
}
}
pub fn delete_blocking(&self) {
let _ = std::process::Command::new(&self.container_cli)
.args(["network", "delete", &self.name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
fn parse_inspect(stdout: &[u8]) -> Result<(String, String), NpxcError> {
let items: Vec<NetworkInspect> = serde_json::from_slice(stdout)?;
let first = items
.into_iter()
.next()
.ok_or_else(|| NpxcError::Runtime("empty `network inspect` output".to_string()))?;
Ok((first.status.ipv4_subnet, first.status.ipv4_gateway))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_inspect_extracts_subnet_and_gateway() {
let json = br#"[
{
"id": "npxc-abc",
"configuration": { "id": "npxc-abc", "mode": "hostOnly" },
"status": {
"ipv4Subnet": "192.168.66.0/24",
"ipv4Gateway": "192.168.66.1",
"ipv6Subnet": "fd00::/64"
}
}
]"#;
let (subnet, gateway) = parse_inspect(json).unwrap();
assert_eq!(subnet, "192.168.66.0/24");
assert_eq!(gateway, "192.168.66.1");
}
#[test]
fn parse_inspect_empty_array_errors() {
let err = parse_inspect(b"[]").unwrap_err();
assert!(matches!(err, NpxcError::Runtime(_)));
}
#[test]
fn parse_inspect_invalid_json_errors() {
let err = parse_inspect(b"not json").unwrap_err();
assert!(matches!(err, NpxcError::Json(_)));
}
#[tokio::test]
async fn provision_none_yields_none_arg_and_no_network() {
let (arg, net) = ManagedNetwork::provision(&NetworkPolicy::None, "container")
.await
.unwrap();
assert_eq!(arg, "none");
assert!(net.is_none());
}
#[tokio::test]
async fn provision_named_passes_through_without_creating() {
let (arg, net) =
ManagedNetwork::provision(&NetworkPolicy::Named("default".to_string()), "container")
.await
.unwrap();
assert_eq!(arg, "default");
assert!(net.is_none());
}
}