OXYGENHUB

Coverage / crates/flow/src/cache.rs 94.8%

661 of 697 lines run, 100 of 204 functions · run #216 on main · run missed

1
//! The build cache (oxygenflow#28, spec §5.4): a disk mount that names a
2
//! `cache` is started from a disk kept by an earlier passing job, rather
3
//! than empty, so a build carries its compiled dependencies forward.
4
//!
5
//! Each runner keeps a pool under `<home>/cache`, by repository, job,
6
//! cache name and bookmark, each disk named by its key: the BLAKE3 of the
7
//! image, the job, the cache name and the content of its key files. A job
8
//! takes, in order:
9
//!
10
//! 1. its bookmark's disk with the same key (moved: no copying);
11
//! 2. its bookmark's newest disk (moved; cargo rebuilds what changed);
12
//! 3. another bookmark's disk with the same key, then its newest (copied,
13
//!    so a proposal never takes main's cache away);
14
//! 4. else an empty disk, as without a cache.
15
//!
16
//! A passing job's disk goes back into its bookmark's place; a failing
17
//! job's is thrown away, so nothing it wrote reaches a later job. The
18
//! receipt names what the job started from. The pool is kept under its
19
//! size limit by dropping the disks used longest ago.
20
//!
21
//! A disk is in the [`Form`] its tier mounts (oxygenflow#70): a microVM's
22
//! is an ext4 image, `<key>.ext4`; the syscall tier's is the host
23
//! directory it binds at the mount's target, `<key>.tree`, taken and kept
24
//! the same way. A directory goes into the pool with nothing in it
25
//! writable by others or set-id, and the pool itself is the runner's
26
//! alone.
27
//!
28
//! Beside each disk, `<key>.sources` (`<key>.tree-sources` for a
29
//! directory) lists the sources it was built from (oxygenflow#52): each
30
//! file's content hash and the mtime it had. A job that takes the disk
31
//! gives every file whose content is unchanged that mtime back, so a build
32
//! tool that judges freshness by time (cargo) rebuilds only what changed;
33
//! a changed file keeps the time of its checkout, newer than anything on
34
//! the disk, so it is always rebuilt.
35
//!
36
//! A passing job's disk is pruned before it is kept (oxygenflow#72; see
37
//! [`flow_init::prune`]): over [`flow_init::prune::DEFAULT_PERCENT`] of
38
//! its size (the runner's `cache_prune_percent`), the files the job did
39
//! not use go, oldest first, and a cache still over it is not kept, so a
40
//! kept target never grows until the job's disk is full. A microVM's
41
//! guest prunes its image before it stops (the runner cannot look inside
42
//! an ext4 image without mounting it); the runner prunes a directory
43
//! itself, having set its files' atimes to the epoch before the job
44
//! ([`forget_reads`]) so that every read is seen. The job's log says what
45
//! was kept and pruned.
46
47
use anyhow::{Context, Result};
48
use flow_core::pipeline::Mount;
49
use flow_init::prune::Pruned;
50
use std::path::{Path, PathBuf};
51
use std::time::SystemTime;
52
53
/// The pool's default size limit.
54
pub const DEFAULT_GB: u64 = 64;
55
56
/// The sources a cached disk was built from: path to content hash and
57
/// mtime, in whole seconds (what the workspace image keeps).
58
pub type Sources = std::collections::BTreeMap<String, (String, i64)>;
59
60
/// What a job started a cached disk from.
61
pub struct Taken {
62
    /// For the log and the receipt.
63
    pub how: String,
64
    /// The sources it was built from, when they were kept with it.
65
    pub sources: Option<Sources>,
66
}
67
68
/// How a tier holds a disk mount's files (oxygenflow#70).
69
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70
pub enum Form {
71
    /// An ext4 image of the mount's size, attached to a microVM.
72
    Image,
73
    /// A host directory, bound into the syscall sandbox.
74
    Dir,
75
}
76
77
impl Form {
7844×
    fn ext(self) -> &'static str {
7944×
        match self {
8021×
            Form::Image => "ext4",
8123×
            Form::Dir => "tree",
82
        }
8344×
    }
84
}
85
86
/// Which form a kept entry of the pool is in, by its name.
87104×
fn form_of(p: &Path) -> Option<Form> {
88104×
    match p.extension()?.to_str()? {
89104×
        "ext4" => Some(Form::Image),
9068×
        "tree" => Some(Form::Dir),
9127×
        _ => None,
92
    }
93104×
}
94
9548×
fn sources_of(disk: &Path) -> PathBuf {
9648×
    match form_of(disk) {
9726×
        Some(Form::Dir) => disk.with_extension("tree-sources"),
9822×
        _ => disk.with_extension("sources"),
99
    }
10048×
}
101
10216×
fn read_sources(disk: &Path) -> Option<Sources> {
10316×
    serde_json::from_slice(&std::fs::read(sources_of(disk)).ok()?).ok()
10416×
}
105
106
/// How a passing job's cache disk is pruned before it is kept
107
/// (oxygenflow#72).
108
pub struct Prune<'a> {
109
    /// When the job started ([`flow_init::prune::start_clock`]): what it
110
    /// read or wrote since is what it used.
111
    pub started: SystemTime,
112
    /// The share of the disk the kept cache may fill.
113
    pub percent: u8,
114
    /// What the guest did to a microVM's image, when it said.
115
    pub guest: Option<&'a Pruned>,
116
}
117
118
/// Where a job's cached disks come from and go back to.
119
pub struct Pool {
120
    root: PathBuf,
121
    limit: u64,
122
}
123
124
/// Which job: the pool's coordinates other than the key, and the form its
125
/// tier keeps a disk in.
126
pub struct Ctx<'a> {
127
    pub repo: &'a str,
128
    pub bookmark: &'a str,
129
    pub job: &'a str,
130
    pub image: &'a str,
131
    pub form: Form,
132
}
133
134
use flow_core::api::segment;
135
136
/// Held while the pool changes: jobs run side by side, and a directory,
137
/// unlike an image, can be half moved or half removed while another job
138
/// copies it.
139
static CHANGING: std::sync::Mutex<()> = std::sync::Mutex::new(());
140
14144×
fn changing() -> std::sync::MutexGuard<'static, ()> {
14244×
    CHANGING.lock().unwrap_or_else(|p| p.into_inner())
