use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::{Error, Result};
use libcontainer::container::builder::ContainerBuilder;
use libcontainer::container::{Container, ContainerStatus};
use libcontainer::oci_spec::runtime::{Linux, ProcessBuilder, RootBuilder, Spec};
use libcontainer::signal::Signal;
use libcontainer::syscall::syscall::SyscallType;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum YoukiState {
Created,
Running,
Stopped,
Paused,
Creating,
}
impl From<ContainerStatus> for YoukiState {
fn from(s: ContainerStatus) -> Self {
match s {
ContainerStatus::Creating => YoukiState::Creating,
ContainerStatus::Created => YoukiState::Created,
ContainerStatus::Running => YoukiState::Running,
ContainerStatus::Stopped => YoukiState::Stopped,
ContainerStatus::Paused => YoukiState::Paused,
}
}
}
#[derive(Debug, Clone)]
pub struct BindMount {
pub source: PathBuf,
pub destination: String,
pub read_only: bool,
}
#[derive(Debug, Clone)]
pub struct YoukiSpec {
pub args: Vec<String>,
pub env: Vec<String>,
pub cwd: String,
pub binds: Vec<BindMount>,
}
impl YoukiSpec {
pub fn new(args: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
args: args.into_iter().map(Into::into).collect(),
env: vec!["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into()],
cwd: "/".into(),
binds: Vec::new(),
}
}
pub fn with_bind(
mut self,
source: impl Into<PathBuf>,
destination: impl Into<String>,
read_only: bool,
) -> Self {
self.binds.push(BindMount {
source: source.into(),
destination: destination.into(),
read_only,
});
self
}
}
#[derive(Debug, Clone)]
pub struct YoukiRuntime {
root: PathBuf,
}
impl YoukiRuntime {
pub fn with_root(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn new() -> Self {
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
Self::with_root(PathBuf::from(base).join("draupnir-youki"))
}
fn euid() -> u32 {
rustix::process::geteuid().as_raw()
}
fn egid() -> u32 {
rustix::process::getegid().as_raw()
}
pub fn unpack_oci_layers(rootfs: &Path, layers: &[PathBuf]) -> Result<()> {
std::fs::create_dir_all(rootfs)
.map_err(|e| Error::Backend(format!("create rootfs {}: {e}", rootfs.display())))?;
for layer in layers {
let bytes = std::fs::read(layer)
.map_err(|e| Error::Backend(format!("read layer {}: {e}", layer.display())))?;
let is_gzip = bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b;
let unpack = |reader: Box<dyn std::io::Read>| -> Result<()> {
let mut ar = tar::Archive::new(reader);
ar.set_preserve_permissions(true);
ar.unpack(rootfs).map_err(|e| {
Error::Backend(format!("unpack layer {} into rootfs: {e}", layer.display()))
})
};
if is_gzip {
unpack(Box::new(flate2::read::GzDecoder::new(
std::io::Cursor::new(bytes),
)))?;
} else {
unpack(Box::new(std::io::Cursor::new(bytes)))?;
}
}
Ok(())
}
pub fn write_bundle_config(bundle: &Path, spec: &YoukiSpec) -> Result<()> {
use libcontainer::oci_spec::runtime::{Mount, MountBuilder};
if spec.args.is_empty() {
return Err(Error::Spec("youki spec needs a non-empty argv".into()));
}
let mut oci = Spec::default();
let process = ProcessBuilder::default()
.args(spec.args.clone())
.env(spec.env.clone())
.cwd(spec.cwd.clone())
.build()
.map_err(|e| Error::Spec(format!("build process spec: {e}")))?;
let root = RootBuilder::default()
.path("rootfs")
.readonly(false)
.build()
.map_err(|e| Error::Spec(format!("build root spec: {e}")))?;
let linux = Linux::rootless(Self::euid(), Self::egid());
let mut mounts: Vec<Mount> = oci.mounts().clone().unwrap_or_default();
for m in &mut mounts {
if let Some(opts) = m.options().clone() {
let kept: Vec<String> = opts
.into_iter()
.filter(|opt| {
let unmapped_id =
|v: &str| v.parse::<u32>().map(|n| n != 0).unwrap_or(false);
if let Some(v) = opt.strip_prefix("uid=") {
return !unmapped_id(v);
}
if let Some(v) = opt.strip_prefix("gid=") {
return !unmapped_id(v);
}
true
})
.collect();
m.set_options(Some(kept));
}
}
for b in &spec.binds {
let opts = if b.read_only {
vec!["rbind".to_string(), "ro".to_string()]
} else {
vec!["rbind".to_string(), "rw".to_string()]
};
let m = MountBuilder::default()
.destination(PathBuf::from(&b.destination))
.typ("bind")
.source(b.source.clone())
.options(opts)
.build()
.map_err(|e| Error::Spec(format!("build bind mount {}: {e}", b.destination)))?;
mounts.push(m);
}
oci.set_process(Some(process))
.set_root(Some(root))
.set_linux(Some(linux))
.set_hostname(Some("draupnir-youki".to_string()))
.set_mounts(Some(mounts));
std::fs::create_dir_all(bundle)
.map_err(|e| Error::Backend(format!("create bundle {}: {e}", bundle.display())))?;
oci.save(bundle.join("config.json")).map_err(|e| {
Error::Backend(format!("write config.json into {}: {e}", bundle.display()))
})?;
Ok(())
}
pub fn create(&self, id: &str, bundle: &Path) -> Result<Container> {
std::fs::create_dir_all(&self.root).map_err(|e| {
Error::Backend(format!(
"create youki state root {}: {e}",
self.root.display()
))
})?;
self.force_cleanup(id);
ContainerBuilder::new(id.to_string(), SyscallType::default())
.with_root_path(self.root.clone())
.map_err(|e| Error::Backend(format!("youki root path {}: {e}", self.root.display())))?
.as_init(bundle)
.with_systemd(false)
.with_detach(true)
.build()
.map_err(|e| Error::Backend(format!("youki create `{id}`: {e}")))
}
fn force_cleanup(&self, id: &str) {
let dir = self.root.join(id);
if !dir.exists() {
return;
}
if let Ok(mut existing) = Container::load(dir.clone()) {
let _ = existing.delete(true);
}
let _ = std::fs::remove_dir_all(&dir);
}
pub fn start(container: &mut Container) -> Result<()> {
container
.start()
.map_err(|e| Error::Backend(format!("youki start `{}`: {e}", container.id())))
}
pub fn state(container: &mut Container) -> YoukiState {
let _ = container.refresh_status();
container.status().into()
}
pub fn kill(container: &mut Container, signal: Signal) -> Result<()> {
container
.kill(signal, true)
.map_err(|e| Error::Backend(format!("youki kill `{}`: {e}", container.id())))
}
pub fn delete(container: &mut Container) -> Result<()> {
container
.delete(true)
.map_err(|e| Error::Backend(format!("youki delete `{}`: {e}", container.id())))
}
pub fn run_until_ready(
&self,
id: &str,
bundle: &Path,
timeout: Duration,
mut ready: impl FnMut() -> bool,
) -> Result<YoukiState> {
let mut container = self.create(id, bundle)?;
if let Err(e) = Self::start(&mut container) {
let _ = Self::delete(&mut container);
return Err(e);
}
let deadline = Instant::now() + timeout;
let mut last;
let mut became_ready;
loop {
became_ready = ready();
last = Self::state(&mut container);
if became_ready || Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = Self::delete(&mut container);
crate::functional_status(
"draupnir/youki",
"run_until_ready",
became_ready,
&if became_ready {
format!("youki container `{id}` reached readiness (daemon-less, rootless)")
} else {
format!("youki container `{id}` never signalled readiness within {timeout:?}")
},
);
if became_ready {
Ok(last)
} else {
Err(Error::Backend(format!(
"youki container `{id}` did not signal readiness within {timeout:?} (last state {last:?})"
)))
}
}
}
impl Default for YoukiRuntime {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_new_defaults_are_sane() {
let s = YoukiSpec::new(["/bin/busybox", "true"]);
assert_eq!(s.args, vec!["/bin/busybox".to_string(), "true".to_string()]);
assert_eq!(s.cwd, "/");
assert!(s.env.iter().any(|e| e.starts_with("PATH=")));
assert!(s.binds.is_empty());
}
#[test]
fn empty_argv_is_rejected_before_any_bundle_write() {
let dir = std::env::temp_dir().join(format!("youki-cfg-test-{}", std::process::id()));
let spec = YoukiSpec {
args: vec![],
env: vec![],
cwd: "/".into(),
binds: vec![],
};
let err = YoukiRuntime::write_bundle_config(&dir, &spec).unwrap_err();
assert!(matches!(err, Error::Spec(_)));
}
#[test]
fn write_bundle_config_emits_a_rootless_config_json() {
let bundle = std::env::temp_dir().join(format!("youki-bundle-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&bundle);
let spec = YoukiSpec::new(["/bin/busybox", "sh", "-c", "true"])
.with_bind("/tmp", "/signal", false);
YoukiRuntime::write_bundle_config(&bundle, &spec).expect("author config.json");
let text = std::fs::read_to_string(bundle.join("config.json")).expect("config.json exists");
assert!(
text.contains("\"user\""),
"config.json declares a user namespace"
);
assert!(
text.contains("uidMappings"),
"config.json carries a rootless uid map"
);
assert!(
text.contains("/signal"),
"config.json carries the bind-mounted signal dir"
);
let _ = std::fs::remove_dir_all(&bundle);
}
#[test]
fn force_cleanup_sweeps_a_stale_state_dir_so_a_fresh_create_is_idempotent() {
let root = std::env::temp_dir().join(format!("youki-idem-root-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let rt = YoukiRuntime::with_root(root.clone());
let id = "korp-falkordb";
let stale = root.join(id);
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join("state.json"), b"{stale}").unwrap();
assert!(stale.exists(), "the stale state dir exists before cleanup");
rt.force_cleanup(id);
assert!(
!stale.exists(),
"force_cleanup swept the stale state dir (idempotent boot)"
);
rt.force_cleanup(id);
let _ = std::fs::remove_dir_all(&root);
}
}