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, Shutdown, Target,
34};
35use self::history::{Closing, Found};
36use self::images::ImageState;
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 store: Store,
151 pub announced: Announced,
154 pub paused: Vec<String>,
156 pub paths: Paths,
158}
159
160#[derive(Debug, Clone)]
162struct Occupant {
163 repository: String,
164 runner_name: String,
165 runner_id: u64,
166 container: String,
167 busy: bool,
168 started_at: SystemTime,
169 memory: u64,
171 admission: Option<api::Admission>,
174 job: Option<u64>,
176 key: Option<Key>,
178}
179
180impl Occupant {
181 fn reserved(&self) -> u64 {
183 self.admission
184 .map_or(self.memory, |admission| admission.reserved)
185 }
186
187 fn running_job(&self) -> api::RunningJob {
188 api::RunningJob {
189 repository: self.repository.clone(),
190 runner: self.runner_name.clone(),
191 job: self.job,
192 }
193 }
194}
195
196#[derive(Debug, Default)]
197struct RepositoryState {
198 queued: Vec<QueuedJob>,
199 listed: bool,
201 held_until: Option<SystemTime>,
202 failures: u32,
204}
205
206#[derive(Debug)]
208pub struct Engine<G = Github, D = Docker> {
209 config: Config,
210 github: Arc<G>,
212 docker: Arc<D>,
213 names: Names,
214 isolation: &'static dyn Isolation,
215 identity: HostIdentity,
216 runner_prefix: String,
217 memory_max: Option<u64>,
218 slots: BTreeMap<String, Vec<Option<Occupant>>>,
219 repositories: BTreeMap<String, RepositoryState>,
220 reseeds: BTreeMap<String, JoinHandle<Result<String, docker::Error>>>,
221 images: BTreeMap<String, ImageState>,
223 reseed_memory: BTreeMap<String, u64>,
225 reseed_requested: BTreeSet<String>,
227 draining: BTreeSet<String>,
229 retired: BTreeMap<String, crate::config::Profile>,
231 reseed_due: BTreeSet<String>,
233 trim_due: bool,
235 trim: Option<JoinHandle<Result<Trimmed, docker::Error>>>,
237 mirror_up: bool,
238 mirror_memory: u64,
240 rate_limit_logged: bool,
241 busy_orphans: BTreeSet<u64>,
243 status: watch::Sender<api::Status>,
244 announced_profiles: Option<Vec<api::ProfileStatus>>,
247 control: Arc<Control>,
248 commands: Commands,
249 caches: watch::Sender<Vec<api::CacheVolume>>,
250 store: Store,
251 paths: Paths,
252 tracked: watch::Sender<Vec<Tracked>>,
254 found: (mpsc::UnboundedSender<Found>, mpsc::UnboundedReceiver<Found>),
255 captures: BTreeMap<String, JoinHandle<()>>,
256 leases: Leases,
258 mirror_until: MirrorPosition,
260 queued_jobs: BTreeMap<u64, String>,
262 cancelling: BTreeMap<String, tokio::time::Instant>,
264 closing: BTreeMap<u64, Closing>,
266 last_prune: Option<SystemTime>,
267 records: records::RecordState,
269 measured: admission::Measured,
271}
272
273fn unix_secs(time: SystemTime) -> u64 {
274 time.duration_since(SystemTime::UNIX_EPOCH)
275 .unwrap_or_default()
276 .as_secs()
277}
278
279impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
280 pub fn new(config: Config, github: G, docker: D, setup: Setup) -> (Engine<G, D>, Handle) {
283 let Setup {
284 names,
285 identity,
286 memory_max,
287 store,
288 announced,
289 paused,
290 paths,
291 } = setup;
292 let slots = config
293 .profiles
294 .iter()
295 .map(|(name, profile)| (name.clone(), vec![None; profile.slots as usize]))
296 .collect();
297 let repositories = config
298 .repositories
299 .iter()
300 .map(|repository| (repository.name.clone(), RepositoryState::default()))
301 .collect();
302 let (control, commands) = Control::new(
303 config
304 .repositories
305 .iter()
306 .map(|repository| repository.name.clone()),
307 paused,
308 store.clone(),
309 );
310 let control = Arc::new(control);
311 let (status, receiver) = watch::channel(api::Status {
312 version: env!("CARGO_PKG_VERSION").to_owned(),
313 shutting_down: false,
314 budget: api::Budget {
315 slice: config.host.slice.clone(),
316 memory_max,
317 ..api::Budget::default()
318 },
319 profiles: Vec::new(),
320 repositories: announced.repositories,
322 queued: Vec::new(),
323 });
324 let (caches, caches_receiver) = watch::channel(cache::volumes(&names, &config));
325 let (records, records_receiver) = records::RecordState::new();
326 let mirror = &config.caches.mirror;
328 let mirror_memory = if mirror.enabled { mirror.memory.0 } else { 0 };
329 let mut engine = Engine {
330 isolation: isolation::backend(config.host.isolation),
331 config,
332 github: Arc::new(github),
333 docker: Arc::new(docker),
334 names,
335 runner_prefix: identity.prefix(),
336 identity,
337 memory_max,
338 slots,
339 repositories,
340 reseeds: BTreeMap::new(),
341 images: BTreeMap::new(),
342 reseed_memory: BTreeMap::new(),
343 reseed_requested: BTreeSet::new(),
344 retired: BTreeMap::new(),
345 draining: BTreeSet::new(),
346 reseed_due: BTreeSet::new(),
347 trim_due: true,
349 trim: None,
350 mirror_up: true,
352 mirror_memory,
353 rate_limit_logged: false,
354 busy_orphans: BTreeSet::new(),
355 status,
356 announced_profiles: Some(announced.profiles),
357 control: control.clone(),
358 commands,
359 caches,
360 store: store.clone(),
361 paths,
362 tracked: watch::channel(Vec::new()).0,
363 found: mpsc::unbounded_channel(),
364 captures: BTreeMap::new(),
365 leases: Leases::default(),
366 mirror_until: MirrorPosition::default(),
367 queued_jobs: BTreeMap::new(),
368 cancelling: BTreeMap::new(),
369 closing: BTreeMap::new(),
370 last_prune: None,
371 measured: admission::Measured::default(),
372 records,
373 };
374 engine.publish();
375 (
376 engine,
377 Handle {
378 status: receiver,
379 control,
380 store,
381 caches: caches_receiver,
382 records: records_receiver,
383 },
384 )
385 }
386
387 pub async fn run(mut self) {
389 tokio::spawn(metrics::run(
390 Sampler::new(self.paths.clone(), &self.config.host.slice),
391 self.tracked.subscribe(),
392 self.store.clone(),
393 ));
394 while let Err(error) = self.recover().await {
397 warn!("cannot list the containers an earlier run left; trying again in 5s: {error}");
398 if self.control.mode() != Shutdown::No {
399 return;
400 }
401 tokio::select! {
402 () = tokio::time::sleep(Duration::from_secs(5)) => {}
403 () = self.control.wake.notified() => {}
404 }
405 }
406 loop {
407 let mode = self.control.mode();
408 self.tick(mode).await;
409 self.publish();
410 let idle = self.slots.values().flatten().all(Option::is_none);
411 if mode != Shutdown::No
412 && idle
413 && self.reseeds.is_empty()
414 && self.trim.is_none()
415 && self.records.recordings.is_empty()
416 {
417 break;
418 }
419 let interval = if mode == Shutdown::No {
420 self.config.host.poll_interval
421 } else {
422 Duration::from_secs(5)
423 };
424 tokio::select! {
425 () = tokio::time::sleep(interval) => {}
426 () = self.control.wake.notified() => {}
427 Some(command) = self.commands.recv() => {
428 self.command(command).await;
429 self.publish();
430 }
431 Some(()) = self.records.finished.recv() => {
432 self.collect_recordings().await;
433 self.publish();
434 }
435 }
436 }
437 self.close_shells().await;
438 if let Err(error) = cache::mirror_down(&*self.docker, &self.names).await {
439 warn!("cannot stop the mirror: {error}");
440 }
441 info!("stopped");
442 }
443
444 async fn serving<T: Send + 'static>(
448 &mut self,
449 work: impl Future<Output = T> + Send + 'static,
450 ) -> T {
451 let mut task = tokio::spawn(work);
452 loop {
453 tokio::select! {
454 biased;
455 done = &mut task => match done.map_err(tokio::task::JoinError::try_into_panic) {
456 Ok(output) => return output,
457 Err(Ok(panic)) => std::panic::resume_unwind(panic),
459 Err(Err(_)) => std::future::pending::<()>().await,
461 },
462 Some(command) = self.commands.recv() => {
463 self.command(command).await;
464 self.publish();
465 }
466 }
467 }
468 }
469
470 fn occupied(&self) -> impl Iterator<Item = (SlotRef, &Occupant)> {
471 self.slots.iter().flat_map(|(profile, slots)| {
472 slots.iter().enumerate().filter_map(|(index, slot)| {
473 slot.as_ref().map(|occupant| {
474 (
475 SlotRef {
476 profile: profile.clone(),
477 index,
478 },
479 occupant,
480 )
481 })
482 })
483 })
484 }
485
486 fn drop_closed_slots(&mut self) {
489 for (name, slots) in &mut self.slots {
490 let count = self
491 .config
492 .profiles
493 .get(name)
494 .map_or(0, |profile| profile.slots as usize);
495 while slots.len() > count && slots.last().is_some_and(Option::is_none) {
496 slots.pop();
497 }
498 }
499 let profiles = &self.config.profiles;
500 self.slots
501 .retain(|name, slots| !slots.is_empty() || profiles.contains_key(name));
502 let (slots, reseeds) = (&self.slots, &self.reseeds);
503 self.retired
504 .retain(|name, _| slots.contains_key(name) || reseeds.contains_key(name));
505 }
506}
507
508#[cfg(test)]
509mod tests;