1mod 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
25pub const MOUNT: &str = "/run/runnerd-build-cache";
27
28pub const SOCKET: &str = "cache.sock";
30
31const ENTRIES: &str = "entries";
33
34const UPLOADS: &str = "uploads";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Writes {
40 Undecided,
42 Allowed,
45 Refused,
47}
48
49const WRITING_EVENTS: [&str; 3] = ["push", "schedule", "workflow_dispatch"];
51
52pub const PULL_REQUEST_EVENT: &str = "pull_request";
55
56pub 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
92pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct Size {
103 pub bytes: u64,
105 pub entries: u64,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct Trimmed {
112 pub before: Size,
114 pub after: Size,
116}
117
118#[derive(Debug, Clone)]
120pub struct BuildCache {
121 inner: Arc<Inner>,
122}
123
124#[derive(Debug)]
125struct Inner {
126 root: PathBuf,
128 uploads: PathBuf,
130 sockets: PathBuf,
132 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 uploading: u64,
147}
148
149#[derive(Debug, Clone, Copy)]
150struct Entry {
151 bytes: u64,
152 used: u64,
154}
155
156#[derive(Debug)]
157struct Runner {
158 repository: String,
159 writes: Writes,
160 counts: BuildCacheCounts,
161 held: Vec<Upload>,
163 uploading: u64,
166 dir: PathBuf,
167 serving: JoinHandle<()>,
168}
169
170#[derive(Debug)]
172struct Upload {
173 file: PathBuf,
174 entry: PathBuf,
175 bytes: u64,
176}
177
178impl BuildCache {
179 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 pub fn directory(&self) -> &Path {
211 &self.inner.root
212 }
213
214 pub fn set_max_size(&self, max_size: u64) {
216 lock(&self.inner.state).max_size = max_size;
217 }
218
219 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 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 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 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 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 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 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 pub fn trim(&self) -> Trimmed {
339 let limit = lock(&self.inner.state).max_size;
340 self.trim_to(limit)
341 }
342
343 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 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 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 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 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 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
482async 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
501fn socket_dir(runner: &str) -> String {
503 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
512fn 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
523fn 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
535fn 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#[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}