14344×
}
144
145
impl Pool {
1466×
    pub fn new(home_root: &Path, gb: Option<u64>) -> Pool {
1476×
        Pool { root: home_root.join("cache"), limit: gb.unwrap_or(DEFAULT_GB).saturating_mul(1 << 30) }
1486×
    }
149
15045×
    fn cache_dir(&self, ctx: &Ctx, name: &str) -> PathBuf {
15145×
        self.root.join(segment(ctx.repo)).join(segment(ctx.job)).join(segment(name))
15245×
    }
153
154
    /// A mount's key: the image, job, cache name and its key files'
155
    /// content hashes (`hash_of` gives a file's, `None` when absent).
1569×
    pub fn key(ctx: &Ctx, m: &Mount, hash_of: impl Fn(&str) -> Option<String>) -> String {
1579×
        let mut h = blake3::Hasher::new();
15827×
        for part in [ctx.image, ctx.job, m.cache.as_deref().unwrap_or("")] {
15927×
            h.update(part.as_bytes());
16027×
            h.update(b"\n");
16127×
        }
16214×
        for k in &m.key {
16314×
            h.update(k.as_bytes());
16414×
            h.update(b"=");
16514×
            h.update(hash_of(k).unwrap_or_else(|| "absent".into()).as_bytes());
16614×
            h.update(b"\n");
167
        }
1689×
        h.finalize().to_hex()[..32].to_string()
1699×
    }
170
171
    /// Start `dest` from the pool for mount `m`: an image of `m.size_mib`,
172
    /// or a directory, as `ctx.form` says; `dest` must not exist yet. What
173
    /// it started from, or `None` (the caller makes an empty disk).
174
    ///
175
    /// A disk this job's bookmark kept is moved out of the pool, so a job
176
    /// of the same slot running beside this one can never take it too.
17727×
    pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<Taken> {
17827×
        let name = m.cache.as_deref()?;
17927×
        let _changing = changing();
18027×
        let dir = self.cache_dir(ctx, name);
18127×
        let size = m.size_mib as u64 * (1 << 20);
18227×
        let form = ctx.form;
18334×
        let fits = |p: &Path| match form {
18417×
            Form::Image => std::fs::metadata(p).is_ok_and(|md| md.len() == size),
18517×
            Form::Dir => std::fs::symlink_metadata(p).is_ok_and(|md| md.is_dir()),
18634×
        };
18727×
        let ext = form.ext();
18827×
        let mine = dir.join(segment(ctx.bookmark));
18927×
        let exact = mine.join(format!("{key}.{ext}"));
190
        // A moved disk takes its sources with it.
19127×
        if fits(&exact) {
19211×
            let sources = read_sources(&exact);
19311×
            if std::fs::rename(&exact, dest).is_ok() {
19411×
                let _ = std::fs::remove_file(sources_of(&exact));
19511×
                return Some(Taken { how: format!("cache {name}: this bookmark's, same key"), sources });
1960×
            }
19716×
        }
19816×
        if let Some(p) = newest(&mine, form, |p| fits(p)) {
1992×
            let sources = read_sources(&p);
2002×
            if std::fs::rename(&p, dest).is_ok() {
2012×
                let _ = std::fs::remove_file(sources_of(&p));
2022×
                return Some(Taken { how: format!("cache {name}: this bookmark's, an older key"), sources });
2030×
            }
20414×
        }
20514×
        let others: Vec<PathBuf> =
20614×
            std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir() && *p != mine).collect();
20714×
        let pick = others
20814×
            .iter()
20914×
            .map(|d| d.join(format!("{key}.{ext}")))
21014×
            .filter(|p| fits(p))
21114×
            .max_by_key(|p| modified(p))
21214×
            .map(|p| (p, "same key"))
21314×
            .or_else(|| {
21411×
                others.iter().filter_map(|d| newest(d, form, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key"))
21511×
            });
21614×
        if let Some((p, which)) = pick {
2173×
            let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
2183×
            if copy(form, &p, dest).is_ok() {
2193×
                return Some(Taken {
2203×
                    how: format!("cache {name}: copied from another bookmark's ({from}), {which}"),
2213×
                    sources: read_sources(&p),
2223×
                });
2230×
            }
22411×
        }
22511×
        None
22627×
    }
227
228
    /// Keep a passing job's disk for mount `m` under its key, then keep
229
    /// the pool under its limit.
23017×
    pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources) -> Result<()> {
23117×
        let Some(name) = m.cache.as_deref() else { return Ok(()) };
23217×
        let _changing = changing();
233
        // Whatever the job left, nothing in the pool is anyone else's to
234
        // write or runs as the runner (oxygenflow#70).
23517×
        if ctx.form == Form::Dir {
2369×
            seal(disk).with_context(|| format!("seal {}", disk.display()))?;
2378×
        }
23817×
        let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark));
23917×
        crate::portable::create_private_dir(&mine)?;
24017×
        crate::portable::set_mode(&self.root, 0o700)?;
24117×
        let dest = mine.join(format!("{key}.{}", ctx.form.ext()));
24217×
        if ctx.form == Form::Dir {
243
            // Another job of this slot kept one since: this one is newer.
2449×
            crate::workspace::remove_tree(&dest)?;
2458×
        }
24617×
        if std::fs::rename(disk, &dest).is_err() {
2470×
            copy(ctx.form, disk, &dest).with_context(|| format!("keep {}", disk.display()))?;
24817×
        }
249
        // Moved disks keep their old time: mark it used now.
25017×
        std::fs::File::open(&dest)?.set_modified(SystemTime::now())?;
25117×
        let sidecar = sources_of(&dest);
25217×
        let tmp = sidecar.with_extension("sources-part");
25317×
        let _ = std::fs::remove_file(&tmp);
254
        {
255
            use std::io::Write;
25617×
            crate::portable::private(std::fs::OpenOptions::new().write(true).create_new(true))
25717×
                .open(&tmp)?
25817×
                .write_all(&serde_json::to_vec(sources)?)?;
259
        }
26017×
        std::fs::rename(&tmp, sidecar)?;
261
        // One disk per key is enough; older keys of this bookmark go once
262
        // the pool is over its limit.
26317×
        self.evict();
26417×
        Ok(())
26517×
    }
266
267
    /// After the job: a passing job's disk is pruned and kept, unless it
268
    /// is still over its cap; a failing one's is thrown away, so nothing
269
    /// it wrote reaches a later job. For a kept or refused cache, what to
270
    /// say in the job's log.
271
    #[allow(clippy::too_many_arguments)]
