OXYGENHUB
# 06a4c472b385 oxygenflow / crates / flow / src / cache.rs
44.9 KiB · Rust · last changed Prune build caches before they are kept 16 days ago source blame history raw
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::os::unix::fs::{MetadataExt, PermissionsExt};
51
use std::path::{Path, PathBuf};
52
use std::time::SystemTime;
53
54
/// The pool's default size limit.
55
pub const DEFAULT_GB: u64 = 64;
56
57
/// The sources a cached disk was built from: path to content hash and
58
/// mtime, in whole seconds (what the workspace image keeps).
59
pub type Sources = std::collections::BTreeMap<String, (String, i64)>;
60
61
/// What a job started a cached disk from.
62
pub struct Taken {
63
    /// For the log and the receipt.
64
    pub how: String,
65
    /// The sources it was built from, when they were kept with it.
66
    pub sources: Option<Sources>,
67
}
68
69
/// How a tier holds a disk mount's files (oxygenflow#70).
70
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71
pub enum Form {
72
    /// An ext4 image of the mount's size, attached to a microVM.
73
    Image,
74
    /// A host directory, bound into the syscall sandbox.
75
    Dir,
76
}
77
78
impl Form {
79
    fn ext(self) -> &'static str {
80
        match self {
81
            Form::Image => "ext4",
82
            Form::Dir => "tree",
83
        }
84
    }
85
}
86
87
/// Which form a kept entry of the pool is in, by its name.
88
fn form_of(p: &Path) -> Option<Form> {
89
    match p.extension()?.to_str()? {
90
        "ext4" => Some(Form::Image),
91
        "tree" => Some(Form::Dir),
92
        _ => None,
93
    }
94
}
95
96
fn sources_of(disk: &Path) -> PathBuf {
97
    match form_of(disk) {
98
        Some(Form::Dir) => disk.with_extension("tree-sources"),
99
        _ => disk.with_extension("sources"),
100
    }
101
}
102
103
fn read_sources(disk: &Path) -> Option<Sources> {
104
    serde_json::from_slice(&std::fs::read(sources_of(disk)).ok()?).ok()
105
}
106
107
/// How a passing job's cache disk is pruned before it is kept
108
/// (oxygenflow#72).
109
pub struct Prune<'a> {
110
    /// When the job started ([`flow_init::prune::start_clock`]): what it
111
    /// read or wrote since is what it used.
112
    pub started: SystemTime,
113
    /// The share of the disk the kept cache may fill.
114
    pub percent: u8,
115
    /// What the guest did to a microVM's image, when it said.
116
    pub guest: Option<&'a Pruned>,
117
}
118
119
/// Where a job's cached disks come from and go back to.
120
pub struct Pool {
121
    root: PathBuf,
122
    limit: u64,
123
}
124
125
/// Which job: the pool's coordinates other than the key, and the form its
126
/// tier keeps a disk in.
127
pub struct Ctx<'a> {
128
    pub repo: &'a str,
129
    pub bookmark: &'a str,
130
    pub job: &'a str,
131
    pub image: &'a str,
132
    pub form: Form,
133
}
134
135
use flow_core::api::segment;
136
137
/// Held while the pool changes: jobs run side by side, and a directory,
138
/// unlike an image, can be half moved or half removed while another job
139
/// copies it.
140
static CHANGING: std::sync::Mutex<()> = std::sync::Mutex::new(());
141
142
fn changing() -> std::sync::MutexGuard<'static, ()> {
143
    CHANGING.lock().unwrap_or_else(|p| p.into_inner())
