1use std::collections::{BTreeMap, HashMap};
6use std::fmt;
7use std::time::{Duration, Instant};
8
9use bollard::exec::{CreateExecOptions, StartExecResults};
10use bollard::models::{
11 ContainerCreateBody, CreateImageInfo, HostConfig, Mount, NetworkCreateRequest, SystemInfo,
12 VolumeCreateRequest,
13};
14use bollard::query_parameters::{
15 CreateContainerOptionsBuilder, CreateImageOptionsBuilder, DataUsageOptionsBuilder,
16 ListContainersOptionsBuilder, LogsOptionsBuilder, RemoveContainerOptionsBuilder,
17 RemoveVolumeOptionsBuilder, UploadToContainerOptionsBuilder, WaitContainerOptionsBuilder,
18};
19use futures_util::stream::BoxStream;
20use futures_util::{StreamExt, TryStreamExt};
21use tracing::info;
22
23use crate::config::ByteSize;
24use crate::isolation::Isolation;
25
26pub use bollard::errors::Error;
27
28#[derive(Debug, Clone)]
30pub struct RunnerSpec {
31 pub name: String,
33 pub image: String,
35 pub profile: String,
37 pub slice: String,
39 pub cpus: u32,
41 pub memory: ByteSize,
43 pub env: Vec<String>,
45 pub mounts: Vec<Mount>,
47 pub network: Option<String>,
49 pub labels: BTreeMap<String, String>,
51 pub entrypoint: Option<Vec<String>>,
53 pub jit_config: Option<JitConfig>,
55}
56
57pub const JIT_CONFIG_PATH: &str = "/run/runnerd/jitconfig";
60
61#[derive(Clone)]
63pub struct JitConfig(pub String);
64
65impl fmt::Debug for JitConfig {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.write_str("JitConfig(<redacted>)")
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct RunnerState {
74 pub running: bool,
76 pub exit_code: Option<i64>,
78 pub privileged: bool,
80 pub runtime: Option<String>,
82 pub pid: Option<u32>,
84 pub address: Option<String>,
86 pub started_at: Option<u64>,
88 pub memory: Option<u64>,
90 pub cpus: Option<u64>,
92}
93
94pub type Output = BoxStream<'static, Result<Vec<u8>, Error>>;
96
97pub fn create_body(spec: &RunnerSpec, isolation: &dyn Isolation) -> ContainerCreateBody {
99 let memory = i64::try_from(spec.memory.0).unwrap_or(i64::MAX);
101 let mut host_config = HostConfig {
102 cgroup_parent: Some(spec.slice.clone()),
103 memory: Some(memory),
104 memory_swap: Some(memory),
106 nano_cpus: Some(i64::from(spec.cpus) * 1_000_000_000),
107 init: Some(true),
108 mounts: Some(spec.mounts.clone()),
109 network_mode: spec.network.clone(),
110 ..Default::default()
111 };
112 isolation.configure(&mut host_config);
113 let mut labels: HashMap<String, String> = spec.labels.clone().into_iter().collect();
114 labels.insert("runnerd.profile".to_owned(), spec.profile.clone());
115 labels.insert("runnerd.isolation".to_owned(), isolation.name().to_owned());
116 ContainerCreateBody {
117 image: Some(spec.image.clone()),
118 env: Some(spec.env.clone()),
119 entrypoint: spec.entrypoint.clone(),
120 labels: Some(labels),
121 host_config: Some(host_config),
122 ..Default::default()
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Container {
129 pub id: String,
131 pub name: String,
133 pub running: bool,
135 pub labels: HashMap<String, String>,
137}
138
139pub fn is_not_found(error: &Error) -> bool {
141 matches!(
142 error,
143 Error::DockerResponseServerError {
144 status_code: 404,
145 ..
146 }
147 )
148}
149
150pub trait DockerApi: Send + Sync {
153 fn start(
155 &self,
156 spec: &RunnerSpec,
157 isolation: &dyn Isolation,
158 ) -> impl Future<Output = Result<String, Error>> + Send;
159 fn inspect(&self, container: &str) -> impl Future<Output = Result<RunnerState, Error>> + Send;
161 fn remove(&self, container: &str) -> impl Future<Output = Result<(), Error>> + Send;
163 fn run(
165 &self,
166 name: &str,
167 body: ContainerCreateBody,
168 ) -> impl Future<Output = Result<String, Error>> + Send;
169 fn list(&self, label: &str) -> impl Future<Output = Result<Vec<Container>, Error>> + Send;
171 fn wait(&self, container: &str) -> impl Future<Output = Result<i64, Error>> + Send;
173 fn logs(&self, container: &str) -> impl Future<Output = Result<String, Error>> + Send;
175 fn logs_since(
178 &self,
179 container: &str,
180 since: u64,
181 ) -> impl Future<Output = Result<String, Error>> + Send;
182 fn exec(
185 &self,
186 container: &str,
187 command: Vec<String>,
188 ) -> impl Future<Output = Result<Output, Error>> + Send;
189 fn image_present(&self, image: &str) -> impl Future<Output = Result<bool, Error>> + Send;
191 fn pull(&self, image: &str) -> impl Future<Output = Result<(), Error>> + Send;
193 fn ensure_network(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
195 fn ensure_volume(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
197 fn remove_volume(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
199 fn volume_sizes(&self) -> impl Future<Output = Result<BTreeMap<String, u64>, Error>> + Send;
201}
202
203#[derive(Debug)]
205pub struct Docker {
206 api: bollard::Docker,
207}
208
209impl Docker {
210 pub fn connect() -> Result<Docker, Error> {
212 connect_to(std::env::var("DOCKER_HOST").ok().as_deref())
213 }
214
215 pub async fn info(&self) -> Result<SystemInfo, Error> {
217 self.api.info().await
218 }
219
220 pub async fn image_labels(
222 &self,
223 image: &str,
224 ) -> Result<Option<HashMap<String, String>>, Error> {
225 match self.api.inspect_image(image).await {
226 Ok(inspect) => Ok(Some(
227 inspect
228 .config
229 .and_then(|config| config.labels)
230 .unwrap_or_default(),
231 )),
232 Err(error) if is_not_found(&error) => Ok(None),
233 Err(error) => Err(error),
234 }
235 }
236
237 pub async fn registry_platforms(&self, image: &str) -> Result<Vec<String>, Error> {
240 let inspect = self.api.inspect_registry_image(image, None).await?;
241 Ok(inspect
242 .platforms
243 .into_iter()
244 .filter_map(|platform| Some(format!("{}/{}", platform.os?, platform.architecture?)))
245 .filter(|platform| platform != "unknown/unknown")
247 .collect())
248 }
249}
250
251impl DockerApi for Docker {
252 async fn start(&self, spec: &RunnerSpec, isolation: &dyn Isolation) -> Result<String, Error> {
253 let options = CreateContainerOptionsBuilder::new()
254 .name(&spec.name)
255 .build();
256 let id = self
257 .api
258 .create_container(Some(options), create_body(spec, isolation))
259 .await?
260 .id;
261 let started = async {
262 if let Some(jit_config) = &spec.jit_config {
263 let options = UploadToContainerOptionsBuilder::new().path("/").build();
264 let tar = jit_config_tar(jit_config)?;
265 let tar = futures_util::stream::once(std::future::ready(tar.into()));
267 self.api
268 .upload_to_container(&id, Some(options), bollard::body_stream(tar))
269 .await?;
270 }
271 self.api.start_container(&id, None).await
272 };
273 if let Err(error) = started.await {
274 let _ = self.remove(&id).await;
276 return Err(error);
277 }
278 Ok(id)
279 }
280
281 async fn inspect(&self, container: &str) -> Result<RunnerState, Error> {
282 let response = self.api.inspect_container(container, None).await?;
283 let state = response.state.unwrap_or_default();
284 let host_config = response.host_config.unwrap_or_default();
285 let address = response
286 .network_settings
287 .and_then(|settings| settings.networks)
288 .and_then(|networks| {
289 networks
290 .into_values()
291 .find_map(|network| network.ip_address.filter(|address| !address.is_empty()))
292 });
293 Ok(RunnerState {
294 running: state.running.unwrap_or(false),
295 exit_code: state.exit_code,
296 privileged: host_config.privileged.unwrap_or(false),
297 runtime: host_config.runtime,
298 pid: state
299 .pid
300 .and_then(|pid| u32::try_from(pid).ok())
301 .filter(|pid| *pid > 0),
302 address,
303 started_at: state.started_at.as_deref().and_then(crate::time::parse_ms),
304 memory: host_config
305 .memory
306 .and_then(|memory| u64::try_from(memory).ok())
307 .filter(|memory| *memory > 0),
308 cpus: host_config
309 .nano_cpus
310 .and_then(|nano| u64::try_from(nano / 1_000_000).ok())
311 .filter(|cpus| *cpus > 0),
312 })
313 }
314
315 async fn remove(&self, container: &str) -> Result<(), Error> {
316 let options = RemoveContainerOptionsBuilder::new().force(true).build();
317 self.api.remove_container(container, Some(options)).await
318 }
319
320 async fn run(&self, name: &str, body: ContainerCreateBody) -> Result<String, Error> {
321 let options = CreateContainerOptionsBuilder::new().name(name).build();
322 let id = self.api.create_container(Some(options), body).await?.id;
323 if let Err(error) = self.api.start_container(&id, None).await {
324 let _ = self.remove(&id).await;
325 return Err(error);
326 }
327 Ok(id)
328 }
329
330 async fn list(&self, label: &str) -> Result<Vec<Container>, Error> {
331 let options = ListContainersOptionsBuilder::new()
332 .all(true)
333 .filters(&HashMap::from([("label", vec![label])]))
334 .build();
335 let containers = self.api.list_containers(Some(options)).await?;
336 Ok(containers
337 .into_iter()
338 .map(|container| Container {
339 id: container.id.unwrap_or_default(),
340 name: container
341 .names
342 .and_then(|names| names.into_iter().next())
343 .unwrap_or_default()
344 .trim_start_matches('/')
345 .to_owned(),
346 running: container.state
347 == Some(bollard::models::ContainerSummaryStateEnum::RUNNING),
348 labels: container.labels.unwrap_or_default(),
349 })
350 .collect())
351 }
352
353 async fn wait(&self, container: &str) -> Result<i64, Error> {
354 let options = WaitContainerOptionsBuilder::new()
355 .condition("not-running")
356 .build();
357 let mut responses = self.api.wait_container(container, Some(options));
358 match responses.next().await {
359 Some(Ok(response)) => Ok(response.status_code),
360 Some(Err(Error::DockerContainerWaitError { code, .. })) => Ok(code),
362 Some(Err(error)) => Err(error),
363 None => Ok(0),
364 }
365 }
366
367 async fn logs(&self, container: &str) -> Result<String, Error> {
368 let options = LogsOptionsBuilder::new().stdout(true).stderr(true).build();
369 let chunks: Vec<_> = self
370 .api
371 .logs(container, Some(options))
372 .try_collect()
373 .await?;
374 Ok(chunks.iter().map(ToString::to_string).collect())
375 }
376
377 async fn logs_since(&self, container: &str, since: u64) -> Result<String, Error> {
378 let options = LogsOptionsBuilder::new()
379 .stdout(true)
380 .stderr(true)
381 .timestamps(true)
382 .since(i32::try_from(since).unwrap_or(i32::MAX))
383 .build();
384 let chunks: Vec<_> = self
385 .api
386 .logs(container, Some(options))
387 .try_collect()
388 .await?;
389 Ok(chunks.iter().map(ToString::to_string).collect())
390 }
391
392 async fn exec(&self, container: &str, command: Vec<String>) -> Result<Output, Error> {
393 let options = CreateExecOptions {
394 cmd: Some(command),
395 user: Some("root".to_owned()),
396 attach_stdout: Some(true),
397 attach_stderr: Some(true),
398 ..Default::default()
399 };
400 let exec = self.api.create_exec(container, options).await?;
401 match self.api.start_exec(&exec.id, None).await? {
402 StartExecResults::Attached { output, .. } => Ok(output
403 .try_filter_map(|output| {
404 std::future::ready(Ok(match output {
405 bollard::container::LogOutput::StdOut { message } => Some(message.to_vec()),
406 _ => None,
407 }))
408 })
409 .boxed()),
410 StartExecResults::Detached => Ok(futures_util::stream::empty().boxed()),
411 }
412 }
413
414 async fn image_present(&self, image: &str) -> Result<bool, Error> {
415 Ok(self.image_labels(image).await?.is_some())
416 }
417
418 async fn pull(&self, image: &str) -> Result<(), Error> {
419 let options = CreateImageOptionsBuilder::new().from_image(image).build();
420 let mut stream = self.api.create_image(Some(options), None, None);
421 let mut progress = PullProgress::default();
422 let mut logged = Instant::now();
423 info!("pulling {image}");
424 while let Some(update) = stream.try_next().await? {
425 progress.update(&update);
426 if logged.elapsed() >= PULL_LOG_INTERVAL {
427 logged = Instant::now();
428 info!("pulling {image}: {progress}");
429 }
430 }
431 info!("pulled {image}");
432 Ok(())
433 }
434
435 async fn ensure_network(&self, name: &str) -> Result<(), Error> {
436 match self.api.inspect_network(name, None).await {
437 Err(error) if is_not_found(&error) => {
438 let request = NetworkCreateRequest {
439 name: name.to_owned(),
440 driver: Some("bridge".to_owned()),
441 ..Default::default()
442 };
443 self.api.create_network(request).await.map(|_| ())
444 }
445 other => other.map(|_| ()),
446 }
447 }
448
449 async fn ensure_volume(&self, name: &str) -> Result<(), Error> {
450 let request = VolumeCreateRequest {
452 name: Some(name.to_owned()),
453 ..Default::default()
454 };
455 self.api.create_volume(request).await.map(|_| ())
456 }
457
458 async fn remove_volume(&self, name: &str) -> Result<(), Error> {
459 let options = RemoveVolumeOptionsBuilder::new().build();
460 match self.api.remove_volume(name, Some(options)).await {
461 Err(error) if is_not_found(&error) => Ok(()),
462 other => other,
463 }
464 }
465
466 async fn volume_sizes(&self) -> Result<BTreeMap<String, u64>, Error> {
467 let options = DataUsageOptionsBuilder::new().verbose(true).build();
469 let usage = self.api.df(Some(options)).await?;
470 Ok(usage
472 .volume_usage
473 .and_then(|volumes| volumes.items)
474 .unwrap_or_default()
475 .into_iter()
476 .filter_map(|volume| {
477 let name = volume.get("Name")?.as_str()?.to_owned();
478 let size = volume.get("UsageData")?.get("Size")?.as_u64()?;
479 Some((name, size))
480 })
481 .collect())
482 }
483}
484
485const PULL_LOG_INTERVAL: Duration = Duration::from_secs(10);
487
488#[derive(Debug, Default)]
490struct PullProgress {
491 layers: BTreeMap<String, Layer>,
492}
493
494#[derive(Debug, Default)]
495struct Layer {
496 downloaded: u64,
497 size: u64,
498 done: bool,
499}
500
501impl PullProgress {
502 fn update(&mut self, update: &CreateImageInfo) {
503 let (Some(id), Some(status)) = (&update.id, &update.status) else {
504 return;
505 };
506 let bytes = |value: Option<i64>| value.and_then(|value| u64::try_from(value).ok());
507 let detail = update.progress_detail.as_ref();
508 match status.as_str() {
509 "Pulling fs layer" | "Waiting" => {
510 self.layers.entry(id.clone()).or_default();
511 }
512 "Downloading" => {
513 let layer = self.layers.entry(id.clone()).or_default();
514 layer.downloaded =
515 bytes(detail.and_then(|detail| detail.current)).unwrap_or(layer.downloaded);
516 layer.size = bytes(detail.and_then(|detail| detail.total)).unwrap_or(layer.size);
517 }
518 "Download complete" => {
519 if let Some(layer) = self.layers.get_mut(id) {
520 layer.downloaded = layer.size;
521 }
522 }
523 "Pull complete" | "Already exists" => {
524 let layer = self.layers.entry(id.clone()).or_default();
525 layer.downloaded = layer.size;
526 layer.done = true;
527 }
528 _ => {}
529 }
530 }
531}
532
533impl fmt::Display for PullProgress {
534 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
535 let done = self.layers.values().filter(|layer| layer.done).count();
536 let downloaded: u64 = self.layers.values().map(|layer| layer.downloaded).sum();
537 let size: u64 = self.layers.values().map(|layer| layer.size).sum();
538 write!(
539 f,
540 "{done} of {} layers done, {} of {} downloaded",
541 self.layers.len(),
542 ByteSize(downloaded),
543 ByteSize(size)
544 )
545 }
546}
547
548fn connect_to(docker_host: Option<&str>) -> Result<Docker, Error> {
549 let api = match docker_host.filter(|host| !host.is_empty()) {
550 None => bollard::Docker::connect_with_socket_defaults()?,
551 Some(host) if host.starts_with("unix://") => {
552 bollard::Docker::connect_with_socket(host, 120, bollard::API_DEFAULT_VERSION)?
553 }
554 Some(host) => {
556 return Err(Error::UnsupportedURISchemeError {
557 uri: host.to_owned(),
558 });
559 }
560 };
561 Ok(Docker { api })
562}
563
564fn jit_config_tar(jit_config: &JitConfig) -> std::io::Result<Vec<u8>> {
566 let mut tar = tar::Builder::new(Vec::new());
567 let path = JIT_CONFIG_PATH.trim_start_matches('/');
568 let mut dir = tar::Header::new_gnu();
569 dir.set_entry_type(tar::EntryType::Directory);
570 dir.set_mode(0o700);
571 dir.set_size(0);
572 tar.append_data(
573 &mut dir,
574 path.rsplit_once('/').map_or("", |(dir, _)| dir),
575 std::io::empty(),
576 )?;
577 let mut file = tar::Header::new_gnu();
578 file.set_mode(0o400);
579 file.set_size(jit_config.0.len() as u64);
580 tar.append_data(&mut file, path, jit_config.0.as_bytes())?;
581 tar.into_inner()
582}
583
584#[cfg(test)]
585mod tests;