1use std::collections::{BTreeMap, HashMap};
7
8use bollard::models::{
9 ContainerCreateBody, EndpointSettings, HostConfig, Mount, MountType, NetworkingConfig,
10};
11
12use crate::api;
13use crate::config::{self, ByteSize};
14use crate::docker::{DockerApi, Error, RunnerSpec, is_not_found};
15use crate::isolation::Isolation;
16
17pub const MIRROR_IMAGE: &str =
19 "registry:3.1.2@sha256:01cd312e67026f06f5d89c3e133a4a9adc7cb9eeb9b1194e0b2954dfd770efd2";
20
21const MIRROR_ALIAS: &str = "mirror";
23
24const MIRROR_OOM_SCORE_ADJ: i64 = -500;
26
27#[derive(Debug, Clone)]
29pub struct Names {
30 prefix: String,
31}
32
33impl Names {
34 pub fn new(prefix: &str) -> Names {
36 Names {
37 prefix: prefix.to_owned(),
38 }
39 }
40
41 pub fn network(&self) -> String {
43 self.prefix.clone()
44 }
45
46 pub fn base(&self, profile: &str) -> String {
48 format!("{}-{profile}-base", self.prefix)
49 }
50
51 pub fn slot(&self, profile: &str, slot: usize) -> String {
53 format!("{}-{profile}-slot-{slot}", self.prefix)
54 }
55
56 pub fn pnpm(&self) -> String {
58 format!("{}-pnpm", self.prefix)
59 }
60
61 pub fn maven(&self) -> String {
63 format!("{}-maven", self.prefix)
64 }
65
66 pub fn slot_of(&self, profile: &str, volume: &str) -> Option<usize> {
68 volume
69 .strip_prefix(&format!("{}-{profile}-slot-", self.prefix))?
70 .parse()
71 .ok()
72 }
73
74 pub fn seed(&self, profile: &str) -> String {
76 format!("{}-{profile}-seed", self.prefix)
77 }
78
79 pub fn mirror(&self) -> String {
81 format!("{}-mirror", self.prefix)
82 }
83
84 pub fn build_cache(&self) -> String {
87 format!("{}-build-cache", self.prefix)
88 }
89}
90
91fn volume(source: String, target: &str, read_only: bool) -> Mount {
92 Mount {
93 target: Some(target.to_owned()),
94 source: Some(source),
95 typ: Some(MountType::VOLUME),
96 read_only: Some(read_only),
97 ..Default::default()
98 }
99}
100
101pub fn runner_mounts(names: &Names, profile: &str, slot: usize) -> Vec<Mount> {
103 vec![
104 volume(names.base(profile), "/base", true),
105 volume(names.slot(profile, slot), "/state", false),
106 volume(names.pnpm(), "/shared/pnpm", false),
107 volume(names.maven(), "/shared/maven", false),
108 ]
109}
110
111pub fn mirror_body(names: &Names, mirror: &config::Mirror, slice: &str) -> ContainerCreateBody {
114 let memory = i64::try_from(mirror.memory.0).unwrap_or(i64::MAX);
115 ContainerCreateBody {
116 image: Some(MIRROR_IMAGE.to_owned()),
117 env: Some(vec![
118 "REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io".to_owned(),
119 format!("REGISTRY_PROXY_TTL={}s", mirror.ttl.as_secs()),
120 "REGISTRY_STORAGE_DELETE_ENABLED=true".to_owned(),
122 ]),
123 labels: Some(HashMap::from([(
124 "runnerd.mirror".to_owned(),
125 String::new(),
126 )])),
127 host_config: Some(HostConfig {
128 cgroup_parent: Some(slice.to_owned()),
129 memory: Some(memory),
130 memory_swap: Some(memory),
131 nano_cpus: Some(i64::from(mirror.cpus) * 1_000_000_000),
132 oom_score_adj: Some(MIRROR_OOM_SCORE_ADJ),
135 mounts: Some(vec![volume(names.mirror(), "/var/lib/registry", false)]),
136 network_mode: Some(names.network()),
137 ..Default::default()
138 }),
139 networking_config: Some(NetworkingConfig {
140 endpoints_config: Some(HashMap::from([(
141 names.network(),
142 EndpointSettings {
143 aliases: Some(vec![MIRROR_ALIAS.to_owned()]),
144 ..Default::default()
145 },
146 )])),
147 }),
148 ..Default::default()
149 }
150}
151
152pub async fn prepare(
154 docker: &impl DockerApi,
155 names: &Names,
156 profile: &str,
157 slot: usize,
158) -> Result<(), Error> {
159 docker.ensure_network(&names.network()).await?;
160 for name in [
161 names.base(profile),
162 names.slot(profile, slot),
163 names.pnpm(),
164 names.maven(),
165 ] {
166 docker.ensure_volume(&name).await?;
167 }
168 Ok(())
169}
170
171pub async fn mirror_up(
173 docker: &impl DockerApi,
174 names: &Names,
175 mirror: &config::Mirror,
176 slice: &str,
177) -> Result<(), Error> {
178 match docker.inspect(&names.mirror()).await {
179 Ok(state) if state.running => return Ok(()),
180 Ok(_) => docker.remove(&names.mirror()).await?,
182 Err(error) if is_not_found(&error) => {}
183 Err(error) => return Err(error),
184 }
185 docker.ensure_network(&names.network()).await?;
186 if !docker.image_present(MIRROR_IMAGE).await? {
187 docker.pull(MIRROR_IMAGE).await?;
188 }
189 docker
190 .run(&names.mirror(), mirror_body(names, mirror, slice))
191 .await?;
192 Ok(())
193}
194
195pub async fn mirror_down(docker: &impl DockerApi, names: &Names) -> Result<(), Error> {
197 match docker.remove(&names.mirror()).await {
198 Err(error) if is_not_found(&error) => Ok(()),
199 other => other,
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum Mirror {
206 Up,
208 Down,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct Trimmed {
215 pub sizes: BTreeMap<String, u64>,
217 pub emptied: Vec<Emptied>,
219 pub closed: Vec<Emptied>,
221 pub free: Option<u64>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct Emptied {
228 pub name: String,
230 pub size: ByteSize,
232}
233
234pub async fn trim_shared(
239 docker: &impl DockerApi,
240 names: &Names,
241 caches: &config::Caches,
242 mirror: Mirror,
243 slots: &BTreeMap<String, usize>,
244) -> Result<Trimmed, Error> {
245 let sizes = docker.volume_sizes().await?;
246 let free = docker.free_space().await.ok();
247 let mut emptied = Vec::new();
248 let mut limits = vec![
249 (names.pnpm(), caches.pnpm.max_size),
250 (names.maven(), caches.maven.max_size),
251 ];
252 if mirror == Mirror::Down {
253 limits.push((names.mirror(), caches.mirror.max_size));
254 }
255 for (name, limit) in limits {
256 if let Some(&size) = sizes.get(&name)
257 && size > limit.0
258 {
259 docker.remove_volume(&name).await?;
260 emptied.push(Emptied {
261 name,
262 size: ByteSize(size),
263 });
264 }
265 }
266 let mut closed = Vec::new();
267 for (name, &size) in &sizes {
268 let past = slots.iter().any(|(profile, &count)| {
269 names
270 .slot_of(profile, name)
271 .is_some_and(|slot| slot > count)
272 });
273 if past {
274 docker.remove_volume(name).await?;
275 closed.push(Emptied {
276 name: name.clone(),
277 size: ByteSize(size),
278 });
279 }
280 }
281 Ok(Trimmed {
282 sizes,
283 emptied,
284 closed,
285 free,
286 })
287}
288
289pub fn docker_disk(
292 names: &Names,
293 config: &config::Config,
294 free: u64,
295 sizes: &BTreeMap<String, u64>,
296) -> u64 {
297 let held: u64 = config
298 .profiles
299 .iter()
300 .flat_map(|(profile, settings)| {
301 (1..=settings.slots as usize).map(move |slot| names.slot(profile, slot))
302 })
303 .filter_map(|name| sizes.get(&name))
304 .sum();
305 free.saturating_add(held) / DOCKER_DISK_SHARE
306}
307
308pub fn docker_budget_gib(disk: u64, slots: u64) -> u64 {
311 ((disk / slots.max(1)) >> 30).clamp(1, DOCKER_SLOT_MAX_GIB)
312}
313
314const DOCKER_DISK_SHARE: u64 = 4;
316
317const DOCKER_SLOT_MAX_GIB: u64 = 20;
319
320pub fn volumes(names: &Names, config: &config::Config) -> Vec<api::CacheVolume> {
322 let volume = |name: String, kind: api::CacheKind, limit: Option<u64>| api::CacheVolume {
323 name,
324 kind,
325 bytes: 0,
326 limit,
327 };
328 let mut volumes = Vec::new();
329 for (profile, settings) in &config.profiles {
330 volumes.push(volume(
331 names.base(profile),
332 api::CacheKind::Snapshot {
333 profile: profile.clone(),
334 },
335 Some(config.caches.snapshot_max.0),
336 ));
337 for slot in 1..=settings.slots as usize {
338 volumes.push(volume(
339 names.slot(profile, slot),
340 api::CacheKind::Slot {
341 profile: profile.clone(),
342 slot,
343 },
344 None,
345 ));
346 }
347 }
348 volumes.push(volume(
349 names.pnpm(),
350 api::CacheKind::Pnpm,
351 Some(config.caches.pnpm.max_size.0),
352 ));
353 volumes.push(volume(
354 names.maven(),
355 api::CacheKind::Maven,
356 Some(config.caches.maven.max_size.0),
357 ));
358 if config.caches.mirror.enabled {
359 volumes.push(volume(
360 names.mirror(),
361 api::CacheKind::Mirror,
362 Some(config.caches.mirror.max_size.0),
363 ));
364 }
365 if config.caches.build.enabled {
366 volumes.push(volume(
367 names.build_cache(),
368 api::CacheKind::Build,
369 Some(config.caches.build.max_size.0),
370 ));
371 }
372 volumes.sort_by(|a, b| a.name.cmp(&b.name));
373 volumes
374}
375
376#[derive(Debug)]
378pub struct Reseed<'profile> {
379 pub names: &'profile Names,
381 pub profile: &'profile str,
383 pub image: &'profile str,
385 pub slots: usize,
387 pub slice: &'profile str,
389 pub cpus: u32,
391 pub memory: ByteSize,
393 pub min_changes: ByteSize,
395 pub max_size: ByteSize,
397 pub isolation: &'profile dyn Isolation,
399}
400
401pub async fn reseed(docker: &impl DockerApi, reseed: &Reseed<'_>) -> Result<String, Error> {
406 let names = reseed.names;
407 let mut mounts = vec![volume(names.base(reseed.profile), "/base", false)];
408 mounts.extend((1..=reseed.slots).map(|slot| {
409 volume(
410 names.slot(reseed.profile, slot),
411 &format!("/workers/{slot}"),
412 false,
413 )
414 }));
415 for mount in &mounts {
416 if let Some(source) = &mount.source {
417 docker.ensure_volume(source).await?;
418 }
419 }
420 let name = names.seed(reseed.profile);
421 let spec = RunnerSpec {
422 name: name.clone(),
423 image: reseed.image.to_owned(),
424 profile: reseed.profile.to_owned(),
425 slice: reseed.slice.to_owned(),
426 cpus: reseed.cpus,
427 memory: reseed.memory,
428 env: Vec::new(),
429 mounts,
430 network: Some("none".to_owned()),
431 labels: BTreeMap::from([("runnerd.seed".to_owned(), String::new())]),
432 entrypoint: Some(vec![
433 "/usr/local/sbin/runnerd-seed".to_owned(),
434 reseed.min_changes.0.to_string(),
435 reseed.max_size.0.to_string(),
436 ]),
437 jit_config: None,
438 };
439 match docker.remove(&name).await {
441 Err(error) if !is_not_found(&error) => return Err(error),
442 _ => {}
443 }
444 let id = docker.start(&spec, reseed.isolation).await?;
445 finish_reseed(docker, &id).await
446}
447
448pub async fn finish_reseed(docker: &impl DockerApi, container: &str) -> Result<String, Error> {
451 let result = async {
452 let code = docker.wait(container).await?;
453 let output = docker.logs(container).await?;
454 if code == 0 {
455 Ok(output)
456 } else {
457 Err(Error::DockerContainerWaitError {
458 error: format!("seeding exited {code}: {}", output.trim()),
459 code,
460 })
461 }
462 }
463 .await;
464 let removed = docker.remove(container).await;
465 let output = result?;
466 removed?;
467 Ok(output)
468}
469
470#[cfg(test)]
471mod tests {
472 use super::docker_budget_gib;
473
474 #[test]
475 fn a_slots_docker_budget_is_its_share_between_1_and_20_gib() {
476 assert_eq!(docker_budget_gib(100 << 30, 10), 10);
477 assert_eq!(
478 docker_budget_gib(1 << 40, 4),
479 20,
480 "a large disk still stops at 20 GiB"
481 );
482 assert_eq!(
483 docker_budget_gib(1 << 30, 13),
484 1,
485 "a full disk still keeps 1 GiB"
486 );
487 assert_eq!(docker_budget_gib(1 << 30, 0), 1);
488 }
489}