144
}
145
146
impl Pool {
147
    pub fn new(home_root: &Path, gb: Option<u64>) -> Pool {
148
        Pool { root: home_root.join("cache"), limit: gb.unwrap_or(DEFAULT_GB).saturating_mul(1 << 30) }
149
    }
150
151
    fn cache_dir(&self, ctx: &Ctx, name: &str) -> PathBuf {
152
        self.root.join(segment(ctx.repo)).join(segment(ctx.job)).join(segment(name))
153
    }
154
155
    /// A mount's key: the image, job, cache name and its key files'
156
    /// content hashes (`hash_of` gives a file's, `None` when absent).
157
    pub fn key(ctx: &Ctx, m: &Mount, hash_of: impl Fn(&str) -> Option<String>) -> String {
158
        let mut h = blake3::Hasher::new();
159
        for part in [ctx.image, ctx.job, m.cache.as_deref().unwrap_or("")] {
160
            h.update(part.as_bytes());
161
            h.update(b"\n");
162
        }
163
        for k in &m.key {
164
            h.update(k.as_bytes());
165
            h.update(b"=");
166
            h.update(hash_of(k).unwrap_or_else(|| "absent".into()).as_bytes());
167
            h.update(b"\n");
168
        }
169
        h.finalize().to_hex()[..32].to_string()
170
    }
171
172
    /// Start `dest` from the pool for mount `m`: an image of `m.size_mib`,
173
    /// or a directory, as `ctx.form` says; `dest` must not exist yet. What
174
    /// it started from, or `None` (the caller makes an empty disk).
175
    ///
176
    /// A disk this job's bookmark kept is moved out of the pool, so a job
177
    /// of the same slot running beside this one can never take it too.
178
    pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<Taken> {
179
        let name = m.cache.as_deref()?;
180
        let _changing = changing();
181
        let dir = self.cache_dir(ctx, name);
182
        let size = m.size_mib as u64 * (1 << 20);
183
        let form = ctx.form;
184
        let fits = |p: &Path| match form {
185
            Form::Image => std::fs::metadata(p).is_ok_and(|md| md.len() == size),
186
            Form::Dir => std::fs::symlink_metadata(p).is_ok_and(|md| md.is_dir()),
187
        };
188
        let ext = form.ext();
189
        let mine = dir.join(segment(ctx.bookmark));
190
        let exact = mine.join(format!("{key}.{ext}"));
191
        // A moved disk takes its sources with it.
192
        if fits(&exact) {
193
            let sources = read_sources(&exact);
194
            if std::fs::rename(&exact, dest).is_ok() {
195
                let _ = std::fs::remove_file(sources_of(&exact));
196
                return Some(Taken { how: format!("cache {name}: this bookmark's, same key"), sources });
197
            }
198
        }
199
        if let Some(p) = newest(&mine, form, |p| fits(p)) {
200
            let sources = read_sources(&p);
201
            if std::fs::rename(&p, dest).is_ok() {
202
                let _ = std::fs::remove_file(sources_of(&p));
203
                return Some(Taken { how: format!("cache {name}: this bookmark's, an older key"), sources });
204
            }
205
        }
206
        let others: Vec<PathBuf> =
207
            std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir() && *p != mine).collect();
208
        let pick = others
209
            .iter()
210
            .map(|d| d.join(format!("{key}.{ext}")))
211
            .filter(|p| fits(p))
212
            .max_by_key(|p| modified(p))
213
            .map(|p| (p, "same key"))
214
            .or_else(|| {
215
                others.iter().filter_map(|d| newest(d, form, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key"))
216
            });
217
        if let Some((p, which)) = pick {
218
            let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
219
            if copy(form, &p, dest).is_ok() {
220
                return Some(Taken {
221
                    how: format!("cache {name}: copied from another bookmark's ({from}), {which}"),
222
                    sources: read_sources(&p),
223
                });
224
            }
225
        }
226
        None
227
    }
228
229
    /// Keep a passing job's disk for mount `m` under its key, then keep
230
    /// the pool under its limit.
231
    pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources) -> Result<()> {
232
        let Some(name) = m.cache.as_deref() else { return Ok(()) };
233
        let _changing = changing();
234
        // Whatever the job left, nothing in the pool is anyone else's to
235
        // write or runs as the runner (oxygenflow#70).
236
        if ctx.form == Form::Dir {
237
            seal(disk).with_context(|| format!("seal {}", disk.display()))?;
238
        }
239
        let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark));
240
        std::os::unix::fs::DirBuilderExt::mode(std::fs::DirBuilder::new().recursive(true), 0o700).create(&mine)?;
241
        std::fs::set_permissions(&self.root, std::fs::Permissions::from_mode(0o700))?;
242
        let dest = mine.join(format!("{key}.{}", ctx.form.ext()));
243
        if ctx.form == Form::Dir {
244
            // Another job of this slot kept one since: this one is newer.
245
            crate::workspace::remove_tree(&dest)?;
246
        }
247
        if std::fs::rename(disk, &dest).is_err() {
248
            copy(ctx.form, disk, &dest).with_context(|| format!("keep {}", disk.display()))?;
249
        }
250
        // Moved disks keep their old time: mark it used now.
251
        std::fs::File::open(&dest)?.set_modified(SystemTime::now())?;
252
        let sidecar = sources_of(&dest);
253
        let tmp = sidecar.with_extension("sources-part");
254
        let _ = std::fs::remove_file(&tmp);
255
        {
256
            use std::io::Write;
257
            use std::os::unix::fs::OpenOptionsExt;
258
            std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&tmp)?.write_all(&serde_json::to_vec(sources)?)?;
259
        }
260
        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.
263
        self.evict();
264
        Ok(())