27213×
    pub fn settle(
27313×
        &self,
27413×
        ctx: &Ctx,
27513×
        m: &Mount,
27613×
        key: &str,
27713×
        disk: &Path,
27813×
        sources: &Sources,
27913×
        passed: bool,
28013×
        prune: &Prune,
28113×
    ) -> Result<Option<String>> {
28213×
        if passed {
28311×
            let Some(name) = m.cache.as_deref() else { return Ok(None) };
28411×
            let pruned = pruned(ctx.form, m, disk, prune)?;
28511×
            let line = describe(name, &pruned);
28611×
            if pruned.fits() {
2878×
                self.keep(ctx, m, key, disk, sources)?;
288
            } else {
2893×
                discard(ctx.form, disk)?;
290
            }
29111×
            return Ok(Some(line));
2922×
        }
2932×
        discard(ctx.form, disk)?;
2942×
        Ok(None)
29513×
    }
296
297
    /// The repository and job slots holding at least one disk, as
298
    /// [`flow_core::api::cache_slot`] names them, for the claim
299
    /// (oxygenflow#51), each with when its newest disk was kept (Unix ms):
300
    /// a disk's time is set when it is kept, so the newest is the freshest
301
    /// build (oxygenflow#54). Images and directories alike, so a job goes
302
    /// where its cache is whichever tier keeps it.
3036×
    pub fn slot_times(&self) -> std::collections::BTreeMap<String, u64> {
3046×
        let mut out = std::collections::BTreeMap::new();
3056×
        for repo in std::fs::read_dir(&self.root).into_iter().flatten().flatten() {
3066×
            for job in std::fs::read_dir(repo.path()).into_iter().flatten().flatten() {
3076×
                let mut newest: Option<SystemTime> = None;
308
                // `<job>/<cache>/<bookmark>/<disk>`
3096×
                kept(&job.path(), 2, &mut |p, _| {
3103×
                    let t = modified(p);
3113×
                    newest = Some(newest.map_or(t, |n| n.max(t)));
3123×
                });
3136×
                if let Some(t) = newest {
3143×
                    let ms = t.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
3153×
                    out.insert(format!("{}/{}", repo.file_name().to_string_lossy(), job.file_name().to_string_lossy()), ms);
3163×
                }
317
            }
318
        }
3196×
        out
3206×
    }
321
322
    /// Drop the disks used longest ago until the pool's blocks fit: an
323
    /// image's blocks, or everything in a directory. The caller holds
324
    /// [`changing`].
32517×
    fn evict(&self) -> u64 {
32617×
        let mut disks: Vec<(SystemTime, u64, PathBuf)> = Vec::new();
327
        // `<repo>/<job>/<cache>/<bookmark>/<disk>`
32819×
        kept(&self.root, 4, &mut |p, form| {
32919×
            if let Ok(md) = std::fs::symlink_metadata(p) {
33019×
                let bytes = match form {
3318×
                    Form::Image => crate::portable::disk_bytes(&md),
33211×
                    Form::Dir => tree_bytes(p),
333
                };
33419×
                disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), bytes, p.to_path_buf()));
3350×
            }
33619×
        });
33717×
        let mut total: u64 = disks.iter().map(|d| d.1).sum();
33817×
        disks.sort();
33917×
        let mut freed = 0;
34018×
        for (_, bytes, p) in disks {
34118×
            if total <= self.limit {
34216×
                break;
3432×
            }
3442×
            let gone = match form_of(&p) {
3451×
                Some(Form::Dir) => crate::workspace::remove_tree(&p),
3461×
                _ => std::fs::remove_file(&p),
347
            };
3482×
            if gone.is_ok() {
3492×
                let _ = std::fs::remove_file(sources_of(&p));
3502×
                total -= bytes;
3512×
                freed += bytes;
3522×
            }
353
        }
35417×
        freed
35517×
    }
356
}
357
358
/// Throw a disk away.
3595×
fn discard(form: Form, disk: &Path) -> Result<()> {
3605×
    let gone = match form {
3612×
        Form::Dir => crate::workspace::remove_tree(disk),
3623×
        Form::Image => std::fs::remove_file(disk).or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }),
363
    };
3645×
    gone.with_context(|| format!("discard {}", disk.display()))
3655×
}
366
367
/// Before a syscall job: set the atime of every file of a directory it
368
/// took from the pool to the epoch, so that whatever the file system's
369
/// atime rule, the job's first read of each is seen when the directory is
370
/// pruned (oxygenflow#72). A microVM's guest mounts its disk
371
/// `strictatime` instead.
3721×
pub fn forget_reads(form: Form, disk: &Path) -> Result<()> {
3731×
    if form == Form::Dir && disk.is_dir() {
3741×
        flow_init::prune::forget_reads(disk).with_context(|| format!("set the atimes in {}", disk.display()))?;
3750×
    }
3761×
    Ok(())
3771×
}
378
379
/// Prune a passing job's disk, or say why it was not: a directory here,
380
/// an image by what its guest said (else only its size is measured).
38111×
fn pruned(form: Form, m: &Mount, disk: &Path, prune: &Prune) -> Result<Pruned> {
38211×
    let size = m.size_mib as u64 * (1 << 20);
38311×
    let percent = prune.percent;
38411×
    let unpruned = |before: u64, why: String| Pruned { size, percent, before, after: before, unpruned: Some(why), ..Pruned::default() };
38511×
    Ok(match form {
386
        Form::Dir => {
3877×
            let before = tree_bytes(disk);
3887×
            if !flow_init::prune::records_reads(disk) {
3890×
                unpruned(before, "this runner's file system records no reads (noatime), so what the job used cannot be told".into())
390
            } else {
3917×
                flow_init::prune::prune(disk, prune.started, &[], size, before, percent)
3927×
                    .unwrap_or_else(|e| unpruned(tree_bytes(disk), format!("pruning failed: {e}")))
393
            }
394
        }
3954×
        Form::Image => match prune.guest.filter(|g| g.size > 0) {
3962×
            Some(g) => g.clone(),
397
            None => {
3982×
                let (before, size) = image_usage(disk)?;
3992×
                let why = prune.guest.and_then(|g| g.unpruned.clone()).unwrap_or_else(|| "the guest did not prune it".into());
4002×
                Pruned { size, ..unpruned(before, why) }
401
            }
402
        },
403
    })
40411×
}
405
406
/// What an ext4 image holds and its size, as its superblock counts them.
4072×
fn image_usage(image: &Path) -> Result<(u64, u64)> {
4082×
    let out = std::process::Command::new("dumpe2fs").arg("-h").arg(image).output().context("run dumpe2fs (e2fsprogs)")?;
4092×
    if !out.status.success() {
4100×
        anyhow::bail!("dumpe2fs {}: {}", image.display(), String::from_utf8_lossy(&out.stderr).trim());
4112×
    }
4122×
    superblock_usage(&String::from_utf8_lossy(&out.stdout)).with_context(|| format!("dumpe2fs {}: no block counts", image.display()))
4132×
}
414
4154×
fn superblock_usage(header: &str) -> Option<(u64, u64)> {
41611×
    let field = |name: &str| -> Option<u64> {
417107×
        header.lines().find_map(|l| l.strip_prefix(name)?.strip_prefix(':')).and_then(|v| v.trim().parse().ok())
41811×
    };
4194×
    let (count, free, block) = (field("Block count")?, field("Free blocks")?, field("Block size")?);
4203×
    Some((count.saturating_sub(free) * block, count * block))
4214×
}
422
423
/// The job log's line for a passing job's cache: what was kept, or why
424
/// not (oxygenflow#72).
42511×
pub fn describe(name: &str, p: &Pruned) -> String {
426
    use crate::images::human;
42711×
    let cap = format!("{}% of its {} disk", p.percent, human(p.size));
42811×
    let pruned = format!("{} in {} file{} this run did not use", human(p.freed), p.files, if p.files == 1 { "" } else { "s" });
42911×
    if p.fits() && p.files > 0 {
4302×
        format!("cache {name}: kept {} of {} (pruned {pruned}; the cap is {cap})", human(p.after), human(p.before))
4319×
    } else if p.fits() {
4326×
        format!("cache {name}: kept {}, under its cap of {cap}", human(p.after))
433
    } else {
4343×
        let after = if p.files > 0 { format!("{} after pruning {pruned}", human(p.after)) } else { human(p.after) };
4353×
        let why = p.unpruned.as_ref().map(|u| format!(" ({u})")).unwrap_or_default();
4363×
        format!(
437
            "cache {name}: not kept: {after} is over its cap of {} ({cap}){why}; the next run starts empty, and a larger `size` on the mount gives the cache room",
4383×
            human(p.cap())
439
        )
440
    }
44111×
}
442
443
/// Where a job's `n`th disk mount is in its scratch directory, in the form
444
/// its tier mounts it: `microvm::run` attaches `mount-N.ext4`, and
445
/// `syscall::run` binds the directory `mount-N`.
4460×
pub fn disk_path(form: Form, scratch: &Path, n: usize) -> PathBuf {
4470×
    match form {
4480×
        Form::Image => scratch.join(format!("mount-{n}.ext4")),
4490×
        Form::Dir => crate::syscall::disk_dir(scratch, n),
450
    }
4510×
}
452
45313×
fn modified(p: &Path) -> SystemTime {
45413×
    std::fs::symlink_metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)
