1use std::collections::{BTreeMap, HashMap};
6use std::fmt;
7
8use bollard::exec::{CreateExecOptions, StartExecResults};
9use bollard::models::{
10 ContainerCreateBody, HostConfig, Mount, NetworkCreateRequest, SystemInfo, VolumeCreateRequest,
11};
12use bollard::query_parameters::{
13 CreateContainerOptionsBuilder, CreateImageOptionsBuilder, DataUsageOptionsBuilder,
14 ListContainersOptionsBuilder, LogsOptionsBuilder, RemoveContainerOptionsBuilder,
15 RemoveVolumeOptionsBuilder, UploadToContainerOptionsBuilder, WaitContainerOptionsBuilder,
16};
17use futures_util::stream::BoxStream;
18use futures_util::{StreamExt, TryStreamExt};
19
20use crate::config::ByteSize;
21use crate::isolation::Isolation;
22
23pub use bollard::errors::Error;
24
25#[derive(Debug, Clone)]
27pub struct RunnerSpec {
28 pub name: String,
30 pub image: String,
32 pub profile: String,
34 pub slice: String,
36 pub cpus: u32,
38 pub memory: ByteSize,
40 pub env: Vec<String>,
42 pub mounts: Vec<Mount>,
44 pub network: Option<String>,
46 pub labels: BTreeMap<String, String>,
48 pub entrypoint: Option<Vec<String>>,
50 pub jit_config: Option<JitConfig>,
52}
53
54pub const JIT_CONFIG_PATH: &str = "/run/runnerd/jitconfig";
57
58#[derive(Clone)]
60pub struct JitConfig(pub String);
61
62impl fmt::Debug for JitConfig {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str("JitConfig(<redacted>)")
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct RunnerState {
71 pub running: bool,
73 pub exit_code: Option<i64>,
75 pub privileged: bool,
77 pub runtime: Option<String>,
79 pub pid: Option<u32>,
81 pub address: Option<String>,
83 pub started_at: Option<u64>,
85 pub memory: Option<u64>,
87 pub cpus: Option<u64>,
89}
90
91pub type Output = BoxStream<'static, Result<Vec<u8>, Error>>;
93
94pub fn create_body(spec: &RunnerSpec, isolation: &dyn Isolation) -> ContainerCreateBody {
96 let memory = i64::try_from(spec.memory.0).unwrap_or(i64::MAX);
98 let mut host_config = HostConfig {
99 cgroup_parent: Some(spec.slice.clone()),
100 memory: Some(memory),
101 memory_swap: Some(memory),
103 nano_cpus: Some(i64::from(spec.cpus) * 1_000_000_000),
104 init: Some(true),
105 mounts: Some(spec.mounts.clone()),
106 network_mode: spec.network.clone(),
107 ..Default::default()
108 };
109 isolation.configure(&mut host_config);
110 let mut labels: HashMap<String, String> = spec.labels.clone().into_iter().collect();
111 labels.insert("runnerd.profile".to_owned(), spec.profile.clone());
112 labels.insert("runnerd.isolation".to_owned(), isolation.name().to_owned());
113 ContainerCreateBody {
114 image: Some(spec.image.clone()),
115 env: Some(spec.env.clone()),
116 entrypoint: spec.entrypoint.clone(),
117 labels: Some(labels),
118 host_config: Some(host_config),
119 ..Default::default()
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Container {
126 pub id: String,
128 pub name: String,
130 pub running: bool,
132 pub labels: HashMap<String, String>,
134}
135
136pub fn is_not_found(error: &Error) -> bool {
138 matches!(
139 error,
140 Error::DockerResponseServerError {
141 status_code: 404,
142 ..
143 }
144 )
145}
146
147pub trait DockerApi: Send + Sync {
150 fn start(
152 &self,
153 spec: &RunnerSpec,
154 isolation: &dyn Isolation,
155 ) -> impl Future<Output = Result<String, Error>> + Send;
156 fn inspect(&self, container: &str) -> impl Future<Output = Result<RunnerState, Error>> + Send;
158 fn remove(&self, container: &str) -> impl Future<Output = Result<(), Error>> + Send;
160 fn run(
162 &self,
163 name: &str,
164 body: ContainerCreateBody,
165 ) -> impl Future<Output = Result<String, Error>> + Send;
166 fn list(&self, label: &str) -> impl Future<Output = Result<Vec<Container>, Error>> + Send;
168 fn wait(&self, container: &str) -> impl Future<Output = Result<i64, Error>> + Send;
170 fn logs(&self, container: &str) -> impl Future<Output = Result<String, Error>> + Send;
172 fn logs_since(
175 &self,
176 container: &str,
177 since: u64,
178 ) -> impl Future<Output = Result<String, Error>> + Send;
179 fn exec(
182 &self,
183 container: &str,
184 command: Vec<String>,
185 ) -> impl Future<Output = Result<Output, Error>> + Send;
186 fn ensure_image(&self, image: &str) -> impl Future<Output = Result<(), Error>> + Send;
188 fn ensure_network(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
190 fn ensure_volume(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
192 fn remove_volume(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
194 fn volume_sizes(&self) -> impl Future<Output = Result<BTreeMap<String, u64>, Error>> + Send;
196}
197
198#[derive(Debug)]
200pub struct Docker {
201 api: bollard::Docker,
202}
203
204impl Docker {
205 pub fn connect() -> Result<Docker, Error> {
207 connect_to(std::env::var("DOCKER_HOST").ok().as_deref())
208 }
209
210 pub async fn info(&self) -> Result<SystemInfo, Error> {
212 self.api.info().await
213 }
214}
215
216impl DockerApi for Docker {
217 async fn start(&self, spec: &RunnerSpec, isolation: &dyn Isolation) -> Result<String, Error> {
218 let options = CreateContainerOptionsBuilder::new()
219 .name(&spec.name)
220 .build();
221 let id = self
222 .api
223 .create_container(Some(options), create_body(spec, isolation))
224 .await?
225 .id;
226 let started = async {
227 if let Some(jit_config) = &spec.jit_config {
228 let options = UploadToContainerOptionsBuilder::new().path("/").build();
229 let tar = jit_config_tar(jit_config)?;
230 let tar = futures_util::stream::once(std::future::ready(tar.into()));
232 self.api
233 .upload_to_container(&id, Some(options), bollard::body_stream(tar))
234 .await?;
235 }
236 self.api.start_container(&id, None).await
237 };
238 if let Err(error) = started.await {
239 let _ = self.remove(&id).await;
241 return Err(error);
242 }
243 Ok(id)
244 }
245
246 async fn inspect(&self, container: &str) -> Result<RunnerState, Error> {
247 let response = self.api.inspect_container(container, None).await?;
248 let state = response.state.unwrap_or_default();
249 let host_config = response.host_config.unwrap_or_default();
250 let address = response
251 .network_settings
252 .and_then(|settings| settings.networks)
253 .and_then(|networks| {
254 networks
255 .into_values()
256 .find_map(|network| network.ip_address.filter(|address| !address.is_empty()))
257 });
258 Ok(RunnerState {
259 running: state.running.unwrap_or(false),
260 exit_code: state.exit_code,
261 privileged: host_config.privileged.unwrap_or(false),
262 runtime: host_config.runtime,
263 pid: state
264 .pid
265 .and_then(|pid| u32::try_from(pid).ok())
266 .filter(|pid| *pid > 0),
267 address,
268 started_at: state.started_at.as_deref().and_then(crate::time::parse_ms),
269 memory: host_config
270 .memory
271 .and_then(|memory| u64::try_from(memory).ok())
272 .filter(|memory| *memory > 0),
273 cpus: host_config
274 .nano_cpus
275 .and_then(|nano| u64::try_from(nano / 1_000_000).ok())
276 .filter(|cpus| *cpus > 0),
277 })
278 }
279
280 async fn remove(&self, container: &str) -> Result<(), Error> {
281 let options = RemoveContainerOptionsBuilder::new().force(true).build();
282 self.api.remove_container(container, Some(options)).await
283 }
284
285 async fn run(&self, name: &str, body: ContainerCreateBody) -> Result<String, Error> {
286 let options = CreateContainerOptionsBuilder::new().name(name).build();
287 let id = self.api.create_container(Some(options), body).await?.id;
288 if let Err(error) = self.api.start_container(&id, None).await {
289 let _ = self.remove(&id).await;
290 return Err(error);
291 }
292 Ok(id)
293 }
294
295 async fn list(&self, label: &str) -> Result<Vec<Container>, Error> {
296 let options = ListContainersOptionsBuilder::new()
297 .all(true)
298 .filters(&HashMap::from([("label", vec![label])]))
299 .build();
300 let containers = self.api.list_containers(Some(options)).await?;
301 Ok(containers
302 .into_iter()
303 .map(|container| Container {
304 id: container.id.unwrap_or_default(),
305 name: container
306 .names
307 .and_then(|names| names.into_iter().next())
308 .unwrap_or_default()
309 .trim_start_matches('/')
310 .to_owned(),
311 running: container.state
312 == Some(bollard::models::ContainerSummaryStateEnum::RUNNING),
313 labels: container.labels.unwrap_or_default(),
314 })
315 .collect())
316 }
317
318 async fn wait(&self, container: &str) -> Result<i64, Error> {
319 let options = WaitContainerOptionsBuilder::new()
320 .condition("not-running")
321 .build();
322 let mut responses = self.api.wait_container(container, Some(options));
323 match responses.next().await {
324 Some(Ok(response)) => Ok(response.status_code),
325 Some(Err(Error::DockerContainerWaitError { code, .. })) => Ok(code),
327 Some(Err(error)) => Err(error),
328 None => Ok(0),
329 }
330 }
331
332 async fn logs(&self, container: &str) -> Result<String, Error> {
333 let options = LogsOptionsBuilder::new().stdout(true).stderr(true).build();
334 let chunks: Vec<_> = self
335 .api
336 .logs(container, Some(options))
337 .try_collect()
338 .await?;
339 Ok(chunks.iter().map(ToString::to_string).collect())
340 }
341
342 async fn logs_since(&self, container: &str, since: u64) -> Result<String, Error> {
343 let options = LogsOptionsBuilder::new()
344 .stdout(true)
345 .stderr(true)
346 .timestamps(true)
347 .since(i32::try_from(since).unwrap_or(i32::MAX))
348 .build();
349 let chunks: Vec<_> = self
350 .api
351 .logs(container, Some(options))
352 .try_collect()
353 .await?;
354 Ok(chunks.iter().map(ToString::to_string).collect())
355 }
356
357 async fn exec(&self, container: &str, command: Vec<String>) -> Result<Output, Error> {
358 let options = CreateExecOptions {
359 cmd: Some(command),
360 user: Some("root".to_owned()),
361 attach_stdout: Some(true),
362 attach_stderr: Some(true),
363 ..Default::default()
364 };
365 let exec = self.api.create_exec(container, options).await?;
366 match self.api.start_exec(&exec.id, None).await? {
367 StartExecResults::Attached { output, .. } => Ok(output
368 .try_filter_map(|output| {
369 std::future::ready(Ok(match output {
370 bollard::container::LogOutput::StdOut { message } => Some(message.to_vec()),
371 _ => None,
372 }))
373 })
374 .boxed()),
375 StartExecResults::Detached => Ok(futures_util::stream::empty().boxed()),
376 }
377 }
378
379 async fn ensure_image(&self, image: &str) -> Result<(), Error> {
380 match self.api.inspect_image(image).await {
381 Err(error) if is_not_found(&error) => {
382 let options = CreateImageOptionsBuilder::new().from_image(image).build();
383 self.api
384 .create_image(Some(options), None, None)
385 .try_collect::<Vec<_>>()
386 .await?;
387 Ok(())
388 }
389 other => other.map(|_| ()),
390 }
391 }
392
393 async fn ensure_network(&self, name: &str) -> Result<(), Error> {
394 match self.api.inspect_network(name, None).await {
395 Err(error) if is_not_found(&error) => {
396 let request = NetworkCreateRequest {
397 name: name.to_owned(),
398 driver: Some("bridge".to_owned()),
399 ..Default::default()
400 };
401 self.api.create_network(request).await.map(|_| ())
402 }
403 other => other.map(|_| ()),
404 }
405 }
406
407 async fn ensure_volume(&self, name: &str) -> Result<(), Error> {
408 let request = VolumeCreateRequest {
410 name: Some(name.to_owned()),
411 ..Default::default()
412 };
413 self.api.create_volume(request).await.map(|_| ())
414 }
415
416 async fn remove_volume(&self, name: &str) -> Result<(), Error> {
417 let options = RemoveVolumeOptionsBuilder::new().build();
418 match self.api.remove_volume(name, Some(options)).await {
419 Err(error) if is_not_found(&error) => Ok(()),
420 other => other,
421 }
422 }
423
424 async fn volume_sizes(&self) -> Result<BTreeMap<String, u64>, Error> {
425 let options = DataUsageOptionsBuilder::new().verbose(true).build();
427 let usage = self.api.df(Some(options)).await?;
428 Ok(usage
430 .volume_usage
431 .and_then(|volumes| volumes.items)
432 .unwrap_or_default()
433 .into_iter()
434 .filter_map(|volume| {
435 let name = volume.get("Name")?.as_str()?.to_owned();
436 let size = volume.get("UsageData")?.get("Size")?.as_u64()?;
437 Some((name, size))
438 })
439 .collect())
440 }
441}
442
443fn connect_to(docker_host: Option<&str>) -> Result<Docker, Error> {
444 let api = match docker_host.filter(|host| !host.is_empty()) {
445 None => bollard::Docker::connect_with_socket_defaults()?,
446 Some(host) if host.starts_with("unix://") => {
447 bollard::Docker::connect_with_socket(host, 120, bollard::API_DEFAULT_VERSION)?
448 }
449 Some(host) => {
451 return Err(Error::UnsupportedURISchemeError {
452 uri: host.to_owned(),
453 });
454 }
455 };
456 Ok(Docker { api })
457}
458
459fn jit_config_tar(jit_config: &JitConfig) -> std::io::Result<Vec<u8>> {
461 let mut tar = tar::Builder::new(Vec::new());
462 let path = JIT_CONFIG_PATH.trim_start_matches('/');
463 let mut dir = tar::Header::new_gnu();
464 dir.set_entry_type(tar::EntryType::Directory);
465 dir.set_mode(0o700);
466 dir.set_size(0);
467 tar.append_data(
468 &mut dir,
469 path.rsplit_once('/').map_or("", |(dir, _)| dir),
470 std::io::empty(),
471 )?;
472 let mut file = tar::Header::new_gnu();
473 file.set_mode(0o400);
474 file.set_size(jit_config.0.len() as u64);
475 tar.append_data(&mut file, path, jit_config.0.as_bytes())?;
476 tar.into_inner()
477}
478
479#[cfg(test)]
480mod tests;