265
    }
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)]
272
    pub fn settle(
273
        &self,
274
        ctx: &Ctx,
275
        m: &Mount,
276
        key: &str,
277
        disk: &Path,
278
        sources: &Sources,
279
        passed: bool,
280
        prune: &Prune,
281
    ) -> Result<Option<String>> {
282
        if passed {
283
            let Some(name) = m.cache.as_deref() else { return Ok(None) };
284
            let pruned = pruned(ctx.form, m, disk, prune)?;
285
            let line = describe(name, &pruned);
286
            if pruned.fits() {
287
                self.keep(ctx, m, key, disk, sources)?;
288
            } else {
289
                discard(ctx.form, disk)?;
290
            }
291
            return Ok(Some(line));
292
        }
293
        discard(ctx.form, disk)?;
294
        Ok(None)
295
    }
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.
303
    pub fn slot_times(&self) -> std::collections::BTreeMap<String, u64> {
304
        let mut out = std::collections::BTreeMap::new();
305
        for repo in std::fs::read_dir(&self.root).into_iter().flatten().flatten() {
306
            for job in std::fs::read_dir(repo.path()).into_iter().flatten().flatten() {
307
                let mut newest: Option<SystemTime> = None;
308
                // `<job>/<cache>/<bookmark>/<disk>`
309
                kept(&job.path(), 2, &mut |p, _| {
310
                    let t = modified(p);
311
                    newest = Some(newest.map_or(t, |n| n.max(t)));
312
                });
313
                if let Some(t) = newest {
314
                    let ms = t.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
315
                    out.insert(format!("{}/{}", repo.file_name().to_string_lossy(), job.file_name().to_string_lossy()), ms);
316
                }
317
            }
318
        }
319
        out
320
    }
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`].
325
    fn evict(&self) -> u64 {
326
        let mut disks: Vec<(SystemTime, u64, PathBuf)> = Vec::new();
327
        // `<repo>/<job>/<cache>/<bookmark>/<disk>`
328
        kept(&self.root, 4, &mut |p, form| {
329
            if let Ok(md) = std::fs::symlink_metadata(p) {
330
                let bytes = match form {
331
                    Form::Image => md.blocks() * 512,
332
                    Form::Dir => tree_bytes(p),
333
                };
334
                disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), bytes, p.to_path_buf()));
335
            }
336
        });
337
        let mut total: u64 = disks.iter().map(|d| d.1).sum();
338
        disks.sort();
339
        let mut freed = 0;
340
        for (_, bytes, p) in disks {
341
            if total <= self.limit {
342
                break;
343
            }
344
            let gone = match form_of(&p) {
345
                Some(Form::Dir) => crate::workspace::remove_tree(&p),
346
                _ => std::fs::remove_file(&p),
347
            };
348
            if gone.is_ok() {
349
                let _ = std::fs::remove_file(sources_of(&p));
350
                total -= bytes;
351
                freed += bytes;
352
            }
353
        }
354
        freed
355
    }
356
}
357
358
/// Throw a disk away.
359
fn discard(form: Form, disk: &Path) -> Result<()> {
360
    let gone = match form {
361
        Form::Dir => crate::workspace::remove_tree(disk),
362
        Form::Image => std::fs::remove_file(disk).or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }),
363
    };
364
    gone.with_context(|| format!("discard {}", disk.display()))
365
}
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.
372
pub fn forget_reads(form: Form, disk: &Path) -> Result<()> {
373
    if form == Form::Dir && disk.is_dir() {
374
        flow_init::prune::forget_reads(disk).with_context(|| format!("set the atimes in {}", disk.display()))?;
375
    }
376
    Ok(())
377
}
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).
381
fn pruned(form: Form, m: &Mount, disk: &Path, prune: &Prune) -> Result<Pruned> {
382
    let size = m.size_mib as u64 * (1 << 20);
383
    let percent = prune.percent;
384
    let unpruned = |before: u64, why: String| Pruned { size, percent, before, after: before, unpruned: Some(why), ..Pruned::default() };
385
    Ok(match form {
386
        Form::Dir => {
387
            let before = tree_bytes(disk);
388
            if !flow_init::prune::records_reads(disk) {
389
                unpruned(before, "this runner's file system records no reads (noatime), so what the job used cannot be told".into())
390
            } else {
391
                flow_init::prune::prune(disk, prune.started, &[], size, before, percent)
392
                    .unwrap_or_else(|e| unpruned(tree_bytes(disk), format!("pruning failed: {e}")))
393
            }
394
        }
395
        Form::Image => match prune.guest.filter(|g| g.size > 0) {
396
            Some(g) => g.clone(),
397
            None => {
398
                let (before, size) = image_usage(disk)?;
399
                let why = prune.guest.and_then(|g| g.unpruned.clone()).unwrap_or_else(|| "the guest did not prune it".into());
400
                Pruned { size, ..unpruned(before, why) }
401
            }
402
        },
403
    })
404
}
405
406
/// What an ext4 image holds and its size, as its superblock counts them.
407
fn image_usage(image: &Path) -> Result<(u64, u64)> {
408
    let out = std::process::Command::new("dumpe2fs").arg("-h").arg(image).output().context("run dumpe2fs (e2fsprogs)")?;
409
    if !out.status.success() {
410
        anyhow::bail!("dumpe2fs {}: {}", image.display(), String::from_utf8_lossy(&out.stderr).trim());
411
    }
412
    superblock_usage(&String::from_utf8_lossy(&out.stdout)).with_context(|| format!("dumpe2fs {}: no block counts", image.display()))
413
}
414
415
fn superblock_usage(header: &str) -> Option<(u64, u64)> {
416
    let field = |name: &str| -> Option<u64> {
417
        header.lines().find_map(|l| l.strip_prefix(name)?.strip_prefix(':')).and_then(|v| v.trim().parse().ok())
418
    };
419
    let (count, free, block) = (field("Block count")?, field("Free blocks")?, field("Block size")?);
420
    Some((count.saturating_sub(free) * block, count * block))
421
}
422
423
/// The job log's line for a passing job's cache: what was kept, or why
424
/// not (oxygenflow#72).
425
pub fn describe(name: &str, p: &Pruned) -> String {
426
    use crate::images::human;
427
    let cap = format!("{}% of its {} disk", p.percent, human(p.size));
428
    let pruned = format!("{} in {} file{} this run did not use", human(p.freed), p.files, if p.files == 1 { "" } else { "s" });
429
    if p.fits() && p.files > 0 {
430
        format!("cache {name}: kept {} of {} (pruned {pruned}; the cap is {cap})", human(p.after), human(p.before))
431
    } else if p.fits() {
432
        format!("cache {name}: kept {}, under its cap of {cap}", human(p.after))
433
    } else {
434
        let after = if p.files > 0 { format!("{} after pruning {pruned}", human(p.after)) } else { human(p.after) };
435
        let why = p.unpruned.as_ref().map(|u| format!(" ({u})")).unwrap_or_default();
436
        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",
438
            human(p.cap())
439
        )
440
    }
441
}
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`.
446
pub fn disk_path(form: Form, scratch: &Path, n: usize) -> PathBuf {
447
    match form {
448
        Form::Image => scratch.join(format!("mount-{n}.ext4")),
449
        Form::Dir => crate::syscall::disk_dir(scratch, n),
450
    }
451
}
452
453
fn modified(p: &Path) -> SystemTime {
454
    std::fs::symlink_metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)