45513×
}
456
45717×
fn newest(dir: &Path, form: Form, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> {
45817×
    std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| form_of(p) == Some(form) && ok(p)).max_by_key(|p| modified(p))
45917×
}
460
461
/// Each kept image or directory `depth` levels of directories below `dir`,
462
/// with its form; never inside a kept directory.
463104×
fn kept(dir: &Path, depth: usize, f: &mut dyn FnMut(&Path, Form)) {
464125×
    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
465125×
        let p = e.path();
466125×
        let Ok(t) = e.file_type() else { continue };
467125×
        if depth == 0 {
46844×
            match form_of(&p) {
46910×
                Some(Form::Image) if t.is_file() => f(&p, Form::Image),
47012×
                Some(Form::Dir) if t.is_dir() => f(&p, Form::Dir),
47122×
                _ => {}
472
            }
47381×
        } else if t.is_dir() {
47481×
            kept(&p, depth - 1, f);
47581×
        }
476
    }
477104×
}
478
479
/// The blocks a directory's files take, each file once however many links
480
/// it has; links are not followed.
48119×
fn tree_bytes(dir: &Path) -> u64 {
48291×
    fn walk(p: &Path, seen: &mut std::collections::HashSet<(u64, u64)>) -> u64 {
48391×
        let Ok(md) = std::fs::symlink_metadata(p) else { return 0 };
48491×
        if crate::portable::shared_identity(&md).is_some_and(|id| !seen.insert(id)) {
4850×
            return 0;
48691×
        }
48791×
        let mut bytes = crate::portable::disk_bytes(&md);
48891×
        if md.is_dir() {
48972×
            for e in std::fs::read_dir(p).into_iter().flatten().flatten() {
49072×
                bytes += walk(&e.path(), seen);
49172×
            }
49250×
        }
49391×
        bytes
49491×
    }
49519×
    walk(dir, &mut Default::default())
49619×
}
497
498
/// Make a directory a job left fit for the pool: nothing in it writable by
499
/// group or others, nothing set-id or sticky, and everything readable by
500
/// the runner (directories searchable and writable too, so it can be
501
/// copied and removed). Links are not followed. The job ran as the
502
/// runner's user, so all of it is the runner's to change; times are kept.
50344×
fn seal(p: &Path) -> std::io::Result<()> {
50444×
    let md = std::fs::symlink_metadata(p)?;
50544×
    if md.file_type().is_symlink() {
5061×
        return Ok(());
50743×
    }
50843×
    let mode = crate::portable::mode(&md);
50943×
    let want = (mode & !0o7022) | if md.is_dir() { 0o700 } else { 0o400 };
51043×
    if want != mode {
5115×
        crate::portable::set_mode(p, want)?;
51238×
    }
51343×
    if md.is_dir() {
51435×
        for e in std::fs::read_dir(p)? {
51535×
            seal(&e?.path())?;
516
        }
51724×
    }
51843×
    Ok(())
51944×
}
520
5213×
fn copy(form: Form, from: &Path, to: &Path) -> Result<()> {
5223×
    match form {
5231×
        Form::Image => sparse_copy(from, to),
5242×
        Form::Dir => tree_copy(from, to),
525
    }
5263×
}
527
528
/// Copy a sparse disk without writing its holes.
5291×
fn sparse_copy(from: &Path, to: &Path) -> Result<()> {
5301×
    let tmp = to.with_extension("part");
5311×
    let st = std::process::Command::new("cp").arg("--sparse=always").arg(from).arg(&tmp).status().context("run cp")?;
5321×
    if !st.success() {
5330×
        let _ = std::fs::remove_file(&tmp);
5340×
        anyhow::bail!("cp {} failed", from.display());
5351×
    }
5361×
    std::fs::rename(&tmp, to)?;
5371×
    Ok(())
5381×
}
539
540
/// Copy a directory with its modes and times (a build tool judges by
541
/// them), sharing blocks where the filesystem can.
5422×
fn tree_copy(from: &Path, to: &Path) -> Result<()> {
5432×
    let tmp = to.with_extension("tree-part");
5442×
    crate::workspace::remove_tree(&tmp)?;
5452×
    let st = std::process::Command::new("cp")
5462×
        .args(["-a", "--reflink=auto", "--no-target-directory"])
5472×
        .arg(from)
5482×
        .arg(&tmp)
5492×
        .status()
5502×
        .context("run cp")?;
5512×
    if !st.success() {
5520×
        let _ = crate::workspace::remove_tree(&tmp);
5530×
        anyhow::bail!("cp {} failed", from.display());
5542×
    }
5552×
    std::fs::rename(&tmp, to).inspect_err(|_| {
5560×
        let _ = crate::workspace::remove_tree(&tmp);
5570×
    })?;
5582×
    Ok(())
5592×
}
560
561
#[cfg(all(test, unix))]
562
mod tests {
563
    use super::*;
564
    use flow_core::pipeline::MountSource;
565
    use std::os::unix::fs::{MetadataExt, PermissionsExt};
566
567
    /// Pruning that never prunes: the cap is the whole disk.
56811×
    fn keep_all() -> Prune<'static> {
56911×
        Prune { started: SystemTime::now(), percent: 100, guest: None }
57011×
    }
