use crate::{Boot, BootSpec, Error, ImageSource, Lifecycle, Machine, PowerState, Result};
#[cfg(feature = "backend-oci")]
use std::sync::{Arc, Mutex};
#[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)
}
#[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(image, &name, &env)?;
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()))
}
}
}
#[cfg(feature = "backend-oci")]
struct Engine {
docker: bollard::Docker,
rt: tokio::runtime::Runtime,
}
#[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 })
}
fn create_and_start(&self, image: &str, name: &str, env: &[String]) -> Result<()> {
use bollard::models::ContainerCreateBody;
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 = ContainerCreateBody {
image: Some(image.to_string()),
env: if env.is_empty() { None } else { Some(env.to_vec()) },
..Default::default()
};
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>(())
})
}
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;
});
}
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,
}
})
}
}
#[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");
}
}