1mod admission;
16mod build_cache;
17mod caches;
18mod control;
19mod history;
20mod images;
21mod records;
22mod recover;
23mod runners;
24mod status;
25mod tick;
26mod warm;
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::sync::Arc;
30use std::time::{Duration, SystemTime};
31
32use tokio::sync::{mpsc, watch};
33use tokio::task::JoinHandle;
34use tracing::{info, warn};
35
36pub use self::control::{
37 Command, Commands, Commit, Control, ControlError, RunningJobs, RunningSlots, Shutdown, Target,
38};
39use self::history::{Closing, Found};
40use self::images::Images;
41pub use self::records::RecordSelection;
42use super::SlotRef;
43use crate::admission::Key;
44use crate::api;
45use crate::buildcache::BuildCache;
46use crate::cache::{self, Names, Trimmed};
47use crate::config::Config;
48use crate::docker::{self, Docker, DockerApi};
49use crate::github::{Github, GithubApi, QueuedJob};
50use crate::isolation::{self, Isolation};
51use crate::observe::metrics::{self, Paths, Sampler, Tracked};
52use crate::observe::mirror::Leases;
53use crate::store::{Announced, MirrorPosition, Store};
54
55#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct HostIdentity {
60 id: String,
62 host: String,
64}
65
66impl HostIdentity {
67 pub fn read() -> std::io::Result<HostIdentity> {
69 let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap_or_default();
70 let machine_id = std::fs::read_to_string("/etc/machine-id")
71 .or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))
72 .map_err(|error| {
73 std::io::Error::new(
74 error.kind(),
75 format!("cannot read /etc/machine-id: {error}"),
76 )
77 })?;
78 HostIdentity::new(&hostname, &machine_id).ok_or_else(|| {
79 std::io::Error::new(
80 std::io::ErrorKind::InvalidData,
81 "/etc/machine-id does not hold a machine ID",
82 )
83 })
84 }
85
86 pub fn new(hostname: &str, machine_id: &str) -> Option<HostIdentity> {
88 let machine_id = machine_id.trim();
89 if machine_id.len() != 32 || !machine_id.chars().all(|c| c.is_ascii_hexdigit()) {
90 return None;
91 }
92 let hash = format!("runnerd:{}", machine_id.to_ascii_lowercase())
94 .bytes()
95 .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
96 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
97 });
98 let host: String = hostname
99 .trim()
100 .chars()
101 .map(|c| {
102 if c.is_ascii_alphanumeric() {
103 c.to_ascii_lowercase()
104 } else {
105 '-'
106 }
107 })
108 .take(20)
109 .collect();
110 let host = host.trim_matches('-');
111 Some(HostIdentity {
112 id: format!("{:08x}", hash >> 32),
113 host: if host.is_empty() { "host" } else { host }.to_owned(),
114 })
115 }
116
117 pub fn prefix(&self) -> String {
120 format!("runnerd-{}-", self.id)
121 }
122
123 pub fn runner_name(&self, profile: &str, slot: usize, secs: u64) -> String {
125 format!("{}{}-{profile}-{slot}-{secs}", self.prefix(), self.host)
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct Handle {
132 pub status: watch::Receiver<api::Status>,
134 pub control: Arc<Control>,
136 pub store: Store,
138 pub caches: watch::Receiver<Vec<api::CacheVolume>>,
141 pub records: watch::Receiver<api::Records>,
143 pub build_cache: Option<BuildCache>,
145}
146
147#[derive(Debug)]
149pub struct Setup {
150 pub names: Names,
152 pub identity: HostIdentity,
154 pub memory_max: Option<u64>,
156 pub cpus: Option<u64>,
158 pub store: Store,
160 pub announced: Announced,
163 pub paused: Vec<String>,
165 pub paths: Paths,
167 pub build_cache: Option<BuildCache>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173enum Purpose {
174 Job,
176 Warm,
178}
179
180#[derive(Debug, Clone)]
182struct Occupant {
183 repository: String,
184 runner_name: String,
185 runner_id: u64,
186 container: String,
187 busy: bool,
188 started_at: SystemTime,
189 memory: u64,
191 cpus: u64,
193 admission: Option<api::Admission>,
196 job: Option<u64>,
198 key: Option<Key>,
200}
201
202impl Occupant {
203 fn reserved(&self) -> u64 {
205 self.admission
206 .map_or(self.memory, |admission| admission.reserved)
207 }
208
209 fn reserved_cpu(&self) -> u64 {
211 self.admission
212 .and_then(|admission| admission.cpu)
213 .map_or(self.cpus, |cpu| cpu.reserved)
214 }
215
216 fn running_job(&self) -> api::RunningJob {
217 api::RunningJob {
218 repository: self.repository.clone(),
219 runner: self.runner_name.clone(),
220 job: self.job,
221 }
222 }
223}
224
225#[derive(Debug, Default)]
226struct RepositoryState {
227 queued: Vec<QueuedJob>,
228 listed: bool,
230 held_until: Option<SystemTime>,
231 failures: u32,
233 idle_exits: u32,
235 cpu_wait: Option<CpuWait>,
238 cpu_held: bool,
240}
241
242#[derive(Debug, Clone, Copy)]
244struct CpuWait {
245 job: u64,
247 since: SystemTime,
249}
250
251#[derive(Debug)]
253pub struct Engine<G = Github, D = Docker> {
254 config: Config,
255 github: Arc<G>,
257 docker: Arc<D>,
258 names: Names,
259 isolation: &'static dyn Isolation,
260 identity: HostIdentity,
261 runner_prefix: String,
262 memory_max: Option<u64>,
263 cpus: Option<u64>,
265 slots: BTreeMap<String, Vec<Option<Occupant>>>,
266 repositories: BTreeMap<String, RepositoryState>,
267 reseeds: BTreeMap<String, JoinHandle<Result<String, docker::Error>>>,
268 images: Images,
270 reseed_memory: BTreeMap<String, u64>,
272 reseed_requested: BTreeSet<String>,
274 draining: BTreeSet<String>,
276 retired: BTreeMap<String, crate::config::Profile>,
278 reseed_due: BTreeSet<String>,
280 trim_due: bool,
282 trim: Option<JoinHandle<Result<Trimmed, docker::Error>>>,
284 checked_at: SystemTime,
287 mirror_up: bool,
288 mirror_memory: u64,
290 rate_limit_logged: bool,
291 busy_orphans: BTreeSet<u64>,
293 status: watch::Sender<api::Status>,
294 announced_profiles: Option<Vec<api::ProfileStatus>>,
297 control: Arc<Control>,
298 commands: Commands,
299 caches: watch::Sender<Vec<api::CacheVolume>>,
300 store: Store,
301 paths: Paths,
302 tracked: watch::Sender<Vec<Tracked>>,
304 found: (mpsc::UnboundedSender<Found>, mpsc::UnboundedReceiver<Found>),
305 captures: BTreeMap<String, JoinHandle<()>>,
306 leases: Leases,
308 mirror_until: MirrorPosition,
310 queued_jobs: BTreeMap<u64, String>,
312 cancelling: BTreeMap<String, tokio::time::Instant>,
314 closing: BTreeMap<u64, Closing>,
316 last_prune: Option<SystemTime>,
317 records: records::RecordState,
319 measured: admission::Measured,
321 warm: warm::Warm,
323 build_cache: Option<BuildCache>,
325 default_branches: BTreeMap<String, String>,
328}
329
330fn unix_secs(time: SystemTime) -> u64 {
331 time.duration_since(SystemTime::UNIX_EPOCH)
332 .unwrap_or_default()
333 .as_secs()
334}
335
336impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
337 #[allow(
340 clippy::too_many_lines,
341 reason = "one line per field of the engine's state, which split up would only be listed twice"
342 )]
343 pub fn new(config: Config, github: G, docker: D, setup: Setup) -> (Engine<G, D>, Handle) {
344 let Setup {
345 names,
346 identity,
347 memory_max,
348 cpus,
349 store,
350 announced,
351 paused,
352 paths,
353 build_cache,
354 } = setup;
355 let slots = config
356 .profiles
357 .iter()
358 .map(|(name, profile)| (name.clone(), vec![None; profile.slots as usize]))
359 .collect();
360 let repositories = config
361 .repositories
362 .iter()
363 .map(|repository| (repository.name.clone(), RepositoryState::default()))
364 .collect();
365 let (control, commands) = Control::new(
366 config
367 .repositories
368 .iter()
369 .map(|repository| repository.name.clone()),
370 paused,
371 store.clone(),
372 );
373 let control = Arc::new(control);
374 let (status, receiver) = watch::channel(api::Status {
375 version: env!("CARGO_PKG_VERSION").to_owned(),
376 shutting_down: false,
377 budget: api::Budget {
378 slice: config.host.slice.clone(),
379 memory_max,
380 ..api::Budget::default()
381 },
382 profiles: Vec::new(),
383 repositories: announced.repositories,
385 queued: Vec::new(),
386 });
387 let (caches, caches_receiver) = watch::channel(cache::volumes(&names, &config));
388 let (records, records_receiver) = records::RecordState::new();
389 let mut engine = Engine {
390 isolation: isolation::backend(config.host.isolation),
391 mirror_memory: config.mirror_memory(),
393 config,
394 github: Arc::new(github),
395 docker: Arc::new(docker),
396 names,
397 runner_prefix: identity.prefix(),
398 identity,
399 memory_max,
400 cpus,
401 slots,
402 repositories,
403 reseeds: BTreeMap::new(),
404 images: Images::new(),
405 reseed_memory: BTreeMap::new(),
406 reseed_requested: BTreeSet::new(),
407 retired: BTreeMap::new(),
408 draining: BTreeSet::new(),
409 reseed_due: BTreeSet::new(),
410 trim_due: true,
412 trim: None,
413 checked_at: SystemTime::now(),
414 mirror_up: true,
416 rate_limit_logged: false,
417 busy_orphans: BTreeSet::new(),
418 status,
419 announced_profiles: Some(announced.profiles),
420 control: control.clone(),
421 commands,
422 caches,
423 store: store.clone(),
424 paths,
425 tracked: watch::channel(Vec::new()).0,
426 found: mpsc::unbounded_channel(),
427 captures: BTreeMap::new(),
428 leases: Leases::default(),
429 mirror_until: MirrorPosition::default(),
430 queued_jobs: BTreeMap::new(),
431 cancelling: BTreeMap::new(),
432 closing: BTreeMap::new(),
433 last_prune: None,
434 measured: admission::Measured::default(),
435 warm: warm::Warm::default(),
436 records,
437 build_cache: build_cache.clone(),
438 default_branches: BTreeMap::new(),
439 };
440 engine.publish();
441 (
442 engine,
443 Handle {
444 status: receiver,
445 control,
446 store,
447 caches: caches_receiver,
448 records: records_receiver,
449 build_cache,
450 },
451 )
452 }
453
454 pub async fn run(mut self) {
456 tokio::spawn(metrics::run(
457 Sampler::new(self.paths.clone(), &self.config.host.slice),
458 self.tracked.subscribe(),
459 self.store.clone(),
460 ));
461 while let Err(error) = self.recover().await {
464 warn!("cannot list the containers an earlier run left; trying again in 5s: {error}");
465 if self.control.mode() != Shutdown::No {
466 return;
467 }
468 tokio::select! {
469 () = tokio::time::sleep(Duration::from_secs(5)) => {}
470 () = self.control.wake.notified() => {}
471 }
472 }
473 loop {
474 let mode = self.control.mode();
475 self.tick(mode).await;
476 self.publish();
477 let idle = self.slots.values().flatten().all(Option::is_none);
478 if mode != Shutdown::No
479 && idle
480 && self.reseeds.is_empty()
481 && self.trim.is_none()
482 && self.records.recordings.is_empty()
483 {
484 break;
485 }
486 let interval = if mode == Shutdown::No {
487 self.config.host.poll_interval
488 } else {
489 Duration::from_secs(5)
490 };
491 tokio::select! {
492 () = tokio::time::sleep(interval) => {}
493 () = self.control.wake.notified() => {}
494 Some(command) = self.commands.recv() => {
495 self.command(command).await;
496 self.publish();
497 }
498 Some(()) = self.records.finished.recv() => {
499 self.collect_recordings().await;
500 self.publish();
501 }
502 }
503 }
504 self.close_shells().await;
505 if let Err(error) = cache::mirror_down(&*self.docker, &self.names).await {
506 warn!("cannot stop the mirror: {error}");
507 }
508 info!("stopped");
509 }
510
511 async fn serving<T: Send + 'static>(
515 &mut self,
516 work: impl Future<Output = T> + Send + 'static,
517 ) -> T {
518 let mut task = tokio::spawn(work);
519 loop {
520 tokio::select! {
521 biased;
522 done = &mut task => match done.map_err(tokio::task::JoinError::try_into_panic) {
523 Ok(output) => return output,
524 Err(Ok(panic)) => std::panic::resume_unwind(panic),
526 Err(Err(_)) => std::future::pending::<()>().await,
528 },
529 Some(command) = self.commands.recv() => {
530 self.command(command).await;
531 self.publish();
532 }
533 }
534 }
535 }
536
537 fn occupied(&self) -> impl Iterator<Item = (SlotRef, &Occupant)> {
538 self.slots.iter().flat_map(|(profile, slots)| {
539 slots.iter().enumerate().filter_map(|(index, slot)| {
540 slot.as_ref().map(|occupant| {
541 (
542 SlotRef {
543 profile: profile.clone(),
544 index,
545 },
546 occupant,
547 )
548 })
549 })
550 })
551 }
552
553 fn drop_closed_slots(&mut self) {
556 for (name, slots) in &mut self.slots {
557 let count = self
558 .config
559 .profiles
560 .get(name)
561 .map_or(0, |profile| profile.slots as usize);
562 while slots.len() > count && slots.last().is_some_and(Option::is_none) {
563 slots.pop();
564 }
565 }
566 let profiles = &self.config.profiles;
567 self.slots
568 .retain(|name, slots| !slots.is_empty() || profiles.contains_key(name));
569 let (slots, reseeds) = (&self.slots, &self.reseeds);
570 self.retired
571 .retain(|name, _| slots.contains_key(name) || reseeds.contains_key(name));
572 }
573}
574
575#[cfg(test)]
576mod tests;