571
57210×
    fn mount(key: &[&str]) -> Mount {
573
        Mount {
57410×
            source: MountSource::Disk,
57510×
            target: "/src/target".into(),
576
            size_mib: 1,
57710×
            cache: Some("target".into()),
57810×
            key: key.iter().map(|s| s.to_string()).collect(),
579
        }
58010×
    }
581
5825×
    fn disk(path: &Path, fill: &[u8]) {
5835×
        let f = std::fs::File::create(path).unwrap();
5845×
        f.set_len(1 << 20).unwrap();
5855×
        std::os::unix::fs::FileExt::write_at(&f, fill, 0).unwrap();
5865×
    }
587
5882×
    fn first(path: &Path) -> Vec<u8> {
5892×
        std::fs::read(path).unwrap()[..4].to_vec()
5902×
    }
591
592
    #[test]
5931×
    fn a_kept_disk_starts_the_next_job_of_its_bookmark_then_others_copy_it() {
5941×
        let home = tempfile::tempdir().unwrap();
5951×
        let work = tempfile::tempdir().unwrap();
5961×
        let pool = Pool::new(home.path(), None);
5971×
        let m = mount(&["Cargo.lock"]);
5981×
        let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img", form: Form::Image };
5991×
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
600
        // Nothing yet: the caller makes an empty disk.
6011×
        assert!(pool.take(&main, &m, &key, &work.path().join("a.ext4")).is_none());
602
        // A passing job's disk is kept and moved into the next job's place.
6031×
        disk(&work.path().join("a.ext4"), b"MAIN");
6041×
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
6051×
        pool.keep(&main, &m, &key, &work.path().join("a.ext4"), &built).unwrap();
6061×
        assert!(!work.path().join("a.ext4").exists(), "moved, not copied");
6071×
        let taken = pool.take(&main, &m, &key, &work.path().join("b.ext4")).unwrap();
6081×
        assert!(taken.how.contains("same key"), "{}", taken.how);
6091×
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
6101×
        assert_eq!(first(&work.path().join("b.ext4")), b"MAIN");
6111×
        pool.keep(&main, &m, &key, &work.path().join("b.ext4"), &built).unwrap();
612
        // A changed key takes the bookmark's newest disk all the same.
6131×
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
6141×
        assert_ne!(key, key2);
6151×
        let taken = pool.take(&main, &m, &key2, &work.path().join("c.ext4")).unwrap();
6161×
        assert!(taken.how.contains("older key"), "{}", taken.how);
6171×
        assert_eq!(taken.sources.as_ref(), Some(&built));
6181×
        pool.keep(&main, &m, &key2, &work.path().join("c.ext4"), &built).unwrap();
6191×
        assert_eq!(
6201×
            pool.slot_times().into_keys().collect::<Vec<_>>(),
6211×
            vec![flow_core::api::cache_slot("andy/hub", "test")],
622
            "what the claim reports (#51)"
623
        );
624
        // With when it was last kept (#54): just now.
6251×
        let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64;
6261×
        let kept = pool.slot_times()[&flow_core::api::cache_slot("andy/hub", "test")];
6271×
        assert!(now.abs_diff(kept) < 60_000, "kept {kept}, now {now}");
628
        // Another bookmark copies it, leaving main's in place.
6291×
        let feature = Ctx { bookmark: "feature", ..main };
6301×
        let taken = pool.take(&feature, &m, &key2, &work.path().join("d.ext4")).unwrap();
6311×
        assert!(taken.how.contains("copied from another bookmark"), "{}", taken.how);
6321×
        assert_eq!(taken.sources.as_ref(), Some(&built), "a copy reads them and leaves them");
6331×
        assert_eq!(first(&work.path().join("d.ext4")), b"MAIN");
6341×
        let taken = pool.take(&main, &m, &key2, &work.path().join("e.ext4")).unwrap();
6351×
        assert!(taken.how.contains("this bookmark's") && taken.sources.is_some(), "main's is still there, with its sources");
636
        // Another job or a different size never shares it.
6371×
        let other_job = Ctx { job: "build", ..main };
6381×
        assert!(pool.take(&other_job, &m, &key2, &work.path().join("f.ext4")).is_none());
6391×
        let mut bigger = mount(&["Cargo.lock"]);
6401×
        bigger.size_mib = 2;
6411×
        assert!(pool.take(&feature, &bigger, &key2, &work.path().join("g.ext4")).is_none());
6421×
    }
643
644
    #[test]
6451×
    fn the_key_follows_the_image_the_job_and_the_key_files() {
6461×
        let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1", form: Form::Image };
6471×
        let m = mount(&["Cargo.lock", "rust-toolchain.toml"]);
6482×
        let k = Pool::key(&ctx, &m, |p| Some(format!("{p}-v1")));
6492×
        assert_eq!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v1"))));
6502×
        assert_ne!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v2"))), "a key file changed");
6512×
        assert_ne!(k, Pool::key(&Ctx { image: "img-2", ..ctx }, &m, |p| Some(format!("{p}-v1"))), "another image");
6521×
        assert_ne!(k, Pool::key(&ctx, &m, |_| None), "a key file missing");
6531×
    }
654
655
    #[test]
