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