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