use crate::{Boot, BootSpec, Error, ImageSource, Lifecycle, Machine, PowerState, Result};
use std::path::Path;
use std::time::{Duration, Instant};
#[cfg(feature = "backend-oci")]
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Readiness {
#[default]
Running,
LogMatch(String),
}
#[cfg_attr(not(feature = "backend-oci"), allow(dead_code))]
fn poll_until_ready(
label: &str,
timeout: Duration,
interval: Duration,
mut check: impl FnMut() -> Result<bool>,
) -> Result<()> {
let deadline = Instant::now() + timeout;
loop {
if check()? {
return Ok(());
}
let now = Instant::now();
if now >= deadline {
return Err(Error::Backend(format!(
"container `{label}` not ready within {timeout:?}"
)));
}
let remaining = deadline.saturating_duration_since(now);
std::thread::sleep(interval.min(remaining));
}
}
#[derive(Default, Clone)]
pub struct ContainerBoot {
#[cfg(feature = "backend-oci")]
engine: Arc<Mutex<Option<Arc<Engine>>>>,
}
impl std::fmt::Debug for ContainerBoot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ContainerBoot").finish_non_exhaustive()
}
}
impl ContainerBoot {
pub fn new() -> Self {
Self::default()
}
pub fn image_ref<'a>(&self, spec: &'a BootSpec) -> Result<&'a str> {
match &spec.image {
ImageSource::OciImage(r) => Ok(r.as_str()),
other => Err(Error::Spec(format!(
"container backend needs an OCI image, got {other:?}"
))),
}
}
#[cfg_attr(not(feature = "backend-oci"), allow(dead_code))]
fn container_name(spec: &BootSpec) -> String {
format!("draupnir-{}", spec.name)
}
pub fn wait_ready(
&self,
machine: &Machine,
readiness: &Readiness,
timeout: Duration,
) -> Result<()> {
#[cfg(feature = "backend-oci")]
{
let engine = self.engine()?;
let name = machine.id.clone();
poll_until_ready(&name, timeout, Duration::from_millis(200), || {
match readiness {
Readiness::Running => Ok(matches!(engine.power_state(&name), PowerState::On)),
Readiness::LogMatch(needle) => engine.log_contains(&name, needle),
}
})
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = (machine, readiness, timeout);
Err(Error::Unsupported(
"container backend needs the `backend-oci` feature".into(),
))
}
}
#[cfg(feature = "backend-oci")]
fn engine(&self) -> Result<Arc<Engine>> {
let mut guard = self.engine.lock().unwrap();
if let Some(e) = guard.as_ref() {
return Ok(Arc::clone(e));
}
let e = Arc::new(Engine::connect()?);
*guard = Some(Arc::clone(&e));
Ok(e)
}
}
impl Boot for ContainerBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
spec.validate()?;
let image = self.image_ref(spec)?;
#[cfg(feature = "backend-oci")]
{
let name = Self::container_name(spec);
let env: Vec<String> = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
self.engine()?.create_and_start_with_binds_and_net(
image, &name, &env, &spec.cmd, &spec.ports, &spec.port_maps, &[], spec.net.oci_value(),
spec.cpus, spec.mem_limit_mb,
)?;
Ok(Machine::started(name, spec))
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = image;
Err(Error::Unsupported(
"container backend needs the `backend-oci` feature (drives the podman/Docker REST API via bollard)"
.into(),
))
}
}
}
impl Lifecycle for ContainerBoot {
fn power_on(&self, machine: &Machine) -> Result<()> {
#[cfg(feature = "backend-oci")]
{
self.engine()?.start(&machine.id)
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = machine;
Err(Error::Unsupported("container backend needs the `backend-oci` feature".into()))
}
}
fn power_off(&self, machine: &Machine) -> Result<()> {
#[cfg(feature = "backend-oci")]
{
self.engine()?.stop(&machine.id);
Ok(())
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = machine;
Err(Error::Unsupported("container backend needs the `backend-oci` feature".into()))
}
}
fn status(&self, machine: &Machine) -> Result<PowerState> {
#[cfg(feature = "backend-oci")]
{
Ok(self.engine()?.power_state(&machine.id))
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = machine;
Err(Error::Unsupported("container backend needs the `backend-oci` feature".into()))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContainerState {
Running,
Exited(i64),
Gone,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ExecOutcome {
pub exit_code: Option<i64>,
pub stdout: Vec<String>,
pub stderr: Vec<String>,
}
pub fn exec_argv(id: &str, argv: &[&str]) -> Vec<String> {
let mut command = Vec::with_capacity(argv.len() + 2);
command.push("exec".to_string());
command.push(id.to_string());
command.extend(argv.iter().map(|a| a.to_string()));
command
}
pub trait ContainerControl {
fn container_state(&self, machine: &Machine) -> ContainerState;
fn drain_logs(&self, machine: &Machine) -> Vec<String>;
fn drain_logs_split(&self, machine: &Machine) -> (Vec<String>, Vec<String>) {
(self.drain_logs(machine), Vec::new())
}
fn stop(&self, machine: &Machine);
fn exec(&self, machine: &Machine, argv: &[&str]) -> Result<ExecOutcome> {
if machine.backend != crate::Backend::Container {
return Err(Error::Spec(format!(
"exec is container-only; a {:?} machine has no container to exec into",
machine.backend
)));
}
if argv.is_empty() {
return Err(Error::Spec(
"a container exec needs a non-empty argv (nothing to run)".into(),
));
}
let command = exec_argv(&machine.id, argv);
self.exec_command(&command)
}
fn exec_command(&self, command: &[String]) -> Result<ExecOutcome> {
let _ = command;
Err(Error::Unsupported(
"this backend cannot exec into a container (needs the OCI engine)".into(),
))
}
}
impl ContainerControl for ContainerBoot {
fn container_state(&self, machine: &Machine) -> ContainerState {
#[cfg(feature = "backend-oci")]
{
match self.engine() {
Ok(e) => e.container_state(&machine.id),
Err(_) => ContainerState::Gone,
}
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = machine;
ContainerState::Gone
}
}
fn drain_logs(&self, machine: &Machine) -> Vec<String> {
#[cfg(feature = "backend-oci")]
{
match self.engine() {
Ok(e) => e.drain_logs(&machine.id),
Err(_) => Vec::new(),
}
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = machine;
Vec::new()
}
}
fn drain_logs_split(&self, machine: &Machine) -> (Vec<String>, Vec<String>) {
#[cfg(feature = "backend-oci")]
{
match self.engine() {
Ok(e) => e.drain_logs_split(&machine.id),
Err(_) => (Vec::new(), Vec::new()),
}
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = machine;
(Vec::new(), Vec::new())
}
}
fn stop(&self, machine: &Machine) {
#[cfg(feature = "backend-oci")]
{
if let Ok(e) = self.engine() {
e.stop(&machine.id);
}
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = machine;
}
}
fn exec_command(&self, command: &[String]) -> Result<ExecOutcome> {
#[cfg(feature = "backend-oci")]
{
let id = &command[1];
let argv: Vec<String> = command[2..].to_vec();
self.engine()?.exec(id, &argv)
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = command;
Err(Error::Unsupported(
"container backend needs the `backend-oci` feature (drives the podman/Docker REST API via bollard)"
.into(),
))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RunOutcome {
pub exit_code: Option<i64>,
pub stdout: Vec<String>,
pub stderr: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunOptions {
pub timeout: Option<Duration>,
pub poll_interval: Duration,
}
impl Default for RunOptions {
fn default() -> Self {
Self { timeout: None, poll_interval: Duration::from_millis(200) }
}
}
impl RunOptions {
pub fn poll_every(poll_interval: Duration) -> Self {
Self { timeout: None, poll_interval }
}
pub fn bounded(timeout: Duration, poll_interval: Duration) -> Self {
Self { timeout: Some(timeout), poll_interval }
}
}
pub fn run_to_completion<B>(backend: &B, spec: &BootSpec, opts: &RunOptions) -> Result<RunOutcome>
where
B: Boot + ContainerControl,
{
let machine = backend.boot(spec)?;
drive_to_completion(backend, &machine, opts)
}
pub fn drive_to_completion<B>(backend: &B, machine: &Machine, opts: &RunOptions) -> Result<RunOutcome>
where
B: ContainerControl,
{
let mut stdout: Vec<String> = Vec::new();
let mut stderr: Vec<String> = Vec::new();
let deadline = opts.timeout.map(|t| Instant::now() + t);
let exit_code = loop {
let (mut out, mut err) = backend.drain_logs_split(machine);
stdout.append(&mut out);
stderr.append(&mut err);
match backend.container_state(machine) {
ContainerState::Exited(code) => break Some(code),
ContainerState::Gone => break None,
ContainerState::Running => {}
}
if let Some(dl) = deadline {
if Instant::now() >= dl {
backend.stop(machine);
return Err(Error::Backend(format!(
"container `{}` did not run to completion within {:?}",
machine.id,
opts.timeout.unwrap()
)));
}
}
std::thread::sleep(opts.poll_interval);
};
let (mut out, mut err) = backend.drain_logs_split(machine);
stdout.append(&mut out);
stderr.append(&mut err);
backend.stop(machine);
Ok(RunOutcome { exit_code, stdout, stderr })
}
impl ContainerBoot {
pub fn run_to_completion(&self, spec: &BootSpec, opts: &RunOptions) -> Result<RunOutcome> {
#[cfg(feature = "backend-oci")]
{
run_to_completion(self, spec, opts)
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = (spec, opts);
Err(Error::Unsupported(
"container backend needs the `backend-oci` feature (drives the podman/Docker REST API via bollard)"
.into(),
))
}
}
pub fn boot_with_binds(&self, spec: &BootSpec, binds: &[String]) -> Result<Machine> {
spec.validate()?;
let image = self.image_ref(spec)?;
#[cfg(feature = "backend-oci")]
{
let name = Self::container_name(spec);
let env: Vec<String> = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
self.engine()?.create_and_start_with_binds_and_net(
image, &name, &env, &spec.cmd, &spec.ports, &spec.port_maps, binds, spec.net.oci_value(),
spec.cpus, spec.mem_limit_mb,
)?;
Ok(Machine::started(name, spec))
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = (image, binds);
Err(Error::Unsupported(
"container backend needs the `backend-oci` feature (drives the podman/Docker REST API via bollard)"
.into(),
))
}
}
pub fn run_to_completion_with_binds(
&self,
spec: &BootSpec,
opts: &RunOptions,
binds: &[String],
) -> Result<RunOutcome> {
#[cfg(feature = "backend-oci")]
{
let machine = self.boot_with_binds(spec, binds)?;
drive_to_completion(self, &machine, opts)
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = (spec, opts, binds);
Err(Error::Unsupported(
"container backend needs the `backend-oci` feature (drives the podman/Docker REST API via bollard)"
.into(),
))
}
}
pub fn build_image(&self, context_dir: &Path, containerfile: &str, tag: &str) -> Result<String> {
#[cfg(feature = "backend-oci")]
{
self.engine()?.build_image(context_dir, containerfile, tag)
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = (context_dir, containerfile, tag);
Err(Error::Unsupported(
"container backend needs the `backend-oci` feature (drives the podman/Docker REST API via bollard)"
.into(),
))
}
}
pub fn extract_path(&self, image: &str, container_path: &str, host_dest: &Path) -> Result<()> {
#[cfg(feature = "backend-oci")]
{
self.engine()?.extract_path(image, container_path, host_dest)
}
#[cfg(not(feature = "backend-oci"))]
{
let _ = (image, container_path, host_dest);
Err(Error::Unsupported(
"container backend needs the `backend-oci` feature (drives the podman/Docker REST API via bollard)"
.into(),
))
}
}
}
#[cfg(feature = "backend-oci")]
fn context_tar(context_dir: &Path) -> Result<Vec<u8>> {
let mut buf: Vec<u8> = Vec::new();
{
let mut builder = tar::Builder::new(&mut buf);
builder
.append_dir_all(".", context_dir)
.map_err(|e| Error::Backend(format!("tar build context {}: {e}", context_dir.display())))?;
builder
.finish()
.map_err(|e| Error::Backend(format!("finish build-context tar: {e}")))?;
}
Ok(buf)
}
#[cfg(feature = "backend-oci")]
type LogBuf = Arc<Mutex<Vec<(bool, String)>>>;
#[cfg(feature = "backend-oci")]
struct Engine {
docker: bollard::Docker,
rt: tokio::runtime::Runtime,
logs: Mutex<std::collections::HashMap<String, LogBuf>>,
}
#[cfg(feature = "backend-oci")]
impl Engine {
fn socket_url() -> String {
if let Ok(h) = std::env::var("DOCKER_HOST") {
return h;
}
let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into());
format!("unix://{xdg}/podman/podman.sock")
}
fn connect() -> Result<Self> {
let url = Self::socket_url();
let path = url.strip_prefix("unix://").unwrap_or(&url);
if url.starts_with("unix://") && !std::path::Path::new(path).exists() {
return Err(Error::Backend(format!(
"podman/Docker API socket not found at {path} (enable with \
`systemctl --user enable --now podman.socket`, or point DOCKER_HOST at a running socket)"
)));
}
let docker = bollard::Docker::connect_with_unix(&url, 120, bollard::API_DEFAULT_VERSION)
.map_err(|e| Error::Backend(format!("connect container socket {url}: {e}")))?;
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| Error::Backend(format!("build tokio runtime for bollard: {e}")))?;
Ok(Engine { docker, rt, logs: Mutex::new(std::collections::HashMap::new()) })
}
#[allow(dead_code)]
pub fn create_body(image: &str, env: &[String], cmd: &[String], ports: &[u16]) -> bollard::models::ContainerCreateBody {
Self::create_body_with_binds(image, env, cmd, ports, &[])
}
pub fn create_body_with_binds(
image: &str,
env: &[String],
cmd: &[String],
ports: &[u16],
binds: &[String],
) -> bollard::models::ContainerCreateBody {
Self::create_body_with_binds_and_net(image, env, cmd, ports, binds, None)
}
pub fn create_body_with_binds_and_net(
image: &str,
env: &[String],
cmd: &[String],
ports: &[u16],
binds: &[String],
network_mode: Option<&str>,
) -> bollard::models::ContainerCreateBody {
Self::create_body_full(image, env, cmd, ports, &[], binds, network_mode, None, None)
}
#[allow(clippy::too_many_arguments)]
pub fn create_body_full(
image: &str,
env: &[String],
cmd: &[String],
ports: &[u16],
port_maps: &[crate::PortMap],
binds: &[String],
network_mode: Option<&str>,
cpus: Option<f64>,
mem_mb: Option<u32>,
) -> bollard::models::ContainerCreateBody {
use bollard::models::{ContainerCreateBody, HostConfig, PortBinding};
use std::collections::HashMap;
let mut exposed: Vec<String> = Vec::new();
let mut bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
let mut publish = |host: u16, container: u16| {
let key = format!("{container}/tcp");
if !exposed.contains(&key) {
exposed.push(key.clone());
}
bindings.insert(
key,
Some(vec![PortBinding {
host_ip: Some("0.0.0.0".to_string()),
host_port: Some(host.to_string()),
}]),
);
};
for &p in ports {
publish(p, p);
}
for pm in port_maps {
publish(pm.host, pm.container);
}
let nano_cpus = cpus.filter(|c| *c > 0.0).map(|c| (c * 1e9) as i64);
let memory = mem_mb.filter(|m| *m > 0).map(|m| i64::from(m) * 1024 * 1024);
let host_config = if bindings.is_empty()
&& binds.is_empty()
&& network_mode.is_none()
&& nano_cpus.is_none()
&& memory.is_none()
{
None
} else {
Some(HostConfig {
port_bindings: if bindings.is_empty() { None } else { Some(bindings) },
binds: if binds.is_empty() { None } else { Some(binds.to_vec()) },
network_mode: network_mode.map(str::to_string),
nano_cpus,
memory,
..Default::default()
})
};
ContainerCreateBody {
image: Some(image.to_string()),
cmd: if cmd.is_empty() { None } else { Some(cmd.to_vec()) },
env: if env.is_empty() { None } else { Some(env.to_vec()) },
exposed_ports: if exposed.is_empty() { None } else { Some(exposed) },
host_config,
..Default::default()
}
}
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub fn create_body_with_res(
image: &str,
env: &[String],
cmd: &[String],
ports: &[u16],
binds: &[String],
network_mode: Option<&str>,
cpus: Option<f64>,
mem_mb: Option<u32>,
) -> bollard::models::ContainerCreateBody {
Self::create_body_full(image, env, cmd, ports, &[], binds, network_mode, cpus, mem_mb)
}
#[allow(dead_code)]
fn create_and_start(&self, image: &str, name: &str, env: &[String], cmd: &[String], ports: &[u16]) -> Result<()> {
self.create_and_start_with_binds(image, name, env, cmd, ports, &[])
}
#[allow(dead_code)]
fn create_and_start_with_binds(&self, image: &str, name: &str, env: &[String], cmd: &[String], ports: &[u16], binds: &[String]) -> Result<()> {
self.create_and_start_with_binds_and_net(image, name, env, cmd, ports, &[], binds, None, None, None)
}
#[allow(clippy::too_many_arguments)]
fn create_and_start_with_binds_and_net(&self, image: &str, name: &str, env: &[String], cmd: &[String], ports: &[u16], port_maps: &[crate::PortMap], binds: &[String], network_mode: Option<&str>, cpus: Option<f64>, mem_mb: Option<u32>) -> Result<()> {
use bollard::query_parameters::{
CreateContainerOptionsBuilder, CreateImageOptionsBuilder, RemoveContainerOptionsBuilder,
StartContainerOptions,
};
use futures::StreamExt;
let docker = &self.docker;
self.rt.block_on(async {
let _ = docker
.remove_container(name, Some(RemoveContainerOptionsBuilder::new().force(true).build()))
.await;
if docker.inspect_image(image).await.is_err() {
let (repo, tag) = image.rsplit_once(':').unwrap_or((image, "latest"));
let opts = CreateImageOptionsBuilder::new().from_image(repo).tag(tag).build();
let mut pull = docker.create_image(Some(opts), None, None);
while let Some(item) = pull.next().await {
item.map_err(|e| Error::Backend(format!("pull image {image}: {e}")))?;
}
}
let body = Self::create_body_full(image, env, cmd, ports, port_maps, binds, network_mode, cpus, mem_mb);
docker
.create_container(Some(CreateContainerOptionsBuilder::new().name(name).build()), body)
.await
.map_err(|e| Error::Backend(format!("create container {name}: {e}")))?;
docker
.start_container(name, None::<StartContainerOptions>)
.await
.map_err(|e| Error::Backend(format!("start container {name}: {e}")))?;
Ok::<(), Error>(())
})?;
let buf: LogBuf = Arc::new(Mutex::new(Vec::new()));
self.logs.lock().unwrap().insert(name.to_string(), Arc::clone(&buf));
let docker = self.docker.clone();
let name_owned = name.to_string();
self.rt.spawn(async move {
use bollard::container::LogOutput;
use bollard::query_parameters::LogsOptionsBuilder;
let mut stream = docker.logs(
&name_owned,
Some(LogsOptionsBuilder::new().follow(true).stdout(true).stderr(true).build()),
);
while let Some(item) = stream.next().await {
match item {
Ok(out) => {
let is_err = matches!(out, LogOutput::StdErr { .. });
let line = LogOutput::to_string(&out);
let line = line.trim_end_matches(['\n', '\r']).to_string();
if !line.is_empty() {
buf.lock().unwrap().push((is_err, line));
}
}
Err(_) => break,
}
}
});
Ok(())
}
fn build_image(&self, context_dir: &Path, containerfile: &str, tag: &str) -> Result<String> {
use bollard::body_full;
use bollard::query_parameters::BuildImageOptionsBuilder;
use futures::StreamExt;
let tar = context_tar(context_dir)?;
let docker = &self.docker;
self.rt.block_on(async {
let opts = BuildImageOptionsBuilder::default()
.dockerfile(containerfile)
.t(tag)
.rm(true)
.build();
let mut stream = docker.build_image(opts, None, Some(body_full(tar.into())));
while let Some(item) = stream.next().await {
let info = item.map_err(|e| Error::Backend(format!("build image {tag}: {e}")))?;
if let Some(detail) = info.error_detail {
return Err(Error::Backend(format!(
"build image {tag}: {}",
detail.message.unwrap_or_default()
)));
}
}
Ok::<(), Error>(())
})?;
Ok(tag.to_string())
}
fn extract_path(&self, image: &str, container_path: &str, host_dest: &Path) -> Result<()> {
use bollard::models::ContainerCreateBody;
use bollard::query_parameters::{
CreateContainerOptionsBuilder, DownloadFromContainerOptionsBuilder,
RemoveContainerOptionsBuilder,
};
use futures::StreamExt;
let docker = &self.docker;
let safe: String = image
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let name = format!("draupnir-extract-{safe}-{}", std::process::id());
let tar_bytes: Vec<u8> = self.rt.block_on(async {
let _ = docker
.remove_container(&name, Some(RemoveContainerOptionsBuilder::new().force(true).build()))
.await;
let body = ContainerCreateBody { image: Some(image.to_string()), ..Default::default() };
docker
.create_container(Some(CreateContainerOptionsBuilder::new().name(name.as_str()).build()), body)
.await
.map_err(|e| Error::Backend(format!("create extract container from {image}: {e}")))?;
let opts = DownloadFromContainerOptionsBuilder::default().path(container_path).build();
let mut stream = docker.download_from_container(&name, Some(opts));
let mut buf: Vec<u8> = Vec::new();
let mut dl_err: Option<Error> = None;
while let Some(item) = stream.next().await {
match item {
Ok(chunk) => buf.extend_from_slice(&chunk),
Err(e) => {
dl_err = Some(Error::Backend(format!(
"download {container_path} from {image}: {e}"
)));
break;
}
}
}
let _ = docker
.remove_container(&name, Some(RemoveContainerOptionsBuilder::new().force(true).build()))
.await;
match dl_err {
Some(e) => Err(e),
None => Ok(buf),
}
})?;
std::fs::create_dir_all(host_dest)
.map_err(|e| Error::Backend(format!("create extract dest {}: {e}", host_dest.display())))?;
tar::Archive::new(std::io::Cursor::new(tar_bytes))
.unpack(host_dest)
.map_err(|e| Error::Backend(format!("unpack extracted tar into {}: {e}", host_dest.display())))?;
Ok(())
}
fn container_state(&self, name: &str) -> ContainerState {
use bollard::query_parameters::InspectContainerOptions;
self.rt.block_on(async {
match self.docker.inspect_container(name, None::<InspectContainerOptions>).await {
Ok(info) => {
let state = info.state;
let running = state.as_ref().and_then(|s| s.running).unwrap_or(false);
if running {
ContainerState::Running
} else {
ContainerState::Exited(state.and_then(|s| s.exit_code).unwrap_or(0))
}
}
Err(_) => ContainerState::Gone,
}
})
}
fn drain_logs(&self, name: &str) -> Vec<String> {
match self.logs.lock().unwrap().get(name) {
Some(buf) => std::mem::take(&mut *buf.lock().unwrap())
.into_iter()
.map(|(_, line)| line)
.collect(),
None => Vec::new(),
}
}
fn drain_logs_split(&self, name: &str) -> (Vec<String>, Vec<String>) {
match self.logs.lock().unwrap().get(name) {
Some(buf) => {
let mut out = Vec::new();
let mut err = Vec::new();
for (is_err, line) in std::mem::take(&mut *buf.lock().unwrap()) {
if is_err {
err.push(line);
} else {
out.push(line);
}
}
(out, err)
}
None => (Vec::new(), Vec::new()),
}
}
fn start(&self, name: &str) -> Result<()> {
use bollard::query_parameters::StartContainerOptions;
self.rt.block_on(async {
self.docker
.start_container(name, None::<StartContainerOptions>)
.await
.map_err(|e| Error::Backend(format!("start container {name}: {e}")))
})
}
fn stop(&self, name: &str) {
use bollard::query_parameters::{RemoveContainerOptionsBuilder, StopContainerOptions};
self.rt.block_on(async {
let _ = self
.docker
.stop_container(name, None::<StopContainerOptions>)
.await;
let _ = self
.docker
.remove_container(name, Some(RemoveContainerOptionsBuilder::new().force(true).build()))
.await;
});
self.logs.lock().unwrap().remove(name);
}
fn exec(&self, name: &str, argv: &[String]) -> Result<ExecOutcome> {
use bollard::container::LogOutput;
use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
use futures::StreamExt;
let docker = &self.docker;
self.rt.block_on(async {
let config = CreateExecOptions::<String> {
cmd: Some(argv.to_vec()),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
};
let created = docker
.create_exec(name, config)
.await
.map_err(|e| Error::Backend(format!("create exec in {name}: {e}")))?;
let mut stdout: Vec<String> = Vec::new();
let mut stderr: Vec<String> = Vec::new();
match docker
.start_exec(&created.id, None::<StartExecOptions>)
.await
.map_err(|e| Error::Backend(format!("start exec in {name}: {e}")))?
{
StartExecResults::Attached { mut output, .. } => {
while let Some(item) = output.next().await {
match item {
Ok(out) => {
let is_err = matches!(out, LogOutput::StdErr { .. });
let line = LogOutput::to_string(&out);
let line = line.trim_end_matches(['\n', '\r']).to_string();
if !line.is_empty() {
if is_err {
stderr.push(line);
} else {
stdout.push(line);
}
}
}
Err(e) => {
return Err(Error::Backend(format!(
"read exec output in {name}: {e}"
)))
}
}
}
}
StartExecResults::Detached => {}
}
let inspect = docker
.inspect_exec(&created.id)
.await
.map_err(|e| Error::Backend(format!("inspect exec in {name}: {e}")))?;
Ok(ExecOutcome { exit_code: inspect.exit_code, stdout, stderr })
})
}
fn power_state(&self, name: &str) -> PowerState {
use bollard::query_parameters::InspectContainerOptions;
self.rt.block_on(async {
match self
.docker
.inspect_container(name, None::<InspectContainerOptions>)
.await
{
Ok(info) => {
let running = info.state.as_ref().and_then(|s| s.running).unwrap_or(false);
if running {
PowerState::On
} else {
PowerState::Off
}
}
Err(_) => PowerState::Off,
}
})
}
fn log_contains(&self, name: &str, needle: &str) -> Result<bool> {
use bollard::query_parameters::LogsOptionsBuilder;
use futures::StreamExt;
self.rt.block_on(async {
let opts = LogsOptionsBuilder::new().stdout(true).stderr(true).build();
let mut stream = self.docker.logs(name, Some(opts));
let mut buf = String::new();
while let Some(item) = stream.next().await {
match item {
Ok(chunk) => buf.push_str(&String::from_utf8_lossy(&chunk.into_bytes())),
Err(e) => {
return Err(Error::Backend(format!("read logs for {name}: {e}")))
}
}
}
Ok(buf.contains(needle))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_ref_extracts_the_oci_reference() {
let spec = BootSpec::container("cache", "docker.io/library/redis:7");
assert_eq!(ContainerBoot::new().image_ref(&spec).unwrap(), "docker.io/library/redis:7");
}
#[test]
fn image_ref_rejects_a_non_oci_image() {
let mut spec = BootSpec::container("bad", "redis:7");
spec.image = ImageSource::Iso("/boot.iso".into());
assert!(matches!(ContainerBoot::new().image_ref(&spec), Err(Error::Spec(_))));
}
#[test]
fn container_name_is_derived_from_the_spec_name() {
let spec = BootSpec::container("cache", "redis:7");
assert_eq!(ContainerBoot::container_name(&spec), "draupnir-cache");
}
#[test]
fn wait_ready_returns_a_clear_timeout_error_when_never_ready() {
let err = poll_until_ready(
"cache",
Duration::from_millis(40),
Duration::from_millis(5),
|| Ok(false),
)
.unwrap_err();
match err {
Error::Backend(m) => {
assert!(m.contains("cache"), "names the instance: {m}");
assert!(m.contains("not ready"), "says it wasn't ready: {m}");
}
other => panic!("expected Error::Backend, got {other:?}"),
}
}
#[test]
fn wait_ready_returns_ok_as_soon_as_the_probe_reports_ready() {
let mut n = 0;
let r = poll_until_ready("cache", Duration::from_secs(5), Duration::from_millis(1), || {
n += 1;
Ok(n >= 3)
});
assert!(r.is_ok());
assert_eq!(n, 3);
}
#[test]
fn wait_ready_fails_fast_on_a_backend_error() {
let r = poll_until_ready("cache", Duration::from_secs(5), Duration::from_millis(1), || {
Err(Error::Backend("socket vanished".into()))
});
assert!(matches!(r, Err(Error::Backend(m)) if m.contains("socket vanished")));
}
#[test]
fn readiness_defaults_to_running() {
assert_eq!(Readiness::default(), Readiness::Running);
}
#[test]
fn container_boot_carries_cmd_and_ports_through_the_spec() {
let spec = BootSpec::container("web", "docker.io/library/nginx:alpine")
.with_cmd(["nginx", "-g", "daemon off;"])
.with_port(8080)
.with_port(8443)
.with_env("TZ", "UTC");
assert_eq!(spec.cmd, vec!["nginx", "-g", "daemon off;"]);
assert_eq!(spec.ports, vec![8080, 8443]);
assert_eq!(spec.env.get("TZ").map(String::as_str), Some("UTC"));
spec.validate().unwrap();
}
#[test]
fn container_control_is_an_honest_noop_without_the_engine() {
let boot = ContainerBoot::new();
let m = Machine::started("draupnir-x", &BootSpec::container("x", "redis:7"));
assert_eq!(boot.container_state(&m), ContainerState::Gone);
assert!(boot.drain_logs(&m).is_empty());
boot.stop(&m); }
#[cfg(feature = "backend-oci")]
#[test]
fn create_body_carries_env_cmd_and_port_bindings() {
let body = Engine::create_body(
"app:1",
&["A=1".to_string(), "B=2".to_string()],
&["/bin/app".to_string(), "--serve".to_string()],
&[8080],
);
assert_eq!(body.image.as_deref(), Some("app:1"));
assert_eq!(body.cmd, Some(vec!["/bin/app".to_string(), "--serve".to_string()]));
assert_eq!(body.env, Some(vec!["A=1".to_string(), "B=2".to_string()]));
let exposed = body.exposed_ports.expect("exposed ports set");
assert!(exposed.iter().any(|s| s == "8080/tcp"));
let hc = body.host_config.expect("host config");
let b = hc.port_bindings.expect("bindings").get("8080/tcp").and_then(|v| v.clone()).expect("8080");
assert_eq!(b[0].host_port.as_deref(), Some("8080"));
}
#[cfg(feature = "backend-oci")]
#[test]
fn create_body_with_binds_attaches_host_mounts_and_stays_parity_when_empty() {
let with = Engine::create_body_with_binds(
"wix:4",
&[],
&["build".to_string()],
&[],
&["/host/in:/work/in:ro".to_string(), "/host/out:/work/out".to_string()],
);
let hc = with.host_config.expect("host config for binds");
assert_eq!(
hc.binds,
Some(vec!["/host/in:/work/in:ro".to_string(), "/host/out:/work/out".to_string()])
);
assert!(hc.port_bindings.is_none(), "no ports => no port bindings");
let plain = Engine::create_body("wix:4", &[], &["build".to_string()], &[]);
let via_empty = Engine::create_body_with_binds("wix:4", &[], &["build".to_string()], &[], &[]);
assert!(plain.host_config.is_none());
assert_eq!(plain, via_empty, "empty binds is byte-parity with create_body");
}
#[cfg(feature = "backend-oci")]
#[test]
fn create_body_sets_network_mode_none_and_default_is_byte_identical() {
let airgap = Engine::create_body_with_binds_and_net(
"job:1", &[], &["run".to_string()], &[], &[], Some("none"),
);
let hc = airgap.host_config.expect("host config minted for network mode");
assert_eq!(hc.network_mode.as_deref(), Some("none"), "airgap => --network none");
assert!(hc.port_bindings.is_none(), "no ports => no port bindings");
assert!(hc.binds.is_none(), "no binds => no binds");
let plain = Engine::create_body_with_binds("job:1", &[], &["run".to_string()], &[], &[]);
let via_none = Engine::create_body_with_binds_and_net(
"job:1", &[], &["run".to_string()], &[], &[], None,
);
assert!(plain.host_config.is_none(), "default net + no ports/binds => no host_config");
assert_eq!(plain, via_none, "None network_mode is byte-parity with the net-less builder");
let ported = Engine::create_body_with_binds("web:1", &[], &[], &[8080], &[]);
let ported_none = Engine::create_body_with_binds_and_net("web:1", &[], &[], &[8080], &[], None);
assert_eq!(ported, ported_none, "None net leaves a ported spec byte-identical");
assert!(ported.host_config.unwrap().network_mode.is_none(), "default net sets no network_mode");
}
#[cfg(feature = "backend-oci")]
#[test]
fn published_ports_bind_to_the_host_on_a_non_isolated_network() {
let body = Engine::create_body_with_binds_and_net(
"docker.io/falkordb/falkordb:v4.20.0", &[], &[], &[6379], &[], None,
);
let hc = body.host_config.expect("host config for published port");
assert!(hc.network_mode.is_none(), "non-isolated: no --network none (reachable)");
let bindings = hc.port_bindings.expect("port bindings");
let b = bindings.get("6379/tcp").and_then(|v| v.clone()).expect("6379 published");
assert_eq!(b[0].host_ip.as_deref(), Some("0.0.0.0"), "bound on the host");
assert_eq!(b[0].host_port.as_deref(), Some("6379"), "host port == container port");
assert!(
body.exposed_ports.unwrap().iter().any(|s| s == "6379/tcp"),
"6379 exposed",
);
crate::functional_status(
"draupnir/container",
"published_port_non_isolated",
hc.network_mode.is_none(),
"published ports bind 0.0.0.0:host on the default (non-isolated) net → host-reachable local-zone container",
);
}
#[cfg(feature = "backend-oci")]
#[test]
fn resource_caps_reach_the_create_body_and_none_is_byte_identical() {
let hot = Engine::create_body_with_res(
"docker.io/falkordb/falkordb:v4.20.0", &[], &[], &[6379], &[], None,
Some(12.0), Some(16384),
);
let hc = hot.host_config.expect("host config for resource caps");
assert_eq!(hc.nano_cpus, Some(12_000_000_000), "12 cores → nano_cpus");
assert_eq!(hc.memory, Some(16384_i64 * 1024 * 1024), "16384 MiB → bytes");
assert!(hc.port_bindings.unwrap().contains_key("6379/tcp"), "port still published");
let plain = Engine::create_body_with_binds_and_net("redis:7", &[], &[], &[], &[], None);
let via_none =
Engine::create_body_with_res("redis:7", &[], &[], &[], &[], None, None, None);
assert!(plain.host_config.is_none(), "no caps + no ports/binds → no host_config");
assert_eq!(plain, via_none, "None caps is byte-parity with the resource-less builder");
crate::functional_status(
"draupnir/container",
"resource_caps_reach_create_body",
hc.nano_cpus == Some(12_000_000_000) && hc.memory == Some(16384_i64 * 1024 * 1024),
"cpus/mem reach HostConfig.nano_cpus/memory; None = all host cores (hot-infra never throttled)",
);
}
#[cfg(feature = "backend-oci")]
#[test]
fn distinct_host_container_port_map_publishes_host_to_container() {
let body = Engine::create_body_full(
"docker.io/falkordb/falkordb:v4.20.0",
&[], &[], &[], &[crate::PortMap::new(6380, 6379)], &[], None, None, None,
);
let hc = body.host_config.expect("host config for published map");
assert!(hc.network_mode.is_none(), "non-isolated: reachable (no --network none)");
let bindings = hc.port_bindings.expect("port bindings");
let b = bindings.get("6379/tcp").and_then(|v| v.clone()).expect("container 6379 published");
assert_eq!(b[0].host_ip.as_deref(), Some("0.0.0.0"), "bound on the host");
assert_eq!(b[0].host_port.as_deref(), Some("6380"), "host 6380 -> container 6379");
assert!(!bindings.contains_key("6380/tcp"), "the HOST port is NOT the container key");
assert!(
body.exposed_ports.as_ref().unwrap().iter().any(|s| s == "6379/tcp"),
"the CONTAINER port is exposed, not the host port",
);
let demo = Engine::create_body_full(
"docker.io/falkordb/falkordb:v4.20.0",
&[], &[], &[6379], &[], &[], None, None, None,
);
let demo_via_map = Engine::create_body_full(
"docker.io/falkordb/falkordb:v4.20.0",
&[], &[], &[], &[crate::PortMap::same(6379)], &[], None, None, None,
);
let db = demo.host_config.clone().unwrap().port_bindings.unwrap();
let sb = db.get("6379/tcp").and_then(|v| v.clone()).expect("demo 6379 published");
assert_eq!(sb[0].host_port.as_deref(), Some("6379"), "single-port form: host==container==6379");
assert_eq!(demo, demo_via_map, "[6379] and PortMap::same(6379) render the same wire");
let bare = Engine::create_body_full("redis:7", &[], &[], &[], &[], &[], None, None, None);
assert!(bare.host_config.is_none(), "no ports/maps/net/caps => no host_config (byte-parity)");
crate::functional_status(
"draupnir/container",
"distinct_host_container_port_map",
sb[0].host_port.as_deref() == Some("6379")
&& b[0].host_port.as_deref() == Some("6380")
&& bindings.contains_key("6379/tcp"),
"PortMap{host,container} publishes host:container (-p 6380:6379) so a per-zone host port reaches a fixed in-container port",
);
}
use std::cell::RefCell;
#[derive(Default)]
struct ScriptedBackend {
booted: RefCell<Vec<String>>,
stopped: RefCell<Vec<String>>,
states: RefCell<std::collections::VecDeque<ContainerState>>,
log_batches: RefCell<std::collections::VecDeque<(Vec<String>, Vec<String>)>>,
}
impl ScriptedBackend {
fn with_states(states: Vec<ContainerState>) -> Self {
Self { states: RefCell::new(states.into()), ..Default::default() }
}
fn push_logs(&self, out: &[&str], err: &[&str]) {
self.log_batches.borrow_mut().push_back((
out.iter().map(|s| s.to_string()).collect(),
err.iter().map(|s| s.to_string()).collect(),
));
}
}
impl Boot for ScriptedBackend {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
self.booted.borrow_mut().push(spec.name.clone());
Ok(Machine::started(format!("draupnir-{}", spec.name), spec))
}
}
impl ContainerControl for ScriptedBackend {
fn container_state(&self, _m: &Machine) -> ContainerState {
let mut q = self.states.borrow_mut();
if q.len() > 1 {
q.pop_front().unwrap()
} else {
q.front().cloned().unwrap_or(ContainerState::Gone)
}
}
fn drain_logs(&self, _m: &Machine) -> Vec<String> {
let (mut out, mut err) = self.log_batches.borrow_mut().pop_front().unwrap_or_default();
out.append(&mut err);
out
}
fn drain_logs_split(&self, _m: &Machine) -> (Vec<String>, Vec<String>) {
self.log_batches.borrow_mut().pop_front().unwrap_or_default()
}
fn stop(&self, m: &Machine) {
self.stopped.borrow_mut().push(m.id.clone());
}
}
fn fast_opts() -> RunOptions {
RunOptions::poll_every(Duration::from_millis(1))
}
#[test]
fn run_to_completion_starts_waits_collects_split_logs_and_exit_code() {
let backend = ScriptedBackend::with_states(vec![
ContainerState::Running,
ContainerState::Running,
ContainerState::Exited(0),
]);
backend.push_logs(&["booting"], &[]); backend.push_logs(&["serving"], &["a warning"]); backend.push_logs(&["bye"], &[]);
let spec = BootSpec::container("job", "docker.io/library/busybox:latest");
let out = run_to_completion(&backend, &spec, &fast_opts()).unwrap();
assert_eq!(out.exit_code, Some(0));
assert_eq!(out.stdout, vec!["booting", "serving", "bye"]);
assert_eq!(out.stderr, vec!["a warning"]);
assert_eq!(backend.booted.borrow().as_slice(), &["job".to_string()]);
assert_eq!(backend.stopped.borrow().as_slice(), &["draupnir-job".to_string()]);
}
#[test]
fn run_to_completion_surfaces_a_nonzero_exit_as_ok_not_err() {
let backend = ScriptedBackend::with_states(vec![ContainerState::Exited(137)]);
backend.push_logs(&[], &["oom-killed"]);
let spec = BootSpec::container("crash", "img:1");
let out = run_to_completion(&backend, &spec, &fast_opts()).unwrap();
assert_eq!(out.exit_code, Some(137));
assert_eq!(out.stderr, vec!["oom-killed"]);
}
#[test]
fn run_to_completion_reports_none_when_the_container_is_gone() {
let backend = ScriptedBackend::with_states(vec![ContainerState::Gone]);
let spec = BootSpec::container("vanished", "img:1");
let out = run_to_completion(&backend, &spec, &fast_opts()).unwrap();
assert_eq!(out.exit_code, None);
assert_eq!(backend.stopped.borrow().len(), 1, "still removed on the way out");
}
#[test]
fn run_to_completion_times_out_with_a_clear_error_and_stops_the_container() {
let backend = ScriptedBackend::with_states(vec![ContainerState::Running]);
let spec = BootSpec::container("hang", "img:1");
let opts = RunOptions::bounded(Duration::from_millis(20), Duration::from_millis(2));
let err = run_to_completion(&backend, &spec, &opts).unwrap_err();
match err {
Error::Backend(m) => {
assert!(m.contains("draupnir-hang"), "names the instance: {m}");
assert!(m.contains("run to completion"), "says what timed out: {m}");
}
other => panic!("expected Error::Backend, got {other:?}"),
}
assert_eq!(backend.stopped.borrow().len(), 1, "container stopped on timeout");
}
#[test]
fn run_to_completion_propagates_a_boot_failure_without_polling() {
struct FailBoot;
impl Boot for FailBoot {
fn boot(&self, _spec: &BootSpec) -> Result<Machine> {
Err(Error::Backend("no socket".into()))
}
}
impl ContainerControl for FailBoot {
fn container_state(&self, _m: &Machine) -> ContainerState {
panic!("must not poll after a boot failure")
}
fn drain_logs(&self, _m: &Machine) -> Vec<String> {
Vec::new()
}
fn stop(&self, _m: &Machine) {
panic!("must not stop after a boot failure")
}
}
let spec = BootSpec::container("x", "img:1");
let err = run_to_completion(&FailBoot, &spec, &fast_opts()).unwrap_err();
assert!(matches!(err, Error::Backend(m) if m.contains("no socket")));
}
#[test]
fn default_drain_logs_split_routes_combined_logs_to_stdout() {
struct CombinedOnly;
impl ContainerControl for CombinedOnly {
fn container_state(&self, _m: &Machine) -> ContainerState {
ContainerState::Gone
}
fn drain_logs(&self, _m: &Machine) -> Vec<String> {
vec!["one".into(), "two".into()]
}
fn stop(&self, _m: &Machine) {}
}
let m = Machine::started("draupnir-x", &BootSpec::container("x", "img:1"));
let (out, err) = CombinedOnly.drain_logs_split(&m);
assert_eq!(out, vec!["one", "two"]);
assert!(err.is_empty());
}
#[derive(Default)]
struct ExecRecorder {
seen: RefCell<Vec<Vec<String>>>,
outcome: ExecOutcome,
}
impl ContainerControl for ExecRecorder {
fn container_state(&self, _m: &Machine) -> ContainerState {
ContainerState::Running
}
fn drain_logs(&self, _m: &Machine) -> Vec<String> {
Vec::new()
}
fn stop(&self, _m: &Machine) {}
fn exec_command(&self, command: &[String]) -> Result<ExecOutcome> {
self.seen.borrow_mut().push(command.to_vec());
Ok(self.outcome.clone())
}
}
#[test]
fn exec_argv_builds_the_podman_exec_command() {
assert_eq!(
exec_argv("draupnir-cache", &["redis-cli", "ping"]),
vec!["exec", "draupnir-cache", "redis-cli", "ping"]
);
assert_eq!(exec_argv("draupnir-x", &["true"]), vec!["exec", "draupnir-x", "true"]);
}
#[test]
fn exec_assembles_the_command_and_returns_the_outcome() {
let recorder = ExecRecorder {
outcome: ExecOutcome {
exit_code: Some(0),
stdout: vec!["PONG".into()],
stderr: vec![],
},
..Default::default()
};
let m = Machine::started("draupnir-cache", &BootSpec::container("cache", "redis:7"));
let out = recorder.exec(&m, &["redis-cli", "ping"]).unwrap();
assert_eq!(
recorder.seen.borrow().as_slice(),
&[vec![
"exec".to_string(),
"draupnir-cache".to_string(),
"redis-cli".to_string(),
"ping".to_string(),
]]
);
assert_eq!(out.exit_code, Some(0));
assert_eq!(out.stdout, vec!["PONG"]);
}
#[test]
fn exec_is_rejected_on_a_non_container_machine() {
let recorder = ExecRecorder::default();
let kvm = Machine::started(
"vm-1",
&BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz"),
);
assert!(matches!(recorder.exec(&kvm, &["ls"]), Err(Error::Spec(_))));
assert!(recorder.seen.borrow().is_empty(), "guard runs before the engine");
}
#[test]
fn exec_rejects_an_empty_argv() {
let recorder = ExecRecorder::default();
let m = Machine::started("draupnir-cache", &BootSpec::container("cache", "redis:7"));
assert!(matches!(recorder.exec(&m, &[]), Err(Error::Spec(_))));
assert!(recorder.seen.borrow().is_empty());
}
#[test]
fn exec_without_an_engine_is_unsupported_not_faked() {
struct NoEngine;
impl ContainerControl for NoEngine {
fn container_state(&self, _m: &Machine) -> ContainerState {
ContainerState::Gone
}
fn drain_logs(&self, _m: &Machine) -> Vec<String> {
Vec::new()
}
fn stop(&self, _m: &Machine) {}
}
let m = Machine::started("draupnir-x", &BootSpec::container("x", "img:1"));
assert!(matches!(NoEngine.exec(&m, &["true"]), Err(Error::Unsupported(_))));
}
#[cfg(feature = "backend-oci")]
#[test]
fn create_body_empty_spec_is_bare() {
let body = Engine::create_body("scratch", &[], &[], &[]);
assert_eq!(body.image.as_deref(), Some("scratch"));
assert!(body.cmd.is_none());
assert!(body.env.is_none());
assert!(body.exposed_ports.is_none());
assert!(body.host_config.is_none());
}
#[cfg(feature = "backend-oci")]
#[test]
fn context_tar_packs_the_build_context() {
let dir = std::env::temp_dir().join(format!("draupnir-ctx-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("Containerfile"), b"FROM scratch\n").unwrap();
std::fs::write(dir.join("sub").join("app.txt"), b"hi").unwrap();
let bytes = context_tar(&dir).unwrap();
assert!(!bytes.is_empty(), "the tar carries the context");
let mut ar = tar::Archive::new(std::io::Cursor::new(bytes));
let names: Vec<String> = ar
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert!(
names.iter().any(|n| n.ends_with("Containerfile")),
"Containerfile packed: {names:?}"
);
assert!(
names.iter().any(|n| n.ends_with("sub/app.txt")),
"nested file packed: {names:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(feature = "backend-oci"))]
#[test]
fn build_and_extract_are_unsupported_without_the_engine() {
let boot = ContainerBoot::new();
assert!(matches!(
boot.build_image(Path::new("."), "Containerfile", "x:test"),
Err(Error::Unsupported(_))
));
assert!(matches!(
boot.extract_path("x:test", "/out", Path::new("/tmp/draupnir-x")),
Err(Error::Unsupported(_))
));
}
}