use std::process::Stdio;
use tokio::process::Command;
use tracing::debug;
use crate::{
config::{EffectiveConfig, sanitize_package_name},
dockerfile,
error::NpxcError,
};
#[must_use]
pub fn image_tag(pkg_name: &str, version: &str) -> String {
format!("npxc/{}:{}", sanitize_package_name(pkg_name), version)
}
pub async fn image_exists(container_cli: &str, tag: &str) -> Result<bool, NpxcError> {
let mut cmd = Command::new(container_cli);
cmd.args(["image", "inspect", tag]);
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
debug!(cmd = ?cmd, "running container command");
let status = cmd.status().await.map_err(|e| {
NpxcError::RuntimeNotAvailable(format!("failed to spawn '{container_cli}': {e}"))
})?;
Ok(status.success())
}
pub async fn build_image(
pkg_name: &str,
version: &str,
config: &EffectiveConfig,
force_rebuild: bool,
) -> Result<(), NpxcError> {
let tag = image_tag(pkg_name, version);
let package_spec = format!("{pkg_name}@{version}");
let tmp = tempfile::Builder::new()
.prefix("npxc-build-")
.tempdir()
.map_err(NpxcError::Io)?;
let dockerfile_path = tmp.path().join("Dockerfile");
std::fs::write(&dockerfile_path, dockerfile::DOCKERFILE_TEMPLATE)?;
let mut cmd = Command::new(&config.container_cli);
cmd.arg("build");
cmd.args(["--platform", "linux/arm64"]);
cmd.args(["--build-arg", &format!("PACKAGE_SPEC={package_spec}")]);
cmd.args(["--build-arg", &format!("NODE_IMAGE={}", config.node_image)]);
cmd.args(["-t", &tag]);
cmd.args(["-f", &dockerfile_path.to_string_lossy()]);
if force_rebuild {
cmd.arg("--no-cache");
}
cmd.arg(tmp.path());
cmd.stderr(Stdio::inherit());
debug!(cmd = ?cmd, "running container command");
let status = cmd.status().await.map_err(|e| {
NpxcError::RuntimeNotAvailable(format!("failed to spawn '{}': {e}", config.container_cli))
})?;
stop_builder(&config.container_cli).await;
if status.success() {
Ok(())
} else {
Err(NpxcError::BuildFailed {
code: status.code(),
})
}
}
async fn stop_builder(container_cli: &str) {
for args in [
["builder", "stop"].as_slice(),
&["builder", "delete", "--force"],
] {
let result = Command::new(container_cli)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
if let Err(e) = result {
debug!("builder cleanup ({} {}): {e}", args[0], args[1]);
}
}
}
pub async fn ensure_image(
pkg_name: &str,
version: &str,
config: &EffectiveConfig,
force_rebuild: bool,
) -> Result<String, NpxcError> {
let tag = image_tag(pkg_name, version);
if !force_rebuild && image_exists(&config.container_cli, &tag).await? {
tracing::debug!(%tag, "image already exists, skipping build");
return Ok(tag);
}
build_image(pkg_name, version, config, force_rebuild).await?;
Ok(tag)
}
#[derive(serde::Deserialize)]
struct ContainerImage {
reference: String,
}
pub async fn list_images(container_cli: &str) -> Result<Vec<(String, String)>, NpxcError> {
let mut cmd = Command::new(container_cli);
cmd.args(["image", "list", "--format", "json"]);
debug!(cmd = ?cmd, "running container command");
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} image list` failed: {}",
stderr.trim()
)));
}
let all: Vec<ContainerImage> = serde_json::from_slice(&output.stdout)?;
Ok(all
.into_iter()
.filter(|img| img.reference.starts_with("npxc/"))
.filter_map(|img| {
let (repo, tag) = img.reference.rsplit_once(':')?;
Some((repo.to_string(), tag.to_string()))
})
.collect())
}
pub async fn remove_image(container_cli: &str, tag: &str) -> Result<(), NpxcError> {
let mut cmd = Command::new(container_cli);
cmd.args(["image", "rm", tag]);
debug!(cmd = ?cmd, "running container command");
let status = cmd.status().await.map_err(|e| {
NpxcError::RuntimeNotAvailable(format!("failed to spawn '{container_cli}': {e}"))
})?;
if status.success() {
Ok(())
} else {
Err(NpxcError::Runtime(format!(
"failed to remove image '{tag}' (exit code: {:?})",
status.code()
)))
}
}