455
}
456
457
fn newest(dir: &Path, form: Form, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> {
458
    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))
459
}
460
461
/// Each kept image or directory `depth` levels of directories below `dir`,
462
/// with its form; never inside a kept directory.
463
fn kept(dir: &Path, depth: usize, f: &mut dyn FnMut(&Path, Form)) {
464
    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
465
        let p = e.path();
466
        let Ok(t) = e.file_type() else { continue };
467
        if depth == 0 {
468
            match form_of(&p) {
469
                Some(Form::Image) if t.is_file() => f(&p, Form::Image),
470
                Some(Form::Dir) if t.is_dir() => f(&p, Form::Dir),
471
                _ => {}
472
            }
473
        } else if t.is_dir() {
474
            kept(&p, depth - 1, f);
475
        }
476
    }
477
}
478
479
/// The blocks a directory's files take, each file once however many links
480
/// it has; links are not followed.
481
fn tree_bytes(dir: &Path) -> u64 {
482
    fn walk(p: &Path, seen: &mut std::collections::HashSet<(u64, u64)>) -> u64 {
483
        let Ok(md) = std::fs::symlink_metadata(p) else { return 0 };
484
        if md.nlink() > 1 && !md.is_dir() && !seen.insert((md.dev(), md.ino())) {
485
            return 0;
486
        }
487
        let mut bytes = md.blocks() * 512;
488
        if md.is_dir() {
489
            for e in std::fs::read_dir(p).into_iter().flatten().flatten() {
490
                bytes += walk(&e.path(), seen);
491
            }
492
        }
493
        bytes
494
    }
495
    walk(dir, &mut Default::default())
496
}
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.
503
fn seal(p: &Path) -> std::io::Result<()> {
504
    let md = std::fs::symlink_metadata(p)?;
505
    if md.file_type().is_symlink() {
506
        return Ok(());
507
    }
508
    let mode = md.mode() & 0o7777;
509
    let want = (mode & !0o7022) | if md.is_dir() { 0o700 } else { 0o400 };
510
    if want != mode {
511
        std::fs::set_permissions(p, std::fs::Permissions::from_mode(want))?;
512
    }
513
    if md.is_dir() {
514
        for e in std::fs::read_dir(p)? {
515
            seal(&e?.path())?;
516
        }
517
    }
518
    Ok(())
519
}
520
521
fn copy(form: Form, from: &Path, to: &Path) -> Result<()> {
522
    match form {
523
        Form::Image => sparse_copy(from, to),
524
        Form::Dir => tree_copy(from, to),
525
    }
526
}
527
528
/// Copy a sparse disk without writing its holes.
529
fn sparse_copy(from: &Path, to: &Path) -> Result<()> {
530
    let tmp = to.with_extension("part");
531
    let st = std::process::Command::new("cp").arg("--sparse=always").arg(from).arg(&tmp).status().context("run cp")?;
532
    if !st.success() {
533
        let _ = std::fs::remove_file(&tmp);
534
        anyhow::bail!("cp {} failed", from.display());
535
    }
536
    std::fs::rename(&tmp, to)?;
537
    Ok(())
538
}
539
540
/// Copy a directory with its modes and times (a build tool judges by
541
/// them), sharing blocks where the filesystem can.
542
fn tree_copy(from: &Path, to: &Path) -> Result<()> {
543
    let tmp = to.with_extension("tree-part");
544
    crate::workspace::remove_tree(&tmp)?;
545
    let st = std::process::Command::new("cp")
546
        .args(["-a", "--reflink=auto", "--no-target-directory"])
547
        .arg(from)
548
        .arg(&tmp)
549
        .status()
550
        .context("run cp")?;
551
    if !st.success() {
552
        let _ = crate::workspace::remove_tree(&tmp);
553
        anyhow::bail!("cp {} failed", from.display());
554
    }
555
    std::fs::rename(&tmp, to).inspect_err(|_| {
556
        let _ = crate::workspace::remove_tree(&tmp);
557
    })?;
558
    Ok(())
559
}
560
561
#[cfg(test)]
562
mod tests {
563
    use super::*;
564
    use flow_core::pipeline::MountSource;
565
566
    /// Pruning that never prunes: the cap is the whole disk.
567
    fn keep_all() -> Prune<'static> {
568
        Prune { started: SystemTime::now(), percent: 100, guest: None }
569
    }
570
571
    fn mount(key: &[&str]) -> Mount {
572
        Mount {
573
            source: MountSource::Disk,
574
            target: "/src/target".into(),
575
            size_mib: 1,
576
            cache: Some("target".into()),
577
            key: key.iter().map(|s| s.to_string()).collect(),
578
        }
579
    }
580
581
    fn disk(path: &Path, fill: &[u8]) {
582
        let f = std::fs::File::create(path).unwrap();
583
        f.set_len(1 << 20).unwrap();
584
        std::os::unix::fs::FileExt::write_at(&f, fill, 0).unwrap();
585
    }
586
587
    fn first(path: &Path) -> Vec<u8> {
588
        std::fs::read(path).unwrap()[..4].to_vec()
589
    }
590
591
    #[test]
