Skip to main content

runnerd_core/
buildcache.rs

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