Skip to main content

runnerd_core/
buildcache.rs

1//! The build cache every runner shares (ADR 0052): the entries of Gradle's HTTP build cache, and of the other tools
2//! the runner image points at it, kept per repository in a directory of the daemon's and served to each runner on a
3//! Unix socket of its own, which the runner image forwards to `127.0.0.1`. Owns the entries, the size limit, the
4//! sockets, and what each runner was served and stored. Which socket a request came in on is who sent it; no job can
5//! claim another's. It never decides whether a job may write: the engine tells it (`Writes`), from what GitHub says
6//! of the job. It never logs a request's headers, which may carry a tool's token.
7
8mod serve;
9
10use std::collections::HashMap;
11use std::io;
12use std::os::unix::fs::PermissionsExt;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15
16use tokio::net::UnixListener;
17use tokio::task::{JoinHandle, JoinSet};
18use tracing::warn;
19
20use crate::api::BuildCacheCounts;
21use crate::github::RunTrigger;
22use crate::sync::lock;
23
24/// Where a runner container mounts its socket's directory, read-only (`image/entrypoint.sh`).
25pub const MOUNT: &str = "/run/runnerd-build-cache";
26
27/// The socket's name in that directory.
28pub const SOCKET: &str = "cache.sock";
29
30/// The directory of the entries, in the build cache's directory: nothing else there is counted or trimmed.
31const ENTRIES: &str = "entries";
32
33/// Where uploads are written before they become entries, in the build cache's directory; emptied at start.
34const UPLOADS: &str = "uploads";
35
36/// Whether a runner's job may write entries.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Writes {
39    /// Not known yet: what it sends is held until it is.
40    Undecided,
41    /// GitHub says it runs a push, scheduled or manually started run of a branch that may write.
42    Allowed,
43    /// Anything else: a pull request, another branch, or a job GitHub does not place on this runner.
44    Refused,
45}
46
47/// The events whose runs may write: they run code that reached a branch, never a pull request's.
48const WRITING_EVENTS: [&str; 3] = ["push", "schedule", "workflow_dispatch"];
49
50/// Whether `runner`'s job may write, from what GitHub says: the runner it placed the job on (`job_runner`, which
51/// a job cannot forge as it can its runner's log), and its run's trigger. Only a push, scheduled or manually started
52/// run of `repository` itself on one of `branches` may; `Undecided` while GitHub places the job on no runner.
53pub fn writes(
54    runner: &str,
55    job_runner: Option<&str>,
56    repository: &str,
57    trigger: &RunTrigger,
58    branches: &[String],
59) -> Writes {
60    match job_runner {
61        None => Writes::Undecided,
62        Some(placed) if placed != runner => Writes::Refused,
63        Some(_)
64            if WRITING_EVENTS.contains(&trigger.event.as_str())
65                && trigger.head_repository.as_deref() == Some(repository)
66                && trigger
67                    .head_branch
68                    .as_ref()
69                    .is_some_and(|branch| branches.contains(branch)) =>
70        {
71            Writes::Allowed
72        }
73        Some(_) => Writes::Refused,
74    }
75}
76
77/// The build cache's size now.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct Size {
80    /// The entries' bytes.
81    pub bytes: u64,
82    /// How many entries there are.
83    pub entries: u64,
84}
85
86/// What `trim` removed.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct Trimmed {
89    /// The size before.
90    pub before: Size,
91    /// The size after.
92    pub after: Size,
93}
94
95/// The shared build cache. Cloning shares it.
96#[derive(Debug, Clone)]
97pub struct BuildCache {
98    inner: Arc<Inner>,
99}
100
101#[derive(Debug)]
102struct Inner {
103    /// The entries, under `<owner>/<name>/<tool>/<key>`.
104    root: PathBuf,
105    /// The uploads not yet entries.
106    uploads: PathBuf,
107    /// One directory per runner, holding its socket.
108    sockets: PathBuf,
109    // A panic midway leaves at worst an entry counted that is gone, or a file not counted, which `open` counts at
110    // the next start.
111    state: Mutex<State>,
112}
113
114#[derive(Debug, Default)]
115struct State {
116    max_size: u64,
117    entries: HashMap<PathBuf, Entry>,
118    bytes: u64,
119    runners: HashMap<String, Runner>,
120    next_upload: u64,
121    /// The bytes of the uploads not yet entries, being received or held: at most `max_size`, so jobs whose writes
122    /// are undecided cannot fill the disk.
123    uploading: u64,
124}
125
126#[derive(Debug, Clone, Copy)]
127struct Entry {
128    bytes: u64,
129    /// When it was last written or served, in ms: the least recently used go first.
130    used: u64,
131}
132
133#[derive(Debug)]
134struct Runner {
135    repository: String,
136    writes: Writes,
137    counts: BuildCacheCounts,
138    /// Uploads waiting for `Writes` to be decided.
139    held: Vec<Upload>,
140    /// The bytes of its uploads not yet entries, being received or held: at most a quarter of `max_size` while its
141    /// writes are undecided, so one job cannot take the room another's uploads need.
142    uploading: u64,
143    dir: PathBuf,
144    serving: JoinHandle<()>,
145}
146
147/// An upload written to a file of its own, not an entry yet.
148#[derive(Debug)]
149struct Upload {
150    file: PathBuf,
151    entry: PathBuf,
152    bytes: u64,
153}
154
155impl BuildCache {
156    /// Opens the build cache in `directory`, counting the entries an earlier run left, with each runner's socket
157    /// under `sockets`.
158    pub fn open(directory: &Path, sockets: &Path, max_size: u64) -> io::Result<BuildCache> {
159        std::fs::create_dir_all(directory)?;
160        let (root, uploads) = (directory.join(ENTRIES), directory.join(UPLOADS));
161        match std::fs::remove_dir_all(&uploads) {
162            Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
163            _ => {}
164        }
165        private_dir(&root)?;
166        private_dir(&uploads)?;
167        private_dir(sockets)?;
168        let mut entries = HashMap::new();
169        count_entries(&root, &root, &mut entries)?;
170        let bytes = entries.values().map(|entry: &Entry| entry.bytes).sum();
171        Ok(BuildCache {
172            inner: Arc::new(Inner {
173                root,
174                uploads,
175                sockets: sockets.to_owned(),
176                state: Mutex::new(State {
177                    max_size,
178                    entries,
179                    bytes,
180                    ..State::default()
181                }),
182            }),
183        })
184    }
185
186    /// Where the entries are: `entries` in the build cache's directory.
187    pub fn directory(&self) -> &Path {
188        &self.inner.root
189    }
190
191    /// Sets the size `trim` brings it under.
192    pub fn set_max_size(&self, max_size: u64) {
193        lock(&self.inner.state).max_size = max_size;
194    }
195
196    /// Opens `runner`'s socket for a job of `repository` and returns the directory its container mounts at `MOUNT`.
197    /// A runner already open, as after a restart, gets its socket back in the directory its container mounts.
198    pub fn attach(&self, runner: &str, repository: &str) -> io::Result<PathBuf> {
199        if !valid_repository(repository) {
200            return Err(io::Error::new(
201                io::ErrorKind::InvalidInput,
202                format!("{repository} is not a repository name"),
203            ));
204        }
205        let dir = self.inner.sockets.join(socket_dir(runner));
206        std::fs::create_dir_all(&dir)?;
207        // The container's root, which forwards to it, is not the daemon's user under sysbox.
208        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))?;
209        let socket = dir.join(SOCKET);
210        match std::fs::remove_file(&socket) {
211            Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
212            _ => {}
213        }
214        let listener = UnixListener::bind(&socket)?;
215        std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o666))?;
216        let serving = tokio::spawn(accept(self.inner.clone(), runner.to_owned(), listener));
217        let old = lock(&self.inner.state).runners.insert(
218            runner.to_owned(),
219            Runner {
220                repository: repository.to_owned(),
221                writes: Writes::Undecided,
222                counts: BuildCacheCounts::default(),
223                held: Vec::new(),
224                uploading: 0,
225                dir: dir.clone(),
226                serving,
227            },
228        );
229        if let Some(old) = old {
230            old.serving.abort();
231            self.inner.remove_uploads(None, &old.held);
232        }
233        Ok(dir)
234    }
235
236    /// Removes the socket directories of runners not in `keep`, which an earlier run left.
237    pub fn forget_others(&self, keep: &[&str]) {
238        let keep: Vec<String> = keep.iter().map(|runner| socket_dir(runner)).collect();
239        let Ok(dirs) = std::fs::read_dir(&self.inner.sockets) else {
240            return;
241        };
242        for dir in dirs.flatten() {
243            if !keep
244                .iter()
245                .any(|name| *name == dir.file_name().to_string_lossy())
246            {
247                let _ = std::fs::remove_dir_all(dir.path());
248            }
249        }
250    }
251
252    /// Whether `runner` is open and its writes undecided.
253    pub fn undecided(&self, runner: &str) -> bool {
254        lock(&self.inner.state)
255            .runners
256            .get(runner)
257            .is_some_and(|runner| runner.writes == Writes::Undecided)
258    }
259
260    /// Decides whether `runner`'s job may write, once: what it sent meanwhile becomes entries or is dropped.
261    pub fn decide(&self, runner: &str, writes: Writes) {
262        let held = {
263            let mut state = lock(&self.inner.state);
264            let Some(open) = state.runners.get_mut(runner) else {
265                return;
266            };
267            if open.writes != Writes::Undecided || writes == Writes::Undecided {
268                return;
269            }
270            open.writes = writes;
271            std::mem::take(&mut open.held)
272        };
273        match writes {
274            Writes::Allowed => {
275                for upload in held {
276                    self.inner.store(runner, upload);
277                }
278            }
279            Writes::Refused | Writes::Undecided => {
280                if let Some(open) = lock(&self.inner.state).runners.get_mut(runner) {
281                    open.counts.refused += held.len() as u64;
282                }
283                self.inner.remove_uploads(Some(runner), &held);
284            }
285        }
286    }
287
288    /// Closes `runner`'s socket and returns what it was served and stored, and whether it could write. What it sent
289    /// while undecided is dropped.
290    pub fn detach(&self, runner: &str) -> Option<BuildCacheCounts> {
291        let mut open = lock(&self.inner.state).runners.remove(runner)?;
292        open.serving.abort();
293        self.inner.remove_uploads(None, &open.held);
294        open.counts.refused += open.held.len() as u64;
295        open.counts.writer = open.writes == Writes::Allowed;
296        if let Err(error) = std::fs::remove_dir_all(&open.dir)
297            && error.kind() != io::ErrorKind::NotFound
298        {
299            warn!("cannot remove {}: {error}", open.dir.display());
300        }
301        Some(open.counts)
302    }
303
304    /// The size of the entries now.
305    pub fn size(&self) -> Size {
306        let state = lock(&self.inner.state);
307        Size {
308            bytes: state.bytes,
309            entries: state.entries.len() as u64,
310        }
311    }
312
313    /// Removes the entries used least recently until the rest fit in the size limit. Blocks on the file system: call
314    /// it off the engine's loop, and only while no runner runs, so no job finds an entry gone midway.
315    pub fn trim(&self) -> Trimmed {
316        let limit = lock(&self.inner.state).max_size;
317        self.trim_to(limit)
318    }
319
320    /// Removes every entry; call it only while no runner runs, as `trim`.
321    pub fn clear(&self) -> Trimmed {
322        self.trim_to(0)
323    }
324
325    fn trim_to(&self, limit: u64) -> Trimmed {
326        let (before, victims) = {
327            let mut state = lock(&self.inner.state);
328            let before = Size {
329                bytes: state.bytes,
330                entries: state.entries.len() as u64,
331            };
332            let mut by_use: Vec<(PathBuf, Entry)> = state
333                .entries
334                .iter()
335                .map(|(path, entry)| (path.clone(), *entry))
336                .collect();
337            by_use.sort_by_key(|(path, entry)| (entry.used, path.clone()));
338            let mut victims = Vec::new();
339            for (path, entry) in by_use {
340                if state.bytes <= limit && (limit > 0 || state.entries.is_empty()) {
341                    break;
342                }
343                state.entries.remove(&path);
344                state.bytes -= entry.bytes;
345                victims.push(path);
346            }
347            (before, victims)
348        };
349        for path in victims {
350            if let Err(error) = std::fs::remove_file(self.inner.root.join(&path))
351                && error.kind() != io::ErrorKind::NotFound
352            {
353                warn!(
354                    "cannot remove build cache entry {}: {error}",
355                    path.display()
356                );
357            }
358        }
359        Trimmed {
360            before,
361            after: self.size(),
362        }
363    }
364}
365
366impl Inner {
367    /// Makes an upload of `runner`'s, whose job may write, an entry; refuses it when the entries already take twice
368    /// the size limit, which only a busy hour between two trims reaches.
369    fn store(&self, runner: &str, upload: Upload) {
370        let fits = {
371            let state = lock(&self.state);
372            let replaced = state
373                .entries
374                .get(&upload.entry)
375                .map_or(0, |entry| entry.bytes);
376            state.bytes - replaced + upload.bytes <= state.max_size.saturating_mul(2)
377        };
378        let path = self.root.join(&upload.entry);
379        let stored = fits
380            && path
381                .parent()
382                .is_none_or(|parent| std::fs::create_dir_all(parent).is_ok())
383            && std::fs::rename(&upload.file, &path).is_ok();
384        let mut state = lock(&self.state);
385        Self::give_back(&mut state, Some(runner), upload.bytes);
386        if stored {
387            let entry = Entry {
388                bytes: upload.bytes,
389                used: crate::time::now_ms(),
390            };
391            if let Some(old) = state.entries.insert(upload.entry, entry) {
392                state.bytes -= old.bytes;
393            }
394            state.bytes += upload.bytes;
395        } else {
396            let _ = std::fs::remove_file(&upload.file);
397        }
398        if let Some(open) = state.runners.get_mut(runner) {
399            if stored {
400                open.counts.written_bytes += upload.bytes;
401            } else {
402                open.counts.refused += 1;
403            }
404        }
405    }
406
407    /// Takes `bytes` more of the uploads' room for `runner`, unless they would take more than `max_size`, or, while
408    /// its writes are undecided, its own more than a quarter of it.
409    fn reserve(&self, runner: &str, bytes: u64) -> bool {
410        let mut state = lock(&self.state);
411        let (total, max_size) = (state.uploading, state.max_size);
412        let Some(open) = state.runners.get_mut(runner) else {
413            return false;
414        };
415        let fits = total + bytes <= max_size
416            && (open.writes != Writes::Undecided || open.uploading + bytes <= max_size / 4);
417        if fits {
418            open.uploading += bytes;
419            state.uploading += bytes;
420        }
421        fits
422    }
423
424    /// Gives back what `reserve` took for an upload of `runner`'s, if it is still open, that became an entry or was
425    /// removed.
426    fn release(&self, runner: Option<&str>, bytes: u64) {
427        Self::give_back(&mut lock(&self.state), runner, bytes);
428    }
429
430    fn give_back(state: &mut State, runner: Option<&str>, bytes: u64) {
431        state.uploading = state.uploading.saturating_sub(bytes);
432        if let Some(open) = runner.and_then(|runner| state.runners.get_mut(runner)) {
433            open.uploading = open.uploading.saturating_sub(bytes);
434        }
435    }
436
437    /// Removes uploads of `runner`'s, or of a runner no longer open, and gives back their room.
438    fn remove_uploads(&self, runner: Option<&str>, uploads: &[Upload]) {
439        for upload in uploads {
440            let _ = std::fs::remove_file(&upload.file);
441            self.release(runner, upload.bytes);
442        }
443    }
444
445    /// Marks an entry used now, in the index and, so the order survives a restart, in its file's time.
446    fn touch(&self, entry: &Path) {
447        if let Some(used) = lock(&self.state).entries.get_mut(entry) {
448            used.used = crate::time::now_ms();
449        }
450        let path = self.root.join(entry);
451        tokio::task::spawn_blocking(move || {
452            if let Ok(file) = std::fs::File::open(&path) {
453                let _ = file.set_modified(std::time::SystemTime::now());
454            }
455        });
456    }
457}
458
459/// Serves `runner`'s connections until it is detached, which aborts this task and with it every connection.
460async fn accept(inner: Arc<Inner>, runner: String, listener: UnixListener) {
461    let mut connections = JoinSet::new();
462    loop {
463        tokio::select! {
464            accepted = listener.accept() => match accepted {
465                Ok((stream, _)) => {
466                    connections.spawn(serve::connection(inner.clone(), runner.clone(), stream));
467                }
468                Err(error) => {
469                    warn!("{runner}: the build cache's socket failed: {error}");
470                    return;
471                }
472            },
473            Some(_) = connections.join_next() => {}
474        }
475    }
476}
477
478/// The directory of a runner's socket: a hash of its name, as a socket's path must stay under 108 bytes.
479fn socket_dir(runner: &str) -> String {
480    // FNV-1a, as `HostIdentity` hashes.
481    let hash = runner
482        .bytes()
483        .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
484            (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
485        });
486    format!("{hash:016x}")
487}
488
489/// Whether `name` is a path segment of an entry: letters, digits, `.`, `_` and `-`, not `.` or `..`.
490fn valid_segment(name: &str) -> bool {
491    !name.is_empty()
492        && name.len() <= 200
493        && name != "."
494        && name != ".."
495        && name
496            .bytes()
497            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
498}
499
500/// Whether `repository` is `owner/name` with no other segment.
501fn valid_repository(repository: &str) -> bool {
502    repository.split_once('/').is_some_and(|(owner, name)| {
503        valid_segment(owner) && valid_segment(name) && !owner.starts_with('.')
504    })
505}
506
507fn private_dir(path: &Path) -> io::Result<()> {
508    std::fs::create_dir_all(path)?;
509    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
510}
511
512/// Counts the entries under `dir`, by their path under `root`, last used when last modified.
513fn count_entries(root: &Path, dir: &Path, entries: &mut HashMap<PathBuf, Entry>) -> io::Result<()> {
514    for item in std::fs::read_dir(dir)? {
515        let item = item?;
516        let path = item.path();
517        let metadata = item.metadata()?;
518        if metadata.is_dir() {
519            count_entries(root, &path, entries)?;
520        } else if metadata.is_file()
521            && let Ok(relative) = path.strip_prefix(root)
522        {
523            entries.insert(
524                relative.to_owned(),
525                Entry {
526                    bytes: metadata.len(),
527                    used: metadata.modified().map_or(0, crate::time::ms),
528                },
529            );
530        }
531    }
532    Ok(())
533}
534
535#[cfg(test)]
536mod tests;
537
538/// Sends one request to a runner's socket in `dir`, as a tool in its container would through the forwarder, and
539/// returns the response's status and body.
540#[cfg(test)]
541#[allow(
542    clippy::unwrap_used,
543    reason = "a test helper: a socket that does not answer fails the test"
544)]
545pub(crate) async fn request(dir: &Path, method: &str, path: &str, body: &[u8]) -> (u16, Vec<u8>) {
546    use tokio::io::{AsyncReadExt, AsyncWriteExt};
547    let mut stream = tokio::net::UnixStream::connect(dir.join(SOCKET))
548        .await
549        .unwrap();
550    let head = format!(
551        "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Basic dG9rZW46c2VjcmV0\r\n\
552         Content-Length: {}\r\nConnection: close\r\n\r\n",
553        body.len()
554    );
555    stream.write_all(head.as_bytes()).await.unwrap();
556    stream.write_all(body).await.unwrap();
557    let mut response = Vec::new();
558    stream.read_to_end(&mut response).await.unwrap();
559    let split = response
560        .windows(4)
561        .position(|window| window == b"\r\n\r\n")
562        .unwrap();
563    let status = String::from_utf8_lossy(&response[9..12]).parse().unwrap();
564    (status, response[split + 4..].to_vec())
565}