592
    fn a_kept_disk_starts_the_next_job_of_its_bookmark_then_others_copy_it() {
593
        let home = tempfile::tempdir().unwrap();
594
        let work = tempfile::tempdir().unwrap();
595
        let pool = Pool::new(home.path(), None);
596
        let m = mount(&["Cargo.lock"]);
597
        let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img", form: Form::Image };
598
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
599
        // Nothing yet: the caller makes an empty disk.
600
        assert!(pool.take(&main, &m, &key, &work.path().join("a.ext4")).is_none());
601
        // A passing job's disk is kept and moved into the next job's place.
602
        disk(&work.path().join("a.ext4"), b"MAIN");
603
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
604
        pool.keep(&main, &m, &key, &work.path().join("a.ext4"), &built).unwrap();
605
        assert!(!work.path().join("a.ext4").exists(), "moved, not copied");
606
        let taken = pool.take(&main, &m, &key, &work.path().join("b.ext4")).unwrap();
607
        assert!(taken.how.contains("same key"), "{}", taken.how);
608
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
609
        assert_eq!(first(&work.path().join("b.ext4")), b"MAIN");
610
        pool.keep(&main, &m, &key, &work.path().join("b.ext4"), &built).unwrap();
611
        // A changed key takes the bookmark's newest disk all the same.
612
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
613
        assert_ne!(key, key2);
614
        let taken = pool.take(&main, &m, &key2, &work.path().join("c.ext4")).unwrap();
615
        assert!(taken.how.contains("older key"), "{}", taken.how);
616
        assert_eq!(taken.sources.as_ref(), Some(&built));
617
        pool.keep(&main, &m, &key2, &work.path().join("c.ext4"), &built).unwrap();
618
        assert_eq!(
619
            pool.slot_times().into_keys().collect::<Vec<_>>(),
620
            vec![flow_core::api::cache_slot("andy/hub", "test")],
621
            "what the claim reports (#51)"
622
        );
623
        // With when it was last kept (#54): just now.
624
        let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64;
625
        let kept = pool.slot_times()[&flow_core::api::cache_slot("andy/hub", "test")];
626
        assert!(now.abs_diff(kept) < 60_000, "kept {kept}, now {now}");
627
        // Another bookmark copies it, leaving main's in place.
628
        let feature = Ctx { bookmark: "feature", ..main };
629
        let taken = pool.take(&feature, &m, &key2, &work.path().join("d.ext4")).unwrap();
630
        assert!(taken.how.contains("copied from another bookmark"), "{}", taken.how);
631
        assert_eq!(taken.sources.as_ref(), Some(&built), "a copy reads them and leaves them");
632
        assert_eq!(first(&work.path().join("d.ext4")), b"MAIN");
633
        let taken = pool.take(&main, &m, &key2, &work.path().join("e.ext4")).unwrap();
634
        assert!(taken.how.contains("this bookmark's") && taken.sources.is_some(), "main's is still there, with its sources");
635
        // Another job or a different size never shares it.
636
        let other_job = Ctx { job: "build", ..main };
637
        assert!(pool.take(&other_job, &m, &key2, &work.path().join("f.ext4")).is_none());
638
        let mut bigger = mount(&["Cargo.lock"]);
639
        bigger.size_mib = 2;
640
        assert!(pool.take(&feature, &bigger, &key2, &work.path().join("g.ext4")).is_none());
641
    }
642
643
    #[test]
644
    fn the_key_follows_the_image_the_job_and_the_key_files() {
645
        let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1", form: Form::Image };
646
        let m = mount(&["Cargo.lock", "rust-toolchain.toml"]);
647
        let k = Pool::key(&ctx, &m, |p| Some(format!("{p}-v1")));
648
        assert_eq!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v1"))));
649
        assert_ne!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v2"))), "a key file changed");
650
        assert_ne!(k, Pool::key(&Ctx { image: "img-2", ..ctx }, &m, |p| Some(format!("{p}-v1"))), "another image");
651
        assert_ne!(k, Pool::key(&ctx, &m, |_| None), "a key file missing");
652
    }
653
654
    #[test]
655
    fn the_pool_drops_the_disks_used_longest_ago() {
656
        let home = tempfile::tempdir().unwrap();
657
        let work = tempfile::tempdir().unwrap();
658
        let pool = Pool { root: home.path().join("cache"), limit: 0 };
659
        let m = mount(&[]);
660
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
661
        let full = |p: &Path| {
662
            let f = std::fs::File::create(p).unwrap();
663
            std::os::unix::fs::FileExt::write_at(&f, &vec![7u8; 1 << 20], 0).unwrap();
664
        };
665
        full(&work.path().join("a.ext4"));
666
        // Over a limit of nothing: kept, then dropped at once.
667
        pool.keep(&ctx, &m, "k1", &work.path().join("a.ext4"), &Sources::new()).unwrap();
668
        assert!(pool.take(&ctx, &m, "k1", &work.path().join("b.ext4")).is_none());
669
        let roomy = Pool { root: home.path().join("cache"), limit: 1 << 30 };
670
        full(&work.path().join("c.ext4"));
671
        roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4"), &Sources::new()).unwrap();
672
        assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays");
673
    }
674
675
    /// A directory as the syscall tier leaves one: `marker` in
676
    /// `debug/marker`, and a file of `kib` KiB.
677
    fn tree(path: &Path, marker: &[u8], kib: usize) {
678
        std::fs::create_dir_all(path.join("debug")).unwrap();
679
        std::fs::write(path.join("debug/marker"), marker).unwrap();
680
        // Not all one byte, so a compressing filesystem stores it whole.
681
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
682
        std::fs::write(path.join("debug/blob"), bytes).unwrap();
683
    }
684
685
    fn dir_ctx<'a>(bookmark: &'a str) -> Ctx<'a> {
686
        Ctx { repo: "andy/flow", bookmark, job: "test", image: "img", form: Form::Dir }
687
    }
