use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::time::{Duration, Instant};
use bollard::exec::{CreateExecOptions, StartExecResults};
use bollard::models::{
ContainerCreateBody, CreateImageInfo, HostConfig, Mount, NetworkCreateRequest, SystemInfo,
VolumeCreateRequest,
};
use bollard::query_parameters::{
CreateContainerOptionsBuilder, CreateImageOptionsBuilder, DataUsageOptionsBuilder,
ListContainersOptionsBuilder, LogsOptionsBuilder, RemoveContainerOptionsBuilder,
RemoveVolumeOptionsBuilder, UploadToContainerOptionsBuilder, WaitContainerOptionsBuilder,
};
use futures_util::stream::BoxStream;
use futures_util::{StreamExt, TryStreamExt};
use tracing::info;
use crate::config::ByteSize;
use crate::isolation::Isolation;
pub use bollard::errors::Error;
#[derive(Debug, Clone)]
pub struct RunnerSpec {
pub name: String,
pub image: String,
pub profile: String,
pub slice: String,
pub cpus: u32,
pub memory: ByteSize,
pub env: Vec<String>,
pub mounts: Vec<Mount>,
pub network: Option<String>,
pub labels: BTreeMap<String, String>,
pub entrypoint: Option<Vec<String>>,
pub jit_config: Option<JitConfig>,
}
pub const JIT_CONFIG_PATH: &str = "/run/runnerd/jitconfig";
#[derive(Clone)]
pub struct JitConfig(pub String);
impl fmt::Debug for JitConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("JitConfig(<redacted>)")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunnerState {
pub running: bool,
pub exit_code: Option<i64>,
pub privileged: bool,
pub runtime: Option<String>,
pub pid: Option<u32>,
pub address: Option<String>,
pub started_at: Option<u64>,
pub memory: Option<u64>,
pub cpus: Option<u64>,
}
pub type Output = BoxStream<'static, Result<Vec<u8>, Error>>;
pub fn create_body(spec: &RunnerSpec, isolation: &dyn Isolation) -> ContainerCreateBody {
let memory = i64::try_from(spec.memory.0).unwrap_or(i64::MAX);
let mut host_config = HostConfig {
cgroup_parent: Some(spec.slice.clone()),
memory: Some(memory),
memory_swap: Some(memory),
nano_cpus: Some(i64::from(spec.cpus) * 1_000_000_000),
init: Some(true),
mounts: Some(spec.mounts.clone()),
network_mode: spec.network.clone(),
..Default::default()
};
isolation.configure(&mut host_config);
let mut labels: HashMap<String, String> = spec.labels.clone().into_iter().collect();
labels.insert("runnerd.profile".to_owned(), spec.profile.clone());
labels.insert("runnerd.isolation".to_owned(), isolation.name().to_owned());
ContainerCreateBody {
image: Some(spec.image.clone()),
env: Some(spec.env.clone()),
entrypoint: spec.entrypoint.clone(),
labels: Some(labels),
host_config: Some(host_config),
..Default::default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Container {
pub id: String,
pub name: String,
pub running: bool,
pub labels: HashMap<String, String>,
}
pub fn is_not_found(error: &Error) -> bool {
matches!(
error,
Error::DockerResponseServerError {
status_code: 404,
..
}
)
}
pub trait DockerApi: Send + Sync {
fn start(
&self,
spec: &RunnerSpec,
isolation: &dyn Isolation,
) -> impl Future<Output = Result<String, Error>> + Send;
fn inspect(&self, container: &str) -> impl Future<Output = Result<RunnerState, Error>> + Send;
fn remove(&self, container: &str) -> impl Future<Output = Result<(), Error>> + Send;
fn run(
&self,
name: &str,
body: ContainerCreateBody,
) -> impl Future<Output = Result<String, Error>> + Send;
fn list(&self, label: &str) -> impl Future<Output = Result<Vec<Container>, Error>> + Send;
fn wait(&self, container: &str) -> impl Future<Output = Result<i64, Error>> + Send;
fn logs(&self, container: &str) -> impl Future<Output = Result<String, Error>> + Send;
fn logs_since(
&self,
container: &str,
since: u64,
) -> impl Future<Output = Result<String, Error>> + Send;
fn exec(
&self,
container: &str,
command: Vec<String>,
) -> impl Future<Output = Result<Output, Error>> + Send;
fn image_present(&self, image: &str) -> impl Future<Output = Result<bool, Error>> + Send;
fn pull(&self, image: &str) -> impl Future<Output = Result<(), Error>> + Send;
fn ensure_network(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
fn ensure_volume(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
fn remove_volume(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
fn volume_sizes(&self) -> impl Future<Output = Result<BTreeMap<String, u64>, Error>> + Send;
}
#[derive(Debug)]
pub struct Docker {
api: bollard::Docker,
}
impl Docker {
pub fn connect() -> Result<Docker, Error> {
connect_to(std::env::var("DOCKER_HOST").ok().as_deref())
}
pub async fn info(&self) -> Result<SystemInfo, Error> {
self.api.info().await
}
pub async fn image_labels(
&self,
image: &str,
) -> Result<Option<HashMap<String, String>>, Error> {
match self.api.inspect_image(image).await {
Ok(inspect) => Ok(Some(
inspect
.config
.and_then(|config| config.labels)
.unwrap_or_default(),
)),
Err(error) if is_not_found(&error) => Ok(None),
Err(error) => Err(error),
}
}
pub async fn registry_platforms(&self, image: &str) -> Result<Vec<String>, Error> {
let inspect = self.api.inspect_registry_image(image, None).await?;
Ok(inspect
.platforms
.into_iter()
.filter_map(|platform| Some(format!("{}/{}", platform.os?, platform.architecture?)))
.filter(|platform| platform != "unknown/unknown")
.collect())
}
}
impl DockerApi for Docker {
async fn start(&self, spec: &RunnerSpec, isolation: &dyn Isolation) -> Result<String, Error> {
let options = CreateContainerOptionsBuilder::new()
.name(&spec.name)
.build();
let id = self
.api
.create_container(Some(options), create_body(spec, isolation))
.await?
.id;
let started = async {
if let Some(jit_config) = &spec.jit_config {
let options = UploadToContainerOptionsBuilder::new().path("/").build();
let tar = jit_config_tar(jit_config)?;
let tar = futures_util::stream::once(std::future::ready(tar.into()));
self.api
.upload_to_container(&id, Some(options), bollard::body_stream(tar))
.await?;
}
self.api.start_container(&id, None).await
};
if let Err(error) = started.await {
let _ = self.remove(&id).await;
return Err(error);
}
Ok(id)
}
async fn inspect(&self, container: &str) -> Result<RunnerState, Error> {
let response = self.api.inspect_container(container, None).await?;
let state = response.state.unwrap_or_default();
let host_config = response.host_config.unwrap_or_default();
let address = response
.network_settings
.and_then(|settings| settings.networks)
.and_then(|networks| {
networks
.into_values()
.find_map(|network| network.ip_address.filter(|address| !address.is_empty()))
});
Ok(RunnerState {
running: state.running.unwrap_or(false),
exit_code: state.exit_code,
privileged: host_config.privileged.unwrap_or(false),
runtime: host_config.runtime,
pid: state
.pid
.and_then(|pid| u32::try_from(pid).ok())
.filter(|pid| *pid > 0),
address,
started_at: state.started_at.as_deref().and_then(crate::time::parse_ms),
memory: host_config
.memory
.and_then(|memory| u64::try_from(memory).ok())
.filter(|memory| *memory > 0),
cpus: host_config
.nano_cpus
.and_then(|nano| u64::try_from(nano / 1_000_000).ok())
.filter(|cpus| *cpus > 0),
})
}
async fn remove(&self, container: &str) -> Result<(), Error> {
let options = RemoveContainerOptionsBuilder::new().force(true).build();
self.api.remove_container(container, Some(options)).await
}
async fn run(&self, name: &str, body: ContainerCreateBody) -> Result<String, Error> {
let options = CreateContainerOptionsBuilder::new().name(name).build();
let id = self.api.create_container(Some(options), body).await?.id;
if let Err(error) = self.api.start_container(&id, None).await {
let _ = self.remove(&id).await;
return Err(error);
}
Ok(id)
}
async fn list(&self, label: &str) -> Result<Vec<Container>, Error> {
let options = ListContainersOptionsBuilder::new()
.all(true)
.filters(&HashMap::from([("label", vec![label])]))
.build();
let containers = self.api.list_containers(Some(options)).await?;
Ok(containers
.into_iter()
.map(|container| Container {
id: container.id.unwrap_or_default(),
name: container
.names
.and_then(|names| names.into_iter().next())
.unwrap_or_default()
.trim_start_matches('/')
.to_owned(),
running: container.state
== Some(bollard::models::ContainerSummaryStateEnum::RUNNING),
labels: container.labels.unwrap_or_default(),
})
.collect())
}
async fn wait(&self, container: &str) -> Result<i64, Error> {
let options = WaitContainerOptionsBuilder::new()
.condition("not-running")
.build();
let mut responses = self.api.wait_container(container, Some(options));
match responses.next().await {
Some(Ok(response)) => Ok(response.status_code),
Some(Err(Error::DockerContainerWaitError { code, .. })) => Ok(code),
Some(Err(error)) => Err(error),
None => Ok(0),
}
}
async fn logs(&self, container: &str) -> Result<String, Error> {
let options = LogsOptionsBuilder::new().stdout(true).stderr(true).build();
let chunks: Vec<_> = self
.api
.logs(container, Some(options))
.try_collect()
.await?;
Ok(chunks.iter().map(ToString::to_string).collect())
}
async fn logs_since(&self, container: &str, since: u64) -> Result<String, Error> {
let options = LogsOptionsBuilder::new()
.stdout(true)
.stderr(true)
.timestamps(true)
.since(i32::try_from(since).unwrap_or(i32::MAX))
.build();
let chunks: Vec<_> = self
.api
.logs(container, Some(options))
.try_collect()
.await?;
Ok(chunks.iter().map(ToString::to_string).collect())
}
async fn exec(&self, container: &str, command: Vec<String>) -> Result<Output, Error> {
let options = CreateExecOptions {
cmd: Some(command),
user: Some("root".to_owned()),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
};
let exec = self.api.create_exec(container, options).await?;
match self.api.start_exec(&exec.id, None).await? {
StartExecResults::Attached { output, .. } => Ok(output
.try_filter_map(|output| {
std::future::ready(Ok(match output {
bollard::container::LogOutput::StdOut { message } => Some(message.to_vec()),
_ => None,
}))
})
.boxed()),
StartExecResults::Detached => Ok(futures_util::stream::empty().boxed()),
}
}
async fn image_present(&self, image: &str) -> Result<bool, Error> {
Ok(self.image_labels(image).await?.is_some())
}
async fn pull(&self, image: &str) -> Result<(), Error> {
let options = CreateImageOptionsBuilder::new().from_image(image).build();
let mut stream = self.api.create_image(Some(options), None, None);
let mut progress = PullProgress::default();
let mut logged = Instant::now();
info!("pulling {image}");
while let Some(update) = stream.try_next().await? {
progress.update(&update);
if logged.elapsed() >= PULL_LOG_INTERVAL {
logged = Instant::now();
info!("pulling {image}: {progress}");
}
}
info!("pulled {image}");
Ok(())
}
async fn ensure_network(&self, name: &str) -> Result<(), Error> {
match self.api.inspect_network(name, None).await {
Err(error) if is_not_found(&error) => {
let request = NetworkCreateRequest {
name: name.to_owned(),
driver: Some("bridge".to_owned()),
..Default::default()
};
self.api.create_network(request).await.map(|_| ())
}
other => other.map(|_| ()),
}
}
async fn ensure_volume(&self, name: &str) -> Result<(), Error> {
let request = VolumeCreateRequest {
name: Some(name.to_owned()),
..Default::default()
};
self.api.create_volume(request).await.map(|_| ())
}
async fn remove_volume(&self, name: &str) -> Result<(), Error> {
let options = RemoveVolumeOptionsBuilder::new().build();
match self.api.remove_volume(name, Some(options)).await {
Err(error) if is_not_found(&error) => Ok(()),
other => other,
}
}
async fn volume_sizes(&self) -> Result<BTreeMap<String, u64>, Error> {
let options = DataUsageOptionsBuilder::new().verbose(true).build();
let usage = self.api.df(Some(options)).await?;
Ok(usage
.volume_usage
.and_then(|volumes| volumes.items)
.unwrap_or_default()
.into_iter()
.filter_map(|volume| {
let name = volume.get("Name")?.as_str()?.to_owned();
let size = volume.get("UsageData")?.get("Size")?.as_u64()?;
Some((name, size))
})
.collect())
}
}
const PULL_LOG_INTERVAL: Duration = Duration::from_secs(10);
#[derive(Debug, Default)]
struct PullProgress {
layers: BTreeMap<String, Layer>,
}
#[derive(Debug, Default)]
struct Layer {
downloaded: u64,
size: u64,
done: bool,
}
impl PullProgress {
fn update(&mut self, update: &CreateImageInfo) {
let (Some(id), Some(status)) = (&update.id, &update.status) else {
return;
};
let bytes = |value: Option<i64>| value.and_then(|value| u64::try_from(value).ok());
let detail = update.progress_detail.as_ref();
match status.as_str() {
"Pulling fs layer" | "Waiting" => {
self.layers.entry(id.clone()).or_default();
}
"Downloading" => {
let layer = self.layers.entry(id.clone()).or_default();
layer.downloaded =
bytes(detail.and_then(|detail| detail.current)).unwrap_or(layer.downloaded);
layer.size = bytes(detail.and_then(|detail| detail.total)).unwrap_or(layer.size);
}
"Download complete" => {
if let Some(layer) = self.layers.get_mut(id) {
layer.downloaded = layer.size;
}
}
"Pull complete" | "Already exists" => {
let layer = self.layers.entry(id.clone()).or_default();
layer.downloaded = layer.size;
layer.done = true;
}
_ => {}
}
}
}
impl fmt::Display for PullProgress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let done = self.layers.values().filter(|layer| layer.done).count();
let downloaded: u64 = self.layers.values().map(|layer| layer.downloaded).sum();
let size: u64 = self.layers.values().map(|layer| layer.size).sum();
write!(
f,
"{done} of {} layers done, {} of {} downloaded",
self.layers.len(),
ByteSize(downloaded),
ByteSize(size)
)
}
}
fn connect_to(docker_host: Option<&str>) -> Result<Docker, Error> {
let api = match docker_host.filter(|host| !host.is_empty()) {
None => bollard::Docker::connect_with_socket_defaults()?,
Some(host) if host.starts_with("unix://") => {
bollard::Docker::connect_with_socket(host, 120, bollard::API_DEFAULT_VERSION)?
}
Some(host) => {
return Err(Error::UnsupportedURISchemeError {
uri: host.to_owned(),
});
}
};
Ok(Docker { api })
}
fn jit_config_tar(jit_config: &JitConfig) -> std::io::Result<Vec<u8>> {
let mut tar = tar::Builder::new(Vec::new());
let path = JIT_CONFIG_PATH.trim_start_matches('/');
let mut dir = tar::Header::new_gnu();
dir.set_entry_type(tar::EntryType::Directory);
dir.set_mode(0o700);
dir.set_size(0);
tar.append_data(
&mut dir,
path.rsplit_once('/').map_or("", |(dir, _)| dir),
std::io::empty(),
)?;
let mut file = tar::Header::new_gnu();
file.set_mode(0o400);
file.set_size(jit_config.0.len() as u64);
tar.append_data(&mut file, path, jit_config.0.as_bytes())?;
tar.into_inner()
}
#[cfg(test)]
mod tests;