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