688
689
    /// oxygenflow#70: the syscall tier's directories are taken and kept as
690
    /// a microVM's images are, and never mixed with them.
691
    #[test]
692
    fn a_kept_directory_starts_the_next_syscall_job_and_others_copy_it() {
693
        let home = tempfile::tempdir().unwrap();
694
        let work = tempfile::tempdir().unwrap();
695
        let pool = Pool::new(home.path(), None);
696
        let m = mount(&["Cargo.lock"]);
697
        let main = dir_ctx("main");
698
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
699
        let w = |n: &str| work.path().join(n);
700
        assert!(pool.take(&main, &m, &key, &w("a")).is_none(), "nothing kept yet");
701
        // A passing job's directory goes into the pool, times and all.
702
        tree(&w("a"), b"FIRST", 4);
703
        // Named like an image: the pool never looks inside a directory.
704
        std::fs::write(w("a/debug/inner.ext4"), b"not a disk").unwrap();
705
        let old = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
706
        std::fs::File::open(w("a/debug/marker")).unwrap().set_modified(old).unwrap();
707
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
708
        pool.settle(&main, &m, &key, &w("a"), &built, true, &keep_all()).unwrap();
709
        assert!(!w("a").exists(), "moved, not copied");
710
        assert_eq!(
711
            pool.slot_times().into_keys().collect::<Vec<_>>(),
712
            vec![flow_core::api::cache_slot("andy/flow", "test")],
713
            "the claim reports it like an image (#51)"
714
        );
715
        // The next job of its bookmark moves it out: a job of the same
716
        // slot beside it finds nothing to take.
717
        let taken = pool.take(&main, &m, &key, &w("b")).unwrap();
718
        assert!(taken.how.contains("this bookmark's, same key"), "{}", taken.how);
719
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
720
        assert_eq!(std::fs::read(w("b/debug/marker")).unwrap(), b"FIRST");
721
        assert_eq!(modified(&w("b/debug/marker")), old, "its files keep their times");
722
        assert_eq!(std::fs::read(w("b/debug/inner.ext4")).unwrap(), b"not a disk");
723
        assert!(pool.take(&main, &m, &key, &w("c")).is_none(), "taken once");
724
        assert!(pool.slot_times().is_empty());
725
        // Kept again, and under a changed key the newest is taken all the same.
726
        std::fs::write(w("b/debug/marker"), b"SECOND").unwrap();
727
        pool.settle(&main, &m, &key, &w("b"), &built, true, &keep_all()).unwrap();
728
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
729
        let taken = pool.take(&main, &m, &key2, &w("d")).unwrap();
730
        assert!(taken.how.contains("older key"), "{}", taken.how);
731
        assert_eq!(std::fs::read(w("d/debug/marker")).unwrap(), b"SECOND");
732
        pool.settle(&main, &m, &key2, &w("d"), &built, true, &keep_all()).unwrap();
733
        // Another bookmark copies it, with its times, and main keeps its own.
734
        let feature = dir_ctx("feature");
735
        let taken = pool.take(&feature, &m, &key2, &w("e")).unwrap();
736
        assert!(taken.how.contains("copied from another bookmark's (main-"), "{}", taken.how);
737
        assert_eq!(taken.sources.as_ref(), Some(&built));
738
        assert_eq!(std::fs::read(w("e/debug/marker")).unwrap(), b"SECOND");
739
        assert!(pool.take(&main, &m, &key2, &w("f")).is_some(), "main's is still there");
740
        assert_eq!(modified(&w("e/debug/marker")), modified(&w("f/debug/marker")), "a copy keeps the times");
741
        pool.settle(&main, &m, &key2, &w("f"), &built, true, &keep_all()).unwrap();
742
        // A microVM job of the same slot never takes a directory, nor a
743
        // syscall job an image.
744
        let image = Ctx { form: Form::Image, ..main };
745
        assert!(pool.take(&image, &m, &key2, &w("g.ext4")).is_none());
746
        disk(&w("h.ext4"), b"DISK");
747
        pool.keep(&image, &m, &key2, &w("h.ext4"), &built).unwrap();
748
        let taken = pool.take(&main, &m, &key2, &w("i")).unwrap();
749
        assert!(w("i/debug/marker").is_file(), "{}", taken.how);
750
        assert!(pool.take(&main, &m, &key2, &w("j")).is_none(), "the image is not a directory");
751
        assert!(pool.take(&image, &m, &key2, &w("k.ext4")).is_some(), "and is still there for a microVM");
752
    }
753
754
    #[test]
755
    fn a_failing_jobs_directory_is_thrown_away() {
756
        let home = tempfile::tempdir().unwrap();
757
        let work = tempfile::tempdir().unwrap();
758
        let pool = Pool::new(home.path(), None);
759
        let m = mount(&[]);
760
        let main = dir_ctx("main");
761
        tree(&work.path().join("a"), b"GOOD", 1);
762
        pool.settle(&main, &m, "k", &work.path().join("a"), &Sources::new(), true, &keep_all()).unwrap();
763
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
764
        assert!(taken.is_some());
765
        // The job that took it fails after writing to it: not kept, gone.
766
        std::fs::write(work.path().join("b/debug/marker"), b"POISON").unwrap();
767
        pool.settle(&main, &m, "k", &work.path().join("b"), &Sources::new(), false, &keep_all()).unwrap();
768
        assert!(!work.path().join("b").exists());
769
        assert!(pool.take(&main, &m, "k", &work.path().join("c")).is_none(), "nothing it wrote reaches a later job");
770
        assert!(pool.slot_times().is_empty());
771
        // A failed image is thrown away the same way.
772
        let image = Ctx { form: Form::Image, ..main };
773
        disk(&work.path().join("d.ext4"), b"BAD!");
774
        pool.settle(&image, &m, "k", &work.path().join("d.ext4"), &Sources::new(), false, &keep_all()).unwrap();
775
        assert!(!work.path().join("d.ext4").exists() && pool.slot_times().is_empty());
776
    }
