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 !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
94pub 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
102fn same_repository(head: Option<&str>, repository: &str) -> bool {
104 head.is_some_and(|head| head.eq_ignore_ascii_case(repository))
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct Size {
110 pub bytes: u64,
112 pub entries: u64,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct Trimmed {
119 pub before: Size,
121 pub after: Size,
123}
124
125#[derive(Debug, Clone)]
127pub struct BuildCache {
128 inner: Arc<Inner>,
129}
130
131#[derive(Debug)]
132struct Inner {
133 root: PathBuf,
135 uploads: PathBuf,
137 sockets: PathBuf,
139 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 uploading: u64,
154}
155
156#[derive(Debug, Clone, Copy)]
157struct Entry {
158 bytes: u64,
159 used: u64,
161}
162
163#[derive(Debug)]
164struct Runner {
165 repository: String,
166 writes: Writes,
167 counts: BuildCacheCounts,
168 held: Vec<Upload>,
170 uploading: u64,
173 dir: PathBuf,
174 serving: JoinHandle<()>,
175}
176
177#[derive(Debug)]
179struct Upload {
180 file: PathBuf,
181 entry: PathBuf,
182 bytes: u64,
183}
184
185impl BuildCache {
186 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 pub fn directory(&self) -> &Path {
218 &self.inner.root
219 }
220
221 pub fn set_max_size(&self, max_size: u64) {
223 lock(&self.inner.state).max_size = max_size;
224 }
225
226 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 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 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 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 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 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 pub fn detach(&self, runner: &str) -> Option<BuildCacheCounts> {
343 let Some(mut open) = lock(&self.inner.state).runners.remove(runner) else {
344 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 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 pub fn trim(&self) -> Trimmed {
377 let limit = lock(&self.inner.state).max_size;
378 self.trim_to(limit)
379 }
380
381 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 fn store(&self, runner: &str, upload: Upload) {
432 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 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 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 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 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
520async 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
539fn socket_dir(runner: &str) -> String {
541 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
550fn 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
561fn 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
573fn 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#[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}