6561×
    fn the_pool_drops_the_disks_used_longest_ago() {
6571×
        let home = tempfile::tempdir().unwrap();
6581×
        let work = tempfile::tempdir().unwrap();
6591×
        let pool = Pool { root: home.path().join("cache"), limit: 0 };
6601×
        let m = mount(&[]);
6611×
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
6622×
        let full = |p: &Path| {
6632×
            let f = std::fs::File::create(p).unwrap();
6642×
            std::os::unix::fs::FileExt::write_at(&f, &vec![7u8; 1 << 20], 0).unwrap();
6652×
        };
6661×
        full(&work.path().join("a.ext4"));
667
        // Over a limit of nothing: kept, then dropped at once.
6681×
        pool.keep(&ctx, &m, "k1", &work.path().join("a.ext4"), &Sources::new()).unwrap();
6691×
        assert!(pool.take(&ctx, &m, "k1", &work.path().join("b.ext4")).is_none());
6701×
        let roomy = Pool { root: home.path().join("cache"), limit: 1 << 30 };
6711×
        full(&work.path().join("c.ext4"));
6721×
        roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4"), &Sources::new()).unwrap();
6731×
        assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays");
6741×
    }
675
676
    /// A directory as the syscall tier leaves one: `marker` in
677
    /// `debug/marker`, and a file of `kib` KiB.
6785×
    fn tree(path: &Path, marker: &[u8], kib: usize) {
6795×
        std::fs::create_dir_all(path.join("debug")).unwrap();
6805×
        std::fs::write(path.join("debug/marker"), marker).unwrap();
681
        // Not all one byte, so a compressing filesystem stores it whole.
6821054720×
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
6835×
        std::fs::write(path.join("debug/blob"), bytes).unwrap();
6845×
    }
685
6867×
    fn dir_ctx<'a>(bookmark: &'a str) -> Ctx<'a> {
6877×
        Ctx { repo: "andy/flow", bookmark, job: "test", image: "img", form: Form::Dir }
6887×
    }
689
690
    /// oxygenflow#70: the syscall tier's directories are taken and kept as
691
    /// a microVM's images are, and never mixed with them.
692
    #[test]
6931×
    fn a_kept_directory_starts_the_next_syscall_job_and_others_copy_it() {
6941×
        let home = tempfile::tempdir().unwrap();
6951×
        let work = tempfile::tempdir().unwrap();
6961×
        let pool = Pool::new(home.path(), None);
6971×
        let m = mount(&["Cargo.lock"]);
6981×
        let main = dir_ctx("main");
6991×
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
70029×
        let w = |n: &str| work.path().join(n);
7011×
        assert!(pool.take(&main, &m, &key, &w("a")).is_none(), "nothing kept yet");
702
        // A passing job's directory goes into the pool, times and all.
7031×
        tree(&w("a"), b"FIRST", 4);
704
        // Named like an image: the pool never looks inside a directory.
7051×
        std::fs::write(w("a/debug/inner.ext4"), b"not a disk").unwrap();
7061×
        let old = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
7071×
        std::fs::File::open(w("a/debug/marker")).unwrap().set_modified(old).unwrap();
7081×
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
7091×
        pool.settle(&main, &m, &key, &w("a"), &built, true, &keep_all()).unwrap();
7101×
        assert!(!w("a").exists(), "moved, not copied");
7111×
        assert_eq!(
7121×
            pool.slot_times().into_keys().collect::<Vec<_>>(),
7131×
            vec![flow_core::api::cache_slot("andy/flow", "test")],
714
            "the claim reports it like an image (#51)"
715
        );
716
        // The next job of its bookmark moves it out: a job of the same
717
        // slot beside it finds nothing to take.
7181×
        let taken = pool.take(&main, &m, &key, &w("b")).unwrap();
7191×
        assert!(taken.how.contains("this bookmark's, same key"), "{}", taken.how);
7201×
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
7211×
        assert_eq!(std::fs::read(w("b/debug/marker")).unwrap(), b"FIRST");
7221×
        assert_eq!(modified(&w("b/debug/marker")), old, "its files keep their times");
7231×
        assert_eq!(std::fs::read(w("b/debug/inner.ext4")).unwrap(), b"not a disk");
7241×
        assert!(pool.take(&main, &m, &key, &w("c")).is_none(), "taken once");
7251×
        assert!(pool.slot_times().is_empty());
726
        // Kept again, and under a changed key the newest is taken all the same.
7271×
        std::fs::write(w("b/debug/marker"), b"SECOND").unwrap();
7281×
        pool.settle(&main, &m, &key, &w("b"), &built, true, &keep_all()).unwrap();
7291×
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
7301×
        let taken = pool.take(&main, &m, &key2, &w("d")).unwrap();
7311×
        assert!(taken.how.contains("older key"), "{}", taken.how);
7321×
        assert_eq!(std::fs::read(w("d/debug/marker")).unwrap(), b"SECOND");
7331×
        pool.settle(&main, &m, &key2, &w("d"), &built, true, &keep_all()).unwrap();
734
        // Another bookmark copies it, with its times, and main keeps its own.
7351×
        let feature = dir_ctx("feature");
7361×
        let taken = pool.take(&feature, &m, &key2, &w("e")).unwrap();
7371×
        assert!(taken.how.contains("copied from another bookmark's (main-"), "{}", taken.how);
7381×
        assert_eq!(taken.sources.as_ref(), Some(&built));
7391×
        assert_eq!(std::fs::read(w("e/debug/marker")).unwrap(), b"SECOND");
7401×
        assert!(pool.take(&main, &m, &key2, &w("f")).is_some(), "main's is still there");
7411×
        assert_eq!(modified(&w("e/debug/marker")), modified(&w("f/debug/marker")), "a copy keeps the times");
7421×
        pool.settle(&main, &m, &key2, &w("f"), &built, true, &keep_all()).unwrap();
743
        // A microVM job of the same slot never takes a directory, nor a
744
        // syscall job an image.
7451×
        let image = Ctx { form: Form::Image, ..main };
7461×
        assert!(pool.take(&image, &m, &key2, &w("g.ext4")).is_none());
7471×
        disk(&w("h.ext4"), b"DISK");
7481×
        pool.keep(&image, &m, &key2, &w("h.ext4"), &built).unwrap();
7491×
        let taken = pool.take(&main, &m, &key2, &w("i")).unwrap();
7501×
        assert!(w("i/debug/marker").is_file(), "{}", taken.how);
7511×
        assert!(pool.take(&main, &m, &key2, &w("j")).is_none(), "the image is not a directory");
7521×
        assert!(pool.take(&image, &m, &key2, &w("k.ext4")).is_some(), "and is still there for a microVM");
7531×
    }
754
755
    #[test]
