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::github::RunTrigger;
22use crate::sync::lock;
23
24pub const MOUNT: &str = "/run/runnerd-build-cache";
26
27pub const SOCKET: &str = "cache.sock";
29
30const ENTRIES: &str = "entries";
32
33const UPLOADS: &str = "uploads";
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Writes {
39 Undecided,
41 Allowed,
43 Refused,
45}
46
47const WRITING_EVENTS: [&str; 3] = ["push", "schedule", "workflow_dispatch"];
49
50pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct Size {
80 pub bytes: u64,
82 pub entries: u64,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct Trimmed {
89 pub before: Size,
91 pub after: Size,
93}
94
95#[derive(Debug, Clone)]
97pub struct BuildCache {
98 inner: Arc<Inner>,
99}
100
101#[derive(Debug)]
102struct Inner {
103 root: PathBuf,
105 uploads: PathBuf,
107 sockets: PathBuf,
109 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 uploading: u64,
124}
125
126#[derive(Debug, Clone, Copy)]
127struct Entry {
128 bytes: u64,
129 used: u64,
131}
132
133#[derive(Debug)]
134struct Runner {
135 repository: String,
136 writes: Writes,
137 counts: BuildCacheCounts,
138 held: Vec<Upload>,
140 uploading: u64,
143 dir: PathBuf,
144 serving: JoinHandle<()>,
145}
146
147#[derive(Debug)]
149struct Upload {
150 file: PathBuf,
151 entry: PathBuf,
152 bytes: u64,
153}
154
155impl BuildCache {
156 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 pub fn directory(&self) -> &Path {
188 &self.inner.root
189 }
190
191 pub fn set_max_size(&self, max_size: u64) {
193 lock(&self.inner.state).max_size = max_size;
194 }
195
196 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 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 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 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 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 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 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 pub fn trim(&self) -> Trimmed {
316 let limit = lock(&self.inner.state).max_size;
317 self.trim_to(limit)
318 }
319
320 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 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 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 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 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 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
459async 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
478fn socket_dir(runner: &str) -> String {
480 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
489fn 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
500fn 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
512fn 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#[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}