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