7561×
    fn a_failing_jobs_directory_is_thrown_away() {
7571×
        let home = tempfile::tempdir().unwrap();
7581×
        let work = tempfile::tempdir().unwrap();
7591×
        let pool = Pool::new(home.path(), None);
7601×
        let m = mount(&[]);
7611×
        let main = dir_ctx("main");
7621×
        tree(&work.path().join("a"), b"GOOD", 1);
7631×
        pool.settle(&main, &m, "k", &work.path().join("a"), &Sources::new(), true, &keep_all()).unwrap();
7641×
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
7651×
        assert!(taken.is_some());
766
        // The job that took it fails after writing to it: not kept, gone.
7671×
        std::fs::write(work.path().join("b/debug/marker"), b"POISON").unwrap();
7681×
        pool.settle(&main, &m, "k", &work.path().join("b"), &Sources::new(), false, &keep_all()).unwrap();
7691×
        assert!(!work.path().join("b").exists());
7701×
        assert!(pool.take(&main, &m, "k", &work.path().join("c")).is_none(), "nothing it wrote reaches a later job");
7711×
        assert!(pool.slot_times().is_empty());
772
        // A failed image is thrown away the same way.
7731×
        let image = Ctx { form: Form::Image, ..main };
7741×
        disk(&work.path().join("d.ext4"), b"BAD!");
7751×
        pool.settle(&image, &m, "k", &work.path().join("d.ext4"), &Sources::new(), false, &keep_all()).unwrap();
7761×
        assert!(!work.path().join("d.ext4").exists() && pool.slot_times().is_empty());
7771×
    }
778
779
    #[test]
7801×
    fn directories_are_evicted_by_their_size_oldest_first() {
7811×
        let home = tempfile::tempdir().unwrap();
7821×
        let work = tempfile::tempdir().unwrap();
783
        // Room for one 512 KiB directory, not two.
7841×
        let pool = Pool { root: home.path().join("cache"), limit: 800 << 10 };
7851×
        let m = mount(&[]);
7861×
        let (main, feature) = (dir_ctx("main"), dir_ctx("feature"));
7871×
        tree(&work.path().join("a"), b"OLD", 512);
7881×
        pool.keep(&main, &m, "k1", &work.path().join("a"), &Sources::new()).unwrap();
7891×
        let kept = pool.cache_dir(&main, "target").join(segment("main")).join("k1.tree");
7901×
        assert!(kept.is_dir(), "under the limit it stays");
7911×
        assert!(tree_bytes(&kept) >= 512 << 10, "a directory counts what is in it");
792
        // Older than the next one, however fast this test runs.
7931×
        std::fs::File::open(&kept).unwrap().set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1)).unwrap();
7941×
        tree(&work.path().join("b"), b"NEW", 512);
7951×
        pool.keep(&feature, &m, "k1", &work.path().join("b"), &Sources::new()).unwrap();
7961×
        assert!(!kept.exists() && !kept.with_extension("tree-sources").exists(), "the one used longest ago goes, sources and all");
7971×
        let taken = pool.take(&main, &m, "k1", &work.path().join("c")).unwrap();
7981×
        assert!(taken.how.contains("bookmark's (feature-"), "{}", taken.how);
7991×
        assert_eq!(std::fs::read(work.path().join("c/debug/marker")).unwrap(), b"NEW");
8001×
    }
801
802
    /// oxygenflow#70: the job wrote what it liked; the pool keeps nothing
803
    /// others may write or that runs as the runner, and is the runner's.
804
    #[test]
8051×
    fn nothing_kept_is_writable_by_others_or_set_id() {
8061×
        let home = tempfile::tempdir().unwrap();
8071×
        let work = tempfile::tempdir().unwrap();
8081×
        let pool = Pool::new(home.path(), None);
8091×
        let m = mount(&[]);
8101×
        let main = dir_ctx("main");
8111×
        let a = work.path().join("a");
8121×
        tree(&a, b"X", 1);
8135×
        let mode = |p: &Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap();
8141×
        std::fs::write(a.join("open"), b"").unwrap();
8151×
        mode(&a.join("open"), 0o777);
8161×
        std::fs::write(a.join("suid"), b"").unwrap();
8171×
        mode(&a.join("suid"), 0o4755);
8181×
        std::fs::write(a.join("debug/sealed"), b"secret").unwrap();
8191×
        mode(&a.join("debug/sealed"), 0o000);
8201×
        std::os::unix::fs::symlink("/etc/passwd", a.join("link")).unwrap();
8211×
        let when = modified(&a.join("debug/marker"));
8221×
        mode(&a.join("debug"), 0o1777);
8231×
        mode(&a, 0o500);
8241×
        pool.keep(&main, &m, "k", &a, &Sources::new()).unwrap();
8251×
        let root = home.path().join("cache");
8261×
        assert_eq!(std::fs::metadata(&root).unwrap().mode() & 0o777, 0o700, "the pool is the runner's alone");
8271×
        let mut seen = 0;
82814×
        fn each(p: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) {
82914×
            let md = std::fs::symlink_metadata(p).unwrap();
83014×
            f(p, &md);
83114×
            if md.is_dir() {
83213×
                for e in std::fs::read_dir(p).unwrap() {
83313×
                    each(&e.unwrap().path(), f);
83413×
                }
8357×
            }
83614×
        }
83714×
        each(&root, &mut |p, md| {
83814×
            seen += 1;
83914×
            if !md.file_type().is_symlink() {
84013×
                assert_eq!(md.mode() & 0o7022, 0, "{} is {:o}", p.display(), md.mode());
8411×
            }
84214×
        });
8431×
        assert!(seen >= 10, "{seen}");
844
        // Still readable, with its times, so the next job builds on it.
8451×
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
8461×
        assert!(taken.is_some());
8471×
        let b = work.path().join("b");
8481×
        assert_eq!(std::fs::read(b.join("debug/sealed")).unwrap(), b"secret");
8491×
        assert_eq!(std::fs::metadata(b.join("open")).unwrap().mode() & 0o777, 0o755);
8501×
        assert_eq!(std::fs::read_link(b.join("link")).unwrap(), Path::new("/etc/passwd"), "a link is kept, not followed");
8511×
        assert_eq!(modified(&b.join("debug/marker")), when);
8521×
    }
853
    /// A file of `kib` KiB at `rel` under `root`, last read and written
854
    /// at `secs` (ahead of the clock, so the time it was changed, now,
855
    /// is before a job starting at [`T`]).
8563×
    fn aged(root: &Path, rel: &str, kib: usize, secs: u64) -> PathBuf {
8573×
        let p = root.join(rel);
8583×
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
8598388608×
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
8603×
        std::fs::write(&p, bytes).unwrap();
8613×
        let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
8623×
        std::fs::File::options()
8633×
            .write(true)
8643×
            .open(&p)
8653×
            .unwrap()
8663×
            .set_times(std::fs::FileTimes::new().set_accessed(t).set_modified(t))
8673×
            .unwrap();
8683×
        p
8693×
    }
