mod admission;
mod build_cache;
mod caches;
mod control;
mod history;
mod images;
mod records;
mod recover;
mod runners;
mod status;
mod tick;
mod warm;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{info, warn};
pub use self::control::{
Command, Commands, Commit, Control, ControlError, RunningJobs, RunningSlots, Shutdown, Target,
};
use self::history::{Closing, Found};
use self::images::Images;
pub use self::records::RecordSelection;
use super::{Hold, SlotRef};
use crate::admission::Key;
use crate::api;
use crate::buildcache::BuildCache;
use crate::cache::{self, Names, Trimmed};
use crate::config::Config;
use crate::docker::{self, Docker, DockerApi};
use crate::github::{Github, GithubApi, QueuedJob};
use crate::isolation::{self, Isolation};
use crate::observe::metrics::{self, Paths, Sampler, Tracked};
use crate::observe::mirror::Leases;
use crate::store::{Announced, MirrorPosition, Store};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostIdentity {
id: String,
host: String,
}
impl HostIdentity {
pub fn read() -> std::io::Result<HostIdentity> {
let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap_or_default();
let machine_id = std::fs::read_to_string("/etc/machine-id")
.or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))
.map_err(|error| {
std::io::Error::new(
error.kind(),
format!("cannot read /etc/machine-id: {error}"),
)
})?;
HostIdentity::new(&hostname, &machine_id).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"/etc/machine-id does not hold a machine ID",
)
})
}
pub fn new(hostname: &str, machine_id: &str) -> Option<HostIdentity> {
let machine_id = machine_id.trim();
if machine_id.len() != 32 || !machine_id.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let hash = format!("runnerd:{}", machine_id.to_ascii_lowercase())
.bytes()
.fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
});
let host: String = hostname
.trim()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.take(20)
.collect();
let host = host.trim_matches('-');
Some(HostIdentity {
id: format!("{:08x}", hash >> 32),
host: if host.is_empty() { "host" } else { host }.to_owned(),
})
}
pub fn prefix(&self) -> String {
format!("runnerd-{}-", self.id)
}
pub fn runner_name(&self, profile: &str, slot: usize, secs: u64) -> String {
format!("{}{}-{profile}-{slot}-{secs}", self.prefix(), self.host)
}
}
#[derive(Debug, Clone)]
pub struct Handle {
pub status: watch::Receiver<api::Status>,
pub control: Arc<Control>,
pub store: Store,
pub caches: watch::Receiver<Vec<api::CacheVolume>>,
pub records: watch::Receiver<api::Records>,
pub build_cache: Option<BuildCache>,
}
#[derive(Debug)]
pub struct Setup {
pub names: Names,
pub identity: HostIdentity,
pub memory_max: Option<u64>,
pub cpus: Option<u64>,
pub store: Store,
pub announced: Announced,
pub paused: Vec<String>,
pub paths: Paths,
pub build_cache: Option<BuildCache>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Purpose {
Job,
Warm,
}
#[derive(Debug, Clone)]
struct Occupant {
repository: String,
runner_name: String,
runner_id: u64,
container: String,
busy: bool,
started_at: SystemTime,
memory: u64,
cpus: u64,
admission: Option<api::Admission>,
job: Option<u64>,
key: Option<Key>,
labels: Option<Vec<String>>,
image: Option<String>,
observed: bool,
}
impl Occupant {
fn outdated(
&self,
repository: &crate::config::Repository,
profile: &crate::config::Profile,
) -> bool {
let differ = self.labels.as_ref().is_some_and(|labels| {
let set = |labels: &[String]| -> BTreeSet<String> {
labels
.iter()
.map(|label| label.to_ascii_lowercase())
.collect()
};
set(labels) != set(&repository.labels)
});
differ || self.memory != profile.memory.0 || self.cpus != u64::from(profile.cpus) * 1000
}
fn other_image(&self, profile: &crate::config::Profile) -> bool {
self.image
.as_ref()
.is_some_and(|image| *image != profile.image.reference())
}
fn reserved(&self) -> u64 {
self.admission
.map_or(self.memory, |admission| admission.reserved)
}
fn reserved_cpu(&self) -> u64 {
self.admission
.and_then(|admission| admission.cpu)
.map_or(self.cpus, |cpu| cpu.reserved)
}
fn running_job(&self) -> api::RunningJob {
api::RunningJob {
repository: self.repository.clone(),
runner: self.runner_name.clone(),
job: self.job,
}
}
}
#[derive(Debug, Default)]
struct RepositoryState {
queued: Vec<QueuedJob>,
listed: bool,
holds: BTreeMap<String, PoolHold>,
cpu_wait: BTreeMap<String, CpuWait>,
cpu_held: BTreeSet<String>,
}
#[derive(Debug, Default)]
struct PoolHold {
held: Option<Hold>,
failures: u32,
idle_exits: u32,
}
#[derive(Debug, Clone, Copy)]
struct CpuWait {
job: u64,
since: SystemTime,
}
#[derive(Debug)]
pub struct Engine<G = Github, D = Docker> {
config: Config,
github: Arc<G>,
docker: Arc<D>,
names: Names,
isolation: &'static dyn Isolation,
identity: HostIdentity,
runner_prefix: String,
memory_max: Option<u64>,
cpus: Option<u64>,
slots: BTreeMap<String, Vec<Option<Occupant>>>,
repositories: BTreeMap<String, RepositoryState>,
reseeds: BTreeMap<String, JoinHandle<Result<String, docker::Error>>>,
images: Images,
reseed_memory: BTreeMap<String, u64>,
reseed_requested: BTreeSet<String>,
draining: BTreeSet<String>,
leftover: BTreeMap<String, String>,
retired: BTreeMap<String, crate::config::Profile>,
reseed_due: BTreeSet<String>,
oversized: BTreeSet<String>,
reseeded_at: BTreeMap<String, SystemTime>,
docker_disk: Option<u64>,
disk: Option<JoinHandle<Result<caches::Disk, docker::Error>>>,
trim_due: bool,
trim: Option<JoinHandle<Result<Trimmed, docker::Error>>>,
checked_at: SystemTime,
mirror_up: bool,
mirror_memory: u64,
rate_limit_logged: bool,
busy_orphans: BTreeSet<u64>,
status: watch::Sender<api::Status>,
announced_profiles: Option<Vec<api::ProfileStatus>>,
control: Arc<Control>,
commands: Commands,
caches: watch::Sender<Vec<api::CacheVolume>>,
store: Store,
paths: Paths,
tracked: watch::Sender<Vec<Tracked>>,
found: (mpsc::UnboundedSender<Found>, mpsc::UnboundedReceiver<Found>),
captures: BTreeMap<String, JoinHandle<()>>,
leases: Leases,
mirror_until: MirrorPosition,
queued_jobs: BTreeMap<u64, String>,
cancelling: BTreeMap<String, tokio::time::Instant>,
closing: BTreeMap<u64, Closing>,
last_prune: Option<SystemTime>,
records: records::RecordState,
measured: admission::Measured,
warm: warm::Warm,
build_cache: Option<BuildCache>,
default_branches: BTreeMap<String, build_cache::DefaultBranch>,
ended_uploads: BTreeMap<String, build_cache::EndedUploads>,
visibility_due: Arc<Mutex<BTreeMap<String, SystemTime>>>,
visibility_asking: Option<JoinHandle<()>>,
}
fn unix_secs(time: SystemTime) -> u64 {
time.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
#[allow(
clippy::too_many_lines,
reason = "one line per field of the engine's state, which split up would only be listed twice"
)]
pub fn new(config: Config, github: G, docker: D, setup: Setup) -> (Engine<G, D>, Handle) {
let Setup {
names,
identity,
memory_max,
cpus,
store,
announced,
paused,
paths,
build_cache,
} = setup;
let slots = config
.profiles
.iter()
.map(|(name, profile)| (name.clone(), vec![None; profile.slots as usize]))
.collect();
let repositories = config
.repositories
.iter()
.map(|repository| (repository.name.clone(), RepositoryState::default()))
.collect();
let (control, commands) = Control::new(
config
.repositories
.iter()
.map(|repository| repository.name.clone()),
paused,
store.clone(),
);
let control = Arc::new(control);
let (status, receiver) = watch::channel(api::Status {
version: env!("CARGO_PKG_VERSION").to_owned(),
shutting_down: false,
budget: api::Budget {
slice: config.host.slice.clone(),
memory_max,
..api::Budget::default()
},
profiles: Vec::new(),
repositories: announced.repositories,
queued: Vec::new(),
});
let (caches, caches_receiver) = watch::channel(cache::volumes(&names, &config));
let (records, records_receiver) = records::RecordState::new();
let mut engine = Engine {
isolation: isolation::backend(config.host.isolation),
mirror_memory: config.mirror_memory(),
config,
github: Arc::new(github),
docker: Arc::new(docker),
names,
runner_prefix: identity.prefix(),
identity,
memory_max,
cpus,
slots,
repositories,
reseeds: BTreeMap::new(),
images: Images::new(),
reseed_memory: BTreeMap::new(),
reseed_requested: BTreeSet::new(),
retired: BTreeMap::new(),
draining: BTreeSet::new(),
leftover: BTreeMap::new(),
reseed_due: BTreeSet::new(),
oversized: BTreeSet::new(),
reseeded_at: BTreeMap::new(),
docker_disk: None,
disk: None,
trim_due: true,
trim: None,
checked_at: SystemTime::now(),
mirror_up: true,
rate_limit_logged: false,
busy_orphans: BTreeSet::new(),
status,
announced_profiles: Some(announced.profiles),
control: control.clone(),
commands,
caches,
store: store.clone(),
paths,
tracked: watch::channel(Vec::new()).0,
found: mpsc::unbounded_channel(),
captures: BTreeMap::new(),
leases: Leases::default(),
mirror_until: MirrorPosition::default(),
queued_jobs: BTreeMap::new(),
cancelling: BTreeMap::new(),
closing: BTreeMap::new(),
last_prune: None,
measured: admission::Measured::default(),
warm: warm::Warm::default(),
records,
build_cache: build_cache.clone(),
default_branches: BTreeMap::new(),
ended_uploads: BTreeMap::new(),
visibility_due: Arc::default(),
visibility_asking: None,
};
engine.publish();
(
engine,
Handle {
status: receiver,
control,
store,
caches: caches_receiver,
records: records_receiver,
build_cache,
},
)
}
pub async fn run(mut self) {
tokio::spawn(metrics::run(
Sampler::new(self.paths.clone(), &self.config.host.slice),
self.tracked.subscribe(),
self.store.clone(),
));
while let Err(error) = self.recover().await {
warn!("cannot list the containers an earlier run left; trying again in 5s: {error}");
if self.control.mode() != Shutdown::No {
return;
}
tokio::select! {
() = tokio::time::sleep(Duration::from_secs(5)) => {}
() = self.control.wake.notified() => {}
}
}
loop {
let mode = self.control.mode();
self.tick(mode).await;
self.publish();
let idle = self.slots.values().flatten().all(Option::is_none);
if mode != Shutdown::No
&& idle
&& self.reseeds.is_empty()
&& self.trim.is_none()
&& self.records.recordings.is_empty()
{
break;
}
let interval = if mode == Shutdown::No {
self.config.host.poll_interval
} else {
Duration::from_secs(5)
};
tokio::select! {
() = tokio::time::sleep(interval) => {}
() = self.control.wake.notified() => {}
Some(command) = self.commands.recv() => {
self.command(command).await;
self.publish();
}
Some(()) = self.records.finished.recv() => {
self.collect_recordings().await;
self.publish();
}
}
}
self.close_shells().await;
if let Err(error) = cache::mirror_down(&*self.docker, &self.names).await {
warn!("cannot stop the mirror: {error}");
}
info!("stopped");
}
async fn serving<T: Send + 'static>(
&mut self,
work: impl Future<Output = T> + Send + 'static,
) -> T {
let mut task = tokio::spawn(work);
loop {
tokio::select! {
biased;
done = &mut task => match done.map_err(tokio::task::JoinError::try_into_panic) {
Ok(output) => return output,
Err(Ok(panic)) => std::panic::resume_unwind(panic),
Err(Err(_)) => std::future::pending::<()>().await,
},
Some(command) = self.commands.recv() => {
self.command(command).await;
self.publish();
self.control.wake.notify_one();
}
}
}
}
fn occupied(&self) -> impl Iterator<Item = (SlotRef, &Occupant)> {
self.slots.iter().flat_map(|(profile, slots)| {
slots.iter().enumerate().filter_map(|(index, slot)| {
slot.as_ref().map(|occupant| {
(
SlotRef {
profile: profile.clone(),
index,
},
occupant,
)
})
})
})
}
fn occupant(&self, slot: &SlotRef) -> Option<&Occupant> {
self.slots
.get(&slot.profile)
.and_then(|slots| slots.get(slot.index))
.and_then(Option::as_ref)
}
fn drop_closed_slots(&mut self) {
for (name, slots) in &mut self.slots {
let count = self
.config
.profiles
.get(name)
.map_or(0, |profile| profile.slots as usize);
while slots.len() > count && slots.last().is_some_and(Option::is_none) {
slots.pop();
}
}
let profiles = &self.config.profiles;
self.slots
.retain(|name, slots| !slots.is_empty() || profiles.contains_key(name));
let (slots, reseeds) = (&self.slots, &self.reseeds);
self.retired
.retain(|name, _| slots.contains_key(name) || reseeds.contains_key(name));
}
}
#[cfg(test)]
mod tests;