forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
17.2 KiB
1//! On-disk layout of the image store, the metadata format, and garbage collection.
2
3use std::collections::HashSet;
4use std::fs;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::time::{Duration, SystemTime};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use super::{ImageError, PreparedImage};
13use crate::sandbox::idmap::{IdMap, remove_tree};
14
15/// The default cap on the unpacked size of one image.
16pub const DEFAULT_MAX_UNPACKED_BYTES: u64 = 20 * 1024 * 1024 * 1024;
17
18/// Staging files and dirs older than this are assumed to be left behind by a crash and are removed.
19const STALE_TEMP_AGE: Duration = Duration::from_secs(60 * 60);
20/// Name prefixes of staging entries: `tempfile`'s default prefix, and the unpack staging dirs.
21const TEMP_PREFIXES: [&str; 2] = [".tmp", ".unpack-"];
22
23/// A cache of pulled and unpacked images rooted at one directory. See the module docs of
24/// [`super`] for the layout.
25pub struct ImageStore {
26 root: PathBuf,
27 max_unpacked_bytes: u64,
28 /// The id map that unpacking and removal run under. Single unless set by `with_idmap`.
29 pub(super) idmap: IdMap,
30 /// `forge-runner` itself, which runs `__sandbox-unpack` and `__sandbox-rm` in subuid mode.
31 pub(super) helper_exe: PathBuf,
32}
33
34/// What a garbage collection pass removed.
35#[derive(Debug, Default, Clone, PartialEq, Eq)]
36pub struct GcStats {
37 pub images_removed: u64,
38 pub blobs_removed: u64,
39 /// Staging leftovers (temp files and `.unpack-*` dirs) from crashed pulls.
40 pub temp_removed: u64,
41}
42
43/// The per-rootfs metadata stored in `meta/<hex>.json`.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub(super) struct ImageMeta {
46 pub digest: String,
47 pub env: Vec<String>,
48 pub working_dir: Option<String>,
49 pub user: Option<String>,
50 /// Layer digests in order, as bare hex. Garbage collection keeps these blobs.
51 pub layers: Vec<String>,
52}
53
54impl ImageStore {
55 /// A store rooted at `root`, with the default unpacked size cap.
56 pub fn new(root: PathBuf) -> Self {
57 Self {
58 root,
59 max_unpacked_bytes: DEFAULT_MAX_UNPACKED_BYTES,
60 idmap: IdMap::single(),
61 helper_exe: PathBuf::new(),
62 }
63 }
64
65 /// Sets the cap on the unpacked size of one image, in bytes.
66 pub fn with_max_unpacked_bytes(mut self, bytes: u64) -> Self {
67 self.max_unpacked_bytes = bytes;
68 self
69 }
70
71 /// Unpacks under `map`. A subuid map unpacks through `helper_exe` (`__sandbox-unpack`), and its
72 /// rootfs dirs are named after the range, so a changed range unpacks again. A single map keeps
73 /// the plain `rootfs/<digest>` layout.
74 pub fn with_idmap(mut self, map: IdMap, helper_exe: PathBuf) -> Self {
75 self.idmap = map;
76 self.helper_exe = helper_exe;
77 self
78 }
79
80 pub(super) fn max_unpacked_bytes(&self) -> u64 {
81 self.max_unpacked_bytes
82 }
83
84 pub(super) fn root_dir(&self) -> &Path {
85 &self.root
86 }
87
88 pub(super) fn blob_path(&self, hex: &str) -> PathBuf {
89 self.root.join("blobs/sha256").join(hex)
90 }
91
92 pub(super) fn rootfs_path(&self, hex: &str) -> PathBuf {
93 self.root
94 .join("rootfs")
95 .join(rootfs_dir_name(&self.idmap, hex))
96 }
97
98 /// Removes a file, symlink or directory tree from the store. Trees go through `remove_tree`,
99 /// because subuid-owned files can only be removed by the helper.
100 pub(super) fn remove_entry(&self, path: &Path) -> io::Result<()> {
101 match fs::symlink_metadata(path) {
102 Ok(meta) if meta.is_dir() => remove_tree(&self.idmap, path, &self.helper_exe),
103 Ok(_) => fs::remove_file(path),
104 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
105 Err(err) => Err(err),
106 }
107 }
108
109 pub(super) fn meta_path(&self, hex: &str) -> PathBuf {
110 self.root.join("meta").join(format!("{hex}.json"))
111 }
112
113 pub(super) fn lock_path(&self, hex: &str) -> PathBuf {
114 self.root.join("locks").join(format!("{hex}.lock"))
115 }
116
117 pub(super) fn stamp_path(&self, hex: &str) -> PathBuf {
118 self.root.join("last_used").join(hex)
119 }
120
121 fn tag_path(&self, reference: &str) -> PathBuf {
122 self.root.join("tags").join(encode_tag(reference))
123 }
124
125 /// Returns the image for `hex` when both its rootfs and its metadata are present. The rootfs
126 /// is only renamed into place once it is complete, so presence means complete.
127 pub(super) fn cached(&self, hex: &str) -> Result<Option<PreparedImage>, ImageError> {
128 if !self.rootfs_path(hex).is_dir() || !self.meta_path(hex).is_file() {
129 return Ok(None);
130 }
131 let meta: ImageMeta = serde_json::from_slice(&fs::read(self.meta_path(hex))?)?;
132 Ok(Some(self.prepared(hex, meta)))
133 }
134
135 pub(super) fn prepared(&self, hex: &str, meta: ImageMeta) -> PreparedImage {
136 PreparedImage {
137 rootfs: self.rootfs_path(hex),
138 digest: meta.digest,
139 env: meta.env,
140 working_dir: meta.working_dir,
141 user: meta.user,
142 }
143 }
144
145 /// The image that `reference` last resolved to, if it is still cached.
146 pub(super) fn cached_for_tag(
147 &self,
148 reference: &str,
149 ) -> Result<Option<PreparedImage>, ImageError> {
150 let path = self.tag_path(reference);
151 if !path.is_file() {
152 return Ok(None);
153 }
154 let digest = fs::read_to_string(&path)?;
155 let hex = super::pull::hex_of(digest.trim())?;
156 self.cached(hex)
157 }
158
159 pub(super) fn write_tag(&self, reference: &str, digest: &str) -> Result<(), ImageError> {
160 write_atomic(&self.tag_path(reference), digest.as_bytes())
161 }
162
163 pub(super) fn write_meta(&self, hex: &str, meta: &ImageMeta) -> Result<(), ImageError> {
164 write_atomic(&self.meta_path(hex), &serde_json::to_vec(meta)?)
165 }
166
167 /// Marks the image as used now, for garbage collection.
168 pub(super) fn touch(&self, hex: &str) -> io::Result<()> {
169 let path = self.stamp_path(hex);
170 if let Some(parent) = path.parent() {
171 fs::create_dir_all(parent)?;
172 }
173 fs::write(path, b"")
174 }
175
176 /// Deletes unpacked rootfs directories and metadata that have not been used for `keep_days`,
177 /// then deletes layer blobs that no kept image refers to and that are older than `keep_days`.
178 /// Staging leftovers older than an hour are removed first. Images are locked while they are
179 /// checked, so a concurrent pull of the same image waits.
180 pub fn gc(&self, keep_days: u32) -> Result<GcStats, ImageError> {
181 let max_age = Duration::from_secs(u64::from(keep_days) * 86_400);
182 let now = SystemTime::now();
183 let mut stats = GcStats::default();
184 let mut live_layers: HashSet<String> = HashSet::new();
185
186 for dir in ["rootfs", "blobs/sha256", "meta", "tags"] {
187 for path in entries(&self.root.join(dir))? {
188 let is_temp = path
189 .file_name()
190 .and_then(|name| name.to_str())
191 .is_some_and(|name| TEMP_PREFIXES.iter().any(|p| name.starts_with(p)));
192 if is_temp && modified(&path).is_some_and(|t| older_than(t, now, STALE_TEMP_AGE)) {
193 self.remove_entry(&path)?;
194 stats.temp_removed += 1;
195 }
196 }
197 }
198
199 for meta_file in entries(&self.root.join("meta"))? {
200 let Some(hex) = file_stem(&meta_file) else {
201 continue;
202 };
203 let last_used = modified(&self.stamp_path(&hex)).or_else(|| modified(&meta_file));
204 let expired = last_used.is_none_or(|t| older_than(t, now, max_age));
205 let _lock = lock_file(&self.lock_path(&hex))?;
206 if expired {
207 self.remove_entry(&self.rootfs_path(&hex))?;
208 remove_file_if_exists(&meta_file)?;
209 remove_file_if_exists(&self.stamp_path(&hex))?;
210 stats.images_removed += 1;
211 } else if let Ok(meta) = serde_json::from_slice::<ImageMeta>(&fs::read(&meta_file)?) {
212 live_layers.extend(meta.layers);
213 }
214 }
215
216 for blob in entries(&self.root.join("blobs/sha256"))? {
217 let Some(name) = file_stem(&blob) else {
218 continue;
219 };
220 if live_layers.contains(&name) {
221 continue;
222 }
223 if modified(&blob).is_some_and(|t| older_than(t, now, max_age)) {
224 remove_file_if_exists(&blob)?;
225 stats.blobs_removed += 1;
226 }
227 }
228 Ok(stats)
229 }
230}
231
232/// Opens (creating if needed) and exclusively locks a lock file. The lock is released when the
233/// returned file is dropped.
234pub(super) fn lock_file(path: &Path) -> io::Result<fs::File> {
235 if let Some(parent) = path.parent() {
236 fs::create_dir_all(parent)?;
237 }
238 let file = fs::OpenOptions::new()
239 .read(true)
240 .write(true)
241 .create(true)
242 .truncate(false)
243 .open(path)?;
244 file.lock()?;
245 Ok(file)
246}
247
248/// Writes `bytes` to `path` through a temp file in the same directory, so readers never see a
249/// partial file.
250pub(super) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ImageError> {
251 let dir = path
252 .parent()
253 .ok_or_else(|| io::Error::other("path has no parent directory"))?;
254 fs::create_dir_all(dir)?;
255 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
256 tmp.write_all(bytes)?;
257 tmp.as_file().sync_all()?;
258 tmp.persist(path).map_err(|err| err.error)?;
259 Ok(())
260}
261
262/// Turns a reference into one file name. Letters, digits, `.` and `-` are kept, and every other
263/// byte becomes `%xx`, so the encoding is injective. Long references are replaced by a hash.
264fn encode_tag(reference: &str) -> String {
265 let mut out = String::with_capacity(reference.len());
266 for byte in reference.bytes() {
267 if byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'-' {
268 out.push(char::from(byte));
269 } else {
270 out.push_str(&format!("%{byte:02x}"));
271 }
272 }
273 if out.len() > 200 {
274 out = hex::encode(Sha256::digest(reference.as_bytes()));
275 }
276 out
277}
278
279/// The entries of `dir`, or none if it does not exist.
280fn entries(dir: &Path) -> io::Result<Vec<PathBuf>> {
281 match fs::read_dir(dir) {
282 Ok(read) => read.map(|entry| entry.map(|e| e.path())).collect(),
283 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
284 Err(err) => Err(err),
285 }
286}
287
288fn file_stem(path: &Path) -> Option<String> {
289 path.file_stem()
290 .and_then(|name| name.to_str())
291 .map(str::to_owned)
292}
293
294fn modified(path: &Path) -> Option<SystemTime> {
295 fs::metadata(path).and_then(|meta| meta.modified()).ok()
296}
297
298fn older_than(time: SystemTime, now: SystemTime, max_age: Duration) -> bool {
299 now.duration_since(time).is_ok_and(|age| age > max_age)
300}
301
302/// The rootfs dir name for `hex`: `<hex>` in single mode, and `<hex>-s<start>-<n>` for a subuid
303/// range, so each range has its own unpacked tree.
304fn rootfs_dir_name(map: &IdMap, hex: &str) -> String {
305 match map.sub_uid {
306 Some((start, n)) if map.is_subuid() => format!("{hex}-s{start}-{n}"),
307 _ => hex.to_owned(),
308 }
309}
310
311fn remove_file_if_exists(path: &Path) -> io::Result<()> {
312 match fs::remove_file(path) {
313 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
314 other => other,
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn rootfs_dir_names_follow_the_mode() {
324 let hex = "ab".repeat(32);
325 assert_eq!(rootfs_dir_name(&IdMap::single(), &hex), hex);
326 let subuid = IdMap {
327 uid: 1000,
328 gid: 1000,
329 sub_uid: Some((524_288, 65_536)),
330 sub_gid: Some((524_288, 65_536)),
331 };
332 assert_eq!(
333 rootfs_dir_name(&subuid, &hex),
334 format!("{hex}-s524288-65536")
335 );
336 let other_range = IdMap {
337 sub_uid: Some((600_000, 65_536)),
338 ..subuid
339 };
340 assert_ne!(
341 rootfs_dir_name(&other_range, &hex),
342 rootfs_dir_name(&subuid, &hex)
343 );
344 }
345
346 #[test]
347 fn tag_encoding_is_injective_for_similar_names() {
348 assert_ne!(encode_tag("a/b:c"), encode_tag("a_b:c"));
349 assert!(!encode_tag("docker.io/library/alpine:latest").contains('/'));
350 assert!(encode_tag(&"x".repeat(500)).len() <= 200);
351 }
352
353 fn hex_n(n: u64) -> String {
354 format!("{n:064x}")
355 }
356
357 /// Sets the mtime of a file or directory to `age` in the past.
358 fn set_age(path: &Path, age: Duration) {
359 let when = SystemTime::now() - age;
360 fs::File::open(path)
361 .expect("open")
362 .set_modified(when)
363 .expect("set_modified");
364 }
365
366 /// Writes an image that was last used just now, with the given layer blobs.
367 fn fresh_image(store: &ImageStore, hex: &str, layers: &[String]) {
368 fs::create_dir_all(store.rootfs_path(hex)).expect("rootfs");
369 fs::write(store.rootfs_path(hex).join("file"), b"x").expect("file");
370 store
371 .write_meta(
372 hex,
373 &ImageMeta {
374 digest: format!("sha256:{hex}"),
375 env: Vec::new(),
376 working_dir: None,
377 user: None,
378 layers: layers.to_vec(),
379 },
380 )
381 .expect("meta");
382 store.touch(hex).expect("stamp");
383 }
384
385 fn blob(store: &ImageStore, hex: &str) {
386 fs::create_dir_all(store.blob_path(hex).parent().expect("parent")).expect("blobs");
387 fs::write(store.blob_path(hex), b"layer").expect("blob");
388 }
389
390 #[test]
391 fn gc_removes_stale_images_and_blobs_and_keeps_fresh_ones() {
392 let dir = tempfile::tempdir().expect("tempdir");
393 let store = ImageStore::new(dir.path().join("oci"));
394 let (stale_img, fresh_img) = (hex_n(1), hex_n(2));
395 let (stale_layer, shared, orphan_old, orphan_fresh) =
396 (hex_n(3), hex_n(4), hex_n(5), hex_n(6));
397 let month = Duration::from_secs(30 * 86_400);
398
399 fresh_image(&store, &stale_img, &[stale_layer.clone(), shared.clone()]);
400 set_age(&store.stamp_path(&stale_img), month);
401 fresh_image(&store, &fresh_img, std::slice::from_ref(&shared));
402
403 blob(&store, &stale_layer);
404 blob(&store, &shared);
405 blob(&store, &orphan_old);
406 blob(&store, &orphan_fresh);
407 set_age(&store.blob_path(&stale_layer), month);
408 set_age(&store.blob_path(&shared), month);
409 set_age(&store.blob_path(&orphan_old), month);
410
411 let stats = store.gc(7).expect("gc");
412 assert_eq!(
413 stats,
414 GcStats {
415 images_removed: 1,
416 blobs_removed: 2,
417 temp_removed: 0,
418 }
419 );
420
421 assert!(!store.rootfs_path(&stale_img).exists());
422 assert!(!store.meta_path(&stale_img).exists());
423 assert!(store.rootfs_path(&fresh_img).is_dir());
424 assert!(store.meta_path(&fresh_img).is_file());
425
426 // Stale but still referenced by a fresh image: kept.
427 assert!(store.blob_path(&shared).is_file());
428 // Stale and only referenced by the removed image: removed.
429 assert!(!store.blob_path(&stale_layer).exists());
430 // Stale orphan: removed. Fresh orphan: kept.
431 assert!(!store.blob_path(&orphan_old).exists());
432 assert!(store.blob_path(&orphan_fresh).is_file());
433 }
434
435 #[test]
436 fn gc_removes_stale_staging_leftovers_only() {
437 let dir = tempfile::tempdir().expect("tempdir");
438 let store = ImageStore::new(dir.path().join("oci"));
439 let root = store.root_dir().to_path_buf();
440 let two_hours = Duration::from_secs(2 * 3600);
441
442 let stale_dir = root.join("rootfs/.unpack-stale");
443 fs::create_dir_all(stale_dir.join("usr")).expect("stale dir");
444 fs::write(stale_dir.join("usr/file"), b"partial").expect("partial");
445 let fresh_dir = root.join("rootfs/.unpack-fresh");
446 fs::create_dir_all(&fresh_dir).expect("fresh dir");
447
448 let blobs = root.join("blobs/sha256");
449 fs::create_dir_all(&blobs).expect("blobs");
450 let stale_blob_tmp = blobs.join(".tmpstale");
451 fs::write(&stale_blob_tmp, b"partial").expect("tmp");
452 let fresh_blob_tmp = blobs.join(".tmpfresh");
453 fs::write(&fresh_blob_tmp, b"partial").expect("tmp");
454
455 let meta = root.join("meta");
456 fs::create_dir_all(&meta).expect("meta");
457 let stale_meta_tmp = meta.join(".tmpstale");
458 fs::write(&stale_meta_tmp, b"{}").expect("meta tmp");
459
460 // A non-staging name in the same dir is never touched, however old it is.
461 let plain = root.join("rootfs/keep-me");
462 fs::create_dir_all(&plain).expect("plain");
463 set_age(&plain, two_hours);
464
465 set_age(&stale_dir.join("usr"), two_hours);
466 set_age(&stale_dir, two_hours);
467 set_age(&stale_blob_tmp, two_hours);
468 set_age(&stale_meta_tmp, two_hours);
469
470 let stats = store.gc(7).expect("gc");
471 assert_eq!(stats.temp_removed, 3);
472 assert_eq!(stats.blobs_removed, 0);
473 assert!(!stale_dir.exists());
474 assert!(fresh_dir.is_dir());
475 assert!(!stale_blob_tmp.exists());
476 assert!(fresh_blob_tmp.is_file());
477 assert!(!stale_meta_tmp.exists());
478 assert!(plain.is_dir());
479 }
480}