870
871
    const T: u64 = 4_000_000_000;
872
8731×
    fn at_t() -> SystemTime {
8741×
        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(T)
8751×
    }
876
877
    /// oxygenflow#72: a syscall job's directory is pruned of what the job
878
    /// did not use before it is kept, and the log says so; one still over
879
    /// its cap is not kept at all.
880
    #[test]
8811×
    fn a_directory_is_pruned_of_what_the_job_did_not_use_before_it_is_kept() {
8821×
        let home = tempfile::tempdir().unwrap();
8831×
        let work = tempfile::tempdir().unwrap();
8841×
        let pool = Pool::new(home.path(), None);
8851×
        let main = dir_ctx("main");
8861×
        let mut m = mount(&[]);
8871×
        m.size_mib = 8;
8881×
        let a = work.path().join("a");
889
        // This run's build, and an older lockfile's that it did not touch.
8901×
        aged(&a, "debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib", 2 << 10, T + 10);
8911×
        aged(&a, "debug/deps/libold-bbbbbbbbbbbbbbbb.rlib", 3 << 10, T - 1_000);
8921×
        let prune = Prune { started: at_t(), percent: 50, guest: None };
8931×
        let line = pool.settle(&main, &m, "k", &a, &Sources::new(), true, &prune).unwrap().unwrap();
8941×
        assert_eq!(
895
            line,
896
            "cache target: kept 2 MiB of 5 MiB (pruned 3 MiB in 1 file this run did not use; the cap is 50% of its 8 MiB disk)"
897
        );
8981×
        let b = work.path().join("b");
8991×
        assert!(pool.take(&main, &m, "k", &b).unwrap().how.contains("same key"));
9001×
        assert!(b.join("debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib").is_file() && !b.join("debug/deps/libold-bbbbbbbbbbbbbbbb.rlib").exists());
901
        // Before the next job its reads are forgotten, its writes not.
9021×
        forget_reads(Form::Dir, &b).unwrap();
9031×
        let md = std::fs::metadata(b.join("debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib")).unwrap();
9041×
        assert_eq!((md.atime(), md.mtime()), (0, (T + 10) as i64));
905
        // This run's own files over the cap: nothing it used goes, and the
906
        // cache is not kept, so the next run starts empty.
9071×
        aged(&b, "debug/deps/libbig-cccccccccccccccc.rlib", 3 << 10, T + 20);
9081×
        let line = pool.settle(&main, &m, "k", &b, &Sources::new(), true, &prune).unwrap().unwrap();
9091×
        assert!(
9101×
            line.starts_with("cache target: not kept: 5 MiB is over its cap of 4 MiB (50% of its 8 MiB disk); the next run starts empty"),
911
            "{line}"
912
        );
9131×
        assert!(!b.exists() && pool.take(&main, &m, "k", &work.path().join("c")).is_none());
9141×
    }
915
916
    /// oxygenflow#72: a microVM's image is kept or not by what its guest
917
    /// said; without word from it, by what its superblock says is in use.
918
    #[test]
9191×
    fn an_image_is_kept_by_what_its_guest_pruned_or_else_by_its_size() {
9201×
        let home = tempfile::tempdir().unwrap();
9211×
        let work = tempfile::tempdir().unwrap();
9221×
        let pool = Pool::new(home.path(), None);
9231×
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
9241×
        let mut m = mount(&[]);
9251×
        m.size_mib = 16;
9261×
        let fits = Pruned { size: 16 << 20, percent: 60, before: 12 << 20, after: 6 << 20, freed: 6 << 20, files: 40, unpruned: None };
9271×
        let over = Pruned { after: 11 << 20, ..fits.clone() };
9281×
        disk(&work.path().join("a.ext4"), b"KEEP");
9291×
        let line = pool
9301×
            .settle(&ctx, &m, "k", &work.path().join("a.ext4"), &Sources::new(), true, &Prune { guest: Some(&fits), ..keep_all() })
9311×
            .unwrap()
9321×
            .unwrap();
9331×
        assert_eq!(
934
            line,
935
            "cache target: kept 6 MiB of 12 MiB (pruned 6 MiB in 40 files this run did not use; the cap is 60% of its 16 MiB disk)"
936
        );
9371×
        assert!(pool.take(&ctx, &m, "k", &work.path().join("b.ext4")).is_none(), "a 1 MiB disk is not the mount's 16 MiB");
9381×
        disk(&work.path().join("c.ext4"), b"OVER");
9391×
        let line = pool
9401×
            .settle(&ctx, &m, "k", &work.path().join("c.ext4"), &Sources::new(), true, &Prune { guest: Some(&over), ..keep_all() })
9411×
            .unwrap()
9421×
            .unwrap();
9431×
        assert!(line.starts_with("cache target: not kept: 11 MiB after pruning 6 MiB in 40 files"), "{line}");
9441×
        assert!(!work.path().join("c.ext4").exists());
945
        // No word from the guest: an empty file system is well under.
9461×
        let img = work.path().join("d.ext4");
9471×
        crate::workspace::empty_ext4(&img, 16, "scratch").unwrap();
9481×
        let line = pool.settle(&ctx, &m, "k", &img, &Sources::new(), true, &Prune { percent: 60, ..keep_all() }).unwrap().unwrap();
9491×
        assert!(line.starts_with("cache target: kept ") && line.contains("under its cap of 60% of its 16 MiB disk"), "{line}");
9501×
        assert!(pool.take(&ctx, &m, "k", &work.path().join("e.ext4")).is_some());
951
        // ... and one nearly all in use is not kept, saying why.
9521×
        let line = pool
9531×
            .settle(&ctx, &m, "k", &work.path().join("e.ext4"), &Sources::new(), true, &Prune { percent: 1, ..keep_all() })
9541×
            .unwrap()
9551×
            .unwrap();
9561×
        assert!(line.contains("not kept") && line.contains("(the guest did not prune it)"), "{line}");
9571×
    }
958
959
    #[test]
9601×
    fn the_superblock_says_what_is_in_use() {
9611×
        let h = "Filesystem volume name:   scratch\nBlock count:              4096\nFree blocks:              1024\nBlock size:               4096\n";
9621×
        assert_eq!(superblock_usage(h), Some((3072 * 4096, 4096 * 4096)));
9631×
        assert_eq!(superblock_usage("Block count: 1\n"), None);
9641×
    }
965
}