1mod caches;
14mod control;
15mod history;
16mod images;
17mod recover;
18mod runners;
19mod status;
20mod tick;
21
22use std::collections::{BTreeMap, BTreeSet};
23use std::sync::Arc;
24use std::time::{Duration, SystemTime};
25
26use tokio::sync::{mpsc, watch};
27use tokio::task::JoinHandle;
28use tracing::{info, warn};
29
30pub use self::control::{
31 Command, Commands, Commit, Control, ControlError, RunningJobs, Shutdown, Target,
32};
33use self::history::{Closing, Found};
34use self::images::ImageState;
35use super::SlotRef;
36use crate::api;
37use crate::cache::{self, Names, Trimmed};
38use crate::config::Config;
39use crate::docker::{self, Docker, DockerApi};
40use crate::github::{Github, GithubApi, QueuedJob};
41use crate::isolation::{self, Isolation};
42use crate::observe::metrics::{self, Paths, Sampler, Tracked};
43use crate::observe::mirror::Leases;
44use crate::store::{MirrorPosition, Store};
45
46#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct HostIdentity {
51 id: String,
53 host: String,
55}
56
57impl HostIdentity {
58 pub fn read() -> std::io::Result<HostIdentity> {
60 let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap_or_default();
61 let machine_id = std::fs::read_to_string("/etc/machine-id")
62 .or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))
63 .map_err(|error| {
64 std::io::Error::new(
65 error.kind(),
66 format!("cannot read /etc/machine-id: {error}"),
67 )
68 })?;
69 HostIdentity::new(&hostname, &machine_id).ok_or_else(|| {
70 std::io::Error::new(
71 std::io::ErrorKind::InvalidData,
72 "/etc/machine-id does not hold a machine ID",
73 )
74 })
75 }
76
77 pub fn new(hostname: &str, machine_id: &str) -> Option<HostIdentity> {
79 let machine_id = machine_id.trim();
80 if machine_id.len() != 32 || !machine_id.chars().all(|c| c.is_ascii_hexdigit()) {
81 return None;
82 }
83 let hash = format!("runnerd:{}", machine_id.to_ascii_lowercase())
85 .bytes()
86 .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
87 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
88 });
89 let host: String = hostname
90 .trim()
91 .chars()
92 .map(|c| {
93 if c.is_ascii_alphanumeric() {
94 c.to_ascii_lowercase()
95 } else {
96 '-'
97 }
98 })
99 .take(20)
100 .collect();
101 let host = host.trim_matches('-');
102 Some(HostIdentity {
103 id: format!("{:08x}", hash >> 32),
104 host: if host.is_empty() { "host" } else { host }.to_owned(),
105 })
106 }
107
108 pub fn prefix(&self) -> String {
111 format!("runnerd-{}-", self.id)
112 }
113
114 pub fn runner_name(&self, profile: &str, slot: usize, secs: u64) -> String {
116 format!("{}{}-{profile}-{slot}-{secs}", self.prefix(), self.host)
117 }
118}
119
120#[derive(Debug, Clone)]
122pub struct Handle {
123 pub status: watch::Receiver<api::Status>,
125 pub control: Arc<Control>,
127 pub store: Store,
129 pub caches: watch::Receiver<Vec<api::CacheVolume>>,
132}
133
134#[derive(Debug)]
136pub struct Setup {
137 pub names: Names,
139 pub identity: HostIdentity,
141 pub memory_max: Option<u64>,
143 pub store: Store,
145 pub announced: Vec<api::RepositoryStatus>,
148 pub paused: Vec<String>,
150 pub paths: Paths,
152}
153
154#[derive(Debug, Clone)]
156struct Occupant {
157 repository: String,
158 runner_name: String,
159 runner_id: u64,
160 container: String,
161 busy: bool,
162 started_at: SystemTime,
163 memory: u64,
165 job: Option<u64>,
167}
168
169impl Occupant {
170 fn running_job(&self) -> api::RunningJob {
171 api::RunningJob {
172 repository: self.repository.clone(),
173 runner: self.runner_name.clone(),
174 job: self.job,
175 }
176 }
177}
178
179#[derive(Debug, Default)]
180struct RepositoryState {
181 queued: Vec<QueuedJob>,
182 listed: bool,
184 held_until: Option<SystemTime>,
185 failures: u32,
187}
188
189#[derive(Debug)]
191pub struct Engine<G = Github, D = Docker> {
192 config: Config,
193 github: G,
194 docker: Arc<D>,
195 names: Names,
196 isolation: &'static dyn Isolation,
197 identity: HostIdentity,
198 runner_prefix: String,
199 memory_max: Option<u64>,
200 slots: BTreeMap<String, Vec<Option<Occupant>>>,
201 repositories: BTreeMap<String, RepositoryState>,
202 reseeds: BTreeMap<String, JoinHandle<Result<String, docker::Error>>>,
203 images: BTreeMap<String, ImageState>,
205 reseed_memory: BTreeMap<String, u64>,
207 reseed_requested: BTreeSet<String>,
209 draining: BTreeSet<String>,
211 retired: BTreeMap<String, crate::config::Profile>,
213 reseed_due: BTreeSet<String>,
215 trim_due: bool,
217 trim: Option<JoinHandle<Result<Trimmed, docker::Error>>>,
219 mirror_up: bool,
220 mirror_memory: u64,
222 rate_limit_logged: bool,
223 busy_orphans: BTreeSet<u64>,
225 status: watch::Sender<api::Status>,
226 control: Arc<Control>,
227 commands: Commands,
228 caches: watch::Sender<Vec<api::CacheVolume>>,
229 store: Store,
230 paths: Paths,
231 tracked: watch::Sender<Vec<Tracked>>,
233 found: (mpsc::UnboundedSender<Found>, mpsc::UnboundedReceiver<Found>),
234 captures: BTreeMap<String, JoinHandle<()>>,
235 leases: Leases,
237 mirror_until: MirrorPosition,
239 queued_jobs: BTreeMap<u64, String>,
241 cancelling: BTreeMap<String, tokio::time::Instant>,
243 closing: BTreeMap<u64, Closing>,
245 last_prune: Option<SystemTime>,
246}
247
248fn unix_secs(time: SystemTime) -> u64 {
249 time.duration_since(SystemTime::UNIX_EPOCH)
250 .unwrap_or_default()
251 .as_secs()
252}
253
254impl<G: GithubApi, D: DockerApi + 'static> Engine<G, D> {
255 pub fn new(config: Config, github: G, docker: D, setup: Setup) -> (Engine<G, D>, Handle) {
258 let Setup {
259 names,
260 identity,
261 memory_max,
262 store,
263 announced,
264 paused,
265 paths,
266 } = setup;
267 let slots = config
268 .profiles
269 .iter()
270 .map(|(name, profile)| (name.clone(), vec![None; profile.slots as usize]))
271 .collect();
272 let repositories = config
273 .repositories
274 .iter()
275 .map(|repository| (repository.name.clone(), RepositoryState::default()))
276 .collect();
277 let (control, commands) = Control::new(
278 config
279 .repositories
280 .iter()
281 .map(|repository| repository.name.clone()),
282 paused,
283 store.clone(),
284 );
285 let control = Arc::new(control);
286 let isolation = isolation::backend(config.host.isolation);
287 let (status, receiver) = watch::channel(api::Status {
288 version: env!("CARGO_PKG_VERSION").to_owned(),
289 shutting_down: false,
290 budget: api::Budget {
291 slice: config.host.slice.clone(),
292 memory_max,
293 memory_in_use: 0,
294 },
295 profiles: Vec::new(),
296 repositories: announced,
298 });
299 let (caches, caches_receiver) = watch::channel(cache::volumes(&names, &config));
300 let mirror_memory = if config.caches.mirror.enabled {
302 config.caches.mirror.memory.0
303 } else {
304 0
305 };
306 let mut engine = Engine {
307 config,
308 github,
309 docker: Arc::new(docker),
310 names,
311 isolation,
312 runner_prefix: identity.prefix(),
313 identity,
314 memory_max,
315 slots,
316 repositories,
317 reseeds: BTreeMap::new(),
318 images: BTreeMap::new(),
319 reseed_memory: BTreeMap::new(),
320 reseed_requested: BTreeSet::new(),
321 retired: BTreeMap::new(),
322 draining: BTreeSet::new(),
323 reseed_due: BTreeSet::new(),
324 trim_due: true,
326 trim: None,
327 mirror_up: true,
329 mirror_memory,
330 rate_limit_logged: false,
331 busy_orphans: BTreeSet::new(),
332 status,
333 control: control.clone(),
334 commands,
335 caches,
336 store: store.clone(),
337 paths,
338 tracked: watch::channel(Vec::new()).0,
339 found: mpsc::unbounded_channel(),
340 captures: BTreeMap::new(),
341 leases: Leases::default(),
342 mirror_until: MirrorPosition::default(),
343 queued_jobs: BTreeMap::new(),
344 cancelling: BTreeMap::new(),
345 closing: BTreeMap::new(),
346 last_prune: None,
347 };
348 engine.publish();
349 (
350 engine,
351 Handle {
352 status: receiver,
353 control,
354 store,
355 caches: caches_receiver,
356 },
357 )
358 }
359
360 pub async fn run(mut self) {
362 tokio::spawn(metrics::run(
363 Sampler::new(self.paths.clone(), &self.config.host.slice),
364 self.tracked.subscribe(),
365 self.store.clone(),
366 ));
367 while let Err(error) = self.recover().await {
370 warn!("cannot list the containers an earlier run left; trying again in 5s: {error}");
371 if self.control.mode() != Shutdown::No {
372 return;
373 }
374 tokio::select! {
375 () = tokio::time::sleep(Duration::from_secs(5)) => {}
376 () = self.control.wake.notified() => {}
377 }
378 }
379 loop {
380 let mode = self.control.mode();
381 self.tick(mode).await;
382 self.publish();
383 let idle = self.slots.values().flatten().all(Option::is_none);
384 if mode != Shutdown::No && idle && self.reseeds.is_empty() && self.trim.is_none() {
385 break;
386 }
387 let interval = if mode == Shutdown::No {
388 self.config.host.poll_interval
389 } else {
390 Duration::from_secs(5)
391 };
392 tokio::select! {
393 () = tokio::time::sleep(interval) => {}
394 () = self.control.wake.notified() => {}
395 Some(command) = self.commands.recv() => {
396 self.command(command).await;
397 self.publish();
398 }
399 }
400 }
401 if let Err(error) = cache::mirror_down(&*self.docker, &self.names).await {
402 warn!("cannot stop the mirror: {error}");
403 }
404 info!("stopped");
405 }
406
407 fn occupied(&self) -> impl Iterator<Item = (SlotRef, &Occupant)> {
408 self.slots.iter().flat_map(|(profile, slots)| {
409 slots.iter().enumerate().filter_map(|(index, slot)| {
410 slot.as_ref().map(|occupant| {
411 (
412 SlotRef {
413 profile: profile.clone(),
414 index,
415 },
416 occupant,
417 )
418 })
419 })
420 })
421 }
422
423 fn drop_closed_slots(&mut self) {
426 for (name, slots) in &mut self.slots {
427 let count = self
428 .config
429 .profiles
430 .get(name)
431 .map_or(0, |profile| profile.slots as usize);
432 while slots.len() > count && slots.last().is_some_and(Option::is_none) {
433 slots.pop();
434 }
435 }
436 let profiles = &self.config.profiles;
437 self.slots
438 .retain(|name, slots| !slots.is_empty() || profiles.contains_key(name));
439 let (slots, reseeds) = (&self.slots, &self.reseeds);
440 self.retired
441 .retain(|name, _| slots.contains_key(name) || reseeds.contains_key(name));
442 }
443}
444
445#[cfg(test)]
446mod tests;