777
778
    #[test]
779
    fn directories_are_evicted_by_their_size_oldest_first() {
780
        let home = tempfile::tempdir().unwrap();
781
        let work = tempfile::tempdir().unwrap();
782
        // Room for one 512 KiB directory, not two.
783
        let pool = Pool { root: home.path().join("cache"), limit: 800 << 10 };
784
        let m = mount(&[]);
785
        let (main, feature) = (dir_ctx("main"), dir_ctx("feature"));
786
        tree(&work.path().join("a"), b"OLD", 512);
787
        pool.keep(&main, &m, "k1", &work.path().join("a"), &Sources::new()).unwrap();
788
        let kept = pool.cache_dir(&main, "target").join(segment("main")).join("k1.tree");
789
        assert!(kept.is_dir(), "under the limit it stays");
790
        assert!(tree_bytes(&kept) >= 512 << 10, "a directory counts what is in it");
791
        // Older than the next one, however fast this test runs.
792
        std::fs::File::open(&kept).unwrap().set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1)).unwrap();
793
        tree(&work.path().join("b"), b"NEW", 512);
794
        pool.keep(&feature, &m, "k1", &work.path().join("b"), &Sources::new()).unwrap();
795
        assert!(!kept.exists() && !kept.with_extension("tree-sources").exists(), "the one used longest ago goes, sources and all");
796
        let taken = pool.take(&main, &m, "k1", &work.path().join("c")).unwrap();
797
        assert!(taken.how.contains("bookmark's (feature-"), "{}", taken.how);
798
        assert_eq!(std::fs::read(work.path().join("c/debug/marker")).unwrap(), b"NEW");
799
    }
800
801
    /// oxygenflow#70: the job wrote what it liked; the pool keeps nothing
802
    /// others may write or that runs as the runner, and is the runner's.
803
    #[test]
804
    fn nothing_kept_is_writable_by_others_or_set_id() {
805
        let home = tempfile::tempdir().unwrap();
806
        let work = tempfile::tempdir().unwrap();
807
        let pool = Pool::new(home.path(), None);
808
        let m = mount(&[]);
809
        let main = dir_ctx("main");
810
        let a = work.path().join("a");
811
        tree(&a, b"X", 1);
812
        let mode = |p: &Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap();
813
        std::fs::write(a.join("open"), b"").unwrap();
814
        mode(&a.join("open"), 0o777);
815
        std::fs::write(a.join("suid"), b"").unwrap();
816
        mode(&a.join("suid"), 0o4755);
817
        std::fs::write(a.join("debug/sealed"), b"secret").unwrap();
818
        mode(&a.join("debug/sealed"), 0o000);
819
        std::os::unix::fs::symlink("/etc/passwd", a.join("link")).unwrap();
820
        let when = modified(&a.join("debug/marker"));
821
        mode(&a.join("debug"), 0o1777);
822
        mode(&a, 0o500);
823
        pool.keep(&main, &m, "k", &a, &Sources::new()).unwrap();
824
        let root = home.path().join("cache");
825
        assert_eq!(std::fs::metadata(&root).unwrap().mode() & 0o777, 0o700, "the pool is the runner's alone");
826
        let mut seen = 0;
827
        fn each(p: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) {
828
            let md = std::fs::symlink_metadata(p).unwrap();
829
            f(p, &md);
830
            if md.is_dir() {
831
                for e in std::fs::read_dir(p).unwrap() {
832
                    each(&e.unwrap().path(), f);
833
                }
834
            }
835
        }
836
        each(&root, &mut |p, md| {
837
            seen += 1;
838
            if !md.file_type().is_symlink() {
839
                assert_eq!(md.mode() & 0o7022, 0, "{} is {:o}", p.display(), md.mode());
840
            }
841
        });
842
        assert!(seen >= 10, "{seen}");
843
        // Still readable, with its times, so the next job builds on it.
844
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
845
        assert!(taken.is_some());
846
        let b = work.path().join("b");
847
        assert_eq!(std::fs::read(b.join("debug/sealed")).unwrap(), b"secret");
848
        assert_eq!(std::fs::metadata(b.join("open")).unwrap().mode() & 0o777, 0o755);
849
        assert_eq!(std::fs::read_link(b.join("link")).unwrap(), Path::new("/etc/passwd"), "a link is kept, not followed");
850
        assert_eq!(modified(&b.join("debug/marker")), when);
851
    }
852
    /// A file of `kib` KiB at `rel` under `root`, last read and written
853
    /// at `secs` (ahead of the clock, so the time it was changed, now,
854
    /// is before a job starting at [`T`]).
855
    fn aged(root: &Path, rel: &str, kib: usize, secs: u64) -> PathBuf {
856
        let p = root.join(rel);
857
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
858
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
859
        std::fs::write(&p, bytes).unwrap();
860
        let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
861
        std::fs::File::options()
862
            .write(true)
863
            .open(&p)
864
            .unwrap()
865
            .set_times(std::fs::FileTimes::new().set_accessed(t).set_modified(t))
866
            .unwrap();
867
        p
868
    }
869
870
    const T: u64 = 4_000_000_000;
871
872
    fn at_t() -> SystemTime {
873
        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(T)
874
    }
875
876
    /// oxygenflow#72: a syscall job's directory is pruned of what the job
877
    /// did not use before it is kept, and the log says so; one still over
878
    /// its cap is not kept at all.
879
    #[test]
880
    fn a_directory_is_pruned_of_what_the_job_did_not_use_before_it_is_kept() {
881
        let home = tempfile::tempdir().unwrap();
882
        let work = tempfile::tempdir().unwrap();
883
        let pool = Pool::new(home.path(), None);
884
        let main = dir_ctx("main");
885
        let mut m = mount(&[]);
886
        m.size_mib = 8;
887
        let a = work.path().join("a");
888
        // This run's build, and an older lockfile's that it did not touch.
889
        aged(&a, "debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib", 2 << 10, T + 10);
890
        aged(&a, "debug/deps/libold-bbbbbbbbbbbbbbbb.rlib", 3 << 10, T - 1_000);
891
        let prune = Prune { started: at_t(), percent: 50, guest: None };
892
        let line = pool.settle(&main, &m, "k", &a, &Sources::new(), true, &prune).unwrap().unwrap();
893
        assert_eq!(
894
            line,
895
            "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)"
896
        );
897
        let b = work.path().join("b");
898
        assert!(pool.take(&main, &m, "k", &b).unwrap().how.contains("same key"));
899
        assert!(b.join("debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib").is_file() && !b.join("debug/deps/libold-bbbbbbbbbbbbbbbb.rlib").exists());
900
        // Before the next job its reads are forgotten, its writes not.
901
        forget_reads(Form::Dir, &b).unwrap();
902
        let md = std::fs::metadata(b.join("debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib")).unwrap();
903
        assert_eq!((md.atime(), md.mtime()), (0, (T + 10) as i64));
904
        // This run's own files over the cap: nothing it used goes, and the
905
        // cache is not kept, so the next run starts empty.
906
        aged(&b, "debug/deps/libbig-cccccccccccccccc.rlib", 3 << 10, T + 20);
907
        let line = pool.settle(&main, &m, "k", &b, &Sources::new(), true, &prune).unwrap().unwrap();
908
        assert!(
909
            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"),
910
            "{line}"
911
        );
912
        assert!(!b.exists() && pool.take(&main, &m, "k", &work.path().join("c")).is_none());
913
    }
914
915
    /// oxygenflow#72: a microVM's image is kept or not by what its guest
916
    /// said; without word from it, by what its superblock says is in use.
917
    #[test]
918
    fn an_image_is_kept_by_what_its_guest_pruned_or_else_by_its_size() {
919
        let home = tempfile::tempdir().unwrap();
920
        let work = tempfile::tempdir().unwrap();
921
        let pool = Pool::new(home.path(), None);
922
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
923
        let mut m = mount(&[]);
924
        m.size_mib = 16;
925
        let fits = Pruned { size: 16 << 20, percent: 60, before: 12 << 20, after: 6 << 20, freed: 6 << 20, files: 40, unpruned: None };
926
        let over = Pruned { after: 11 << 20, ..fits.clone() };
927
        disk(&work.path().join("a.ext4"), b"KEEP");
928
        let line = pool
929
            .settle(&ctx, &m, "k", &work.path().join("a.ext4"), &Sources::new(), true, &Prune { guest: Some(&fits), ..keep_all() })
930
            .unwrap()
931
            .unwrap();
932
        assert_eq!(
933
            line,
934
            "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)"
935
        );
936
        assert!(pool.take(&ctx, &m, "k", &work.path().join("b.ext4")).is_none(), "a 1 MiB disk is not the mount's 16 MiB");
937
        disk(&work.path().join("c.ext4"), b"OVER");
938
        let line = pool
939
            .settle(&ctx, &m, "k", &work.path().join("c.ext4"), &Sources::new(), true, &Prune { guest: Some(&over), ..keep_all() })
940
            .unwrap()
941
            .unwrap();
942
        assert!(line.starts_with("cache target: not kept: 11 MiB after pruning 6 MiB in 40 files"), "{line}");
943
        assert!(!work.path().join("c.ext4").exists());
944
        // No word from the guest: an empty file system is well under.
945
        let img = work.path().join("d.ext4");
946
        crate::workspace::empty_ext4(&img, 16, "scratch").unwrap();
947
        let line = pool.settle(&ctx, &m, "k", &img, &Sources::new(), true, &Prune { percent: 60, ..keep_all() }).unwrap().unwrap();
948
        assert!(line.starts_with("cache target: kept ") && line.contains("under its cap of 60% of its 16 MiB disk"), "{line}");
949
        assert!(pool.take(&ctx, &m, "k", &work.path().join("e.ext4")).is_some());
950
        // ... and one nearly all in use is not kept, saying why.
951
        let line = pool
952
            .settle(&ctx, &m, "k", &work.path().join("e.ext4"), &Sources::new(), true, &Prune { percent: 1, ..keep_all() })
953
            .unwrap()
954
            .unwrap();
955
        assert!(line.contains("not kept") && line.contains("(the guest did not prune it)"), "{line}");
956
    }
957
958
    #[test]
959
    fn the_superblock_says_what_is_in_use() {
960
        let h = "Filesystem volume name:   scratch\nBlock count:              4096\nFree blocks:              1024\nBlock size:               4096\n";
961
        assert_eq!(superblock_usage(h), Some((3072 * 4096, 4096 * 4096)));
962
        assert_eq!(superblock_usage("Block count: 1\n"), None);
963
    }
964
}