OXYGENHUB
# 0d663e91c598 oxygenflow / crates / flow / src / cache.rs
44.8 KiB · Rust · last changed flow builds for Windows, offering the WASM tier 10 days ago source blame history raw
d60aac6c Andy McCall ↶ 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.
82cf6e91 Andy McCall ↶ 20
//!
2594278f Andy McCall ↶ 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.
077a6524 Andy McCall ↶ 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.
d60aac6c Andy McCall ↶ 46
47
use anyhow::{Context, Result};
48
use flow_core::pipeline::Mount;
077a6524 Andy McCall ↶ 49
use flow_init::prune::Pruned;
d60aac6c Andy McCall ↶ 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;
82cf6e91 Andy McCall ↶ 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>,
2594278f Andy McCall ↶ 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 {
78
    fn ext(self) -> &'static str {
79
        match self {
80
            Form::Image => "ext4",
81
            Form::Dir => "tree",
82
        }
83
    }
84
}
85
86
/// Which form a kept entry of the pool is in, by its name.
87
fn form_of(p: &Path) -> Option<Form> {
88
    match p.extension()?.to_str()? {
89
        "ext4" => Some(Form::Image),
90
        "tree" => Some(Form::Dir),
91
        _ => None,
92
    }
82cf6e91 Andy McCall ↶ 93
}
94
95
fn sources_of(disk: &Path) -> PathBuf {
2594278f Andy McCall ↶ 96
    match form_of(disk) {
97
        Some(Form::Dir) => disk.with_extension("tree-sources"),
98
        _ => disk.with_extension("sources"),
99
    }
82cf6e91 Andy McCall ↶ 100
}
101
102
fn read_sources(disk: &Path) -> Option<Sources> {
103
    serde_json::from_slice(&std::fs::read(sources_of(disk)).ok()?).ok()
077a6524 Andy McCall ↶ 104
}
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>,
82cf6e91 Andy McCall ↶ 116
}
d60aac6c Andy McCall ↶ 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
2594278f Andy McCall ↶ 124
/// Which job: the pool's coordinates other than the key, and the form its
125
/// tier keeps a disk in.
d60aac6c Andy McCall ↶ 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,
2594278f Andy McCall ↶ 131
    pub form: Form,
d60aac6c Andy McCall ↶ 132
}
133
82cf6e91 Andy McCall ↶ 134
use flow_core::api::segment;
2594278f Andy McCall ↶ 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(());
d60aac6c Andy McCall ↶ 140
2594278f Andy McCall ↶ 141
fn changing() -> std::sync::MutexGuard<'static, ()> {
142
    CHANGING.lock().unwrap_or_else(|p| p.into_inner())
143
}
144
d60aac6c Andy McCall ↶ 145
impl Pool {
146
    pub fn new(home_root: &Path, gb: Option<u64>) -> Pool {
147
        Pool { root: home_root.join("cache"), limit: gb.unwrap_or(DEFAULT_GB).saturating_mul(1 << 30) }
148
    }
149
150
    fn cache_dir(&self, ctx: &Ctx, name: &str) -> PathBuf {
151
        self.root.join(segment(ctx.repo)).join(segment(ctx.job)).join(segment(name))
152
    }
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).
156
    pub fn key(ctx: &Ctx, m: &Mount, hash_of: impl Fn(&str) -> Option<String>) -> String {
157
        let mut h = blake3::Hasher::new();
158
        for part in [ctx.image, ctx.job, m.cache.as_deref().unwrap_or("")] {
159
            h.update(part.as_bytes());
160
            h.update(b"\n");
161
        }
162
        for k in &m.key {
163
            h.update(k.as_bytes());
164
            h.update(b"=");
165
            h.update(hash_of(k).unwrap_or_else(|| "absent".into()).as_bytes());
166
            h.update(b"\n");
167
        }
168
        h.finalize().to_hex()[..32].to_string()
169
    }
170
2594278f Andy McCall ↶ 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.
82cf6e91 Andy McCall ↶ 177
    pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<Taken> {
d60aac6c Andy McCall ↶ 178
        let name = m.cache.as_deref()?;
2594278f Andy McCall ↶ 179
        let _changing = changing();
d60aac6c Andy McCall ↶ 180
        let dir = self.cache_dir(ctx, name);
181
        let size = m.size_mib as u64 * (1 << 20);
2594278f Andy McCall ↶ 182
        let form = ctx.form;
183
        let fits = |p: &Path| match form {
184
            Form::Image => std::fs::metadata(p).is_ok_and(|md| md.len() == size),
185
            Form::Dir => std::fs::symlink_metadata(p).is_ok_and(|md| md.is_dir()),
186
        };
187
        let ext = form.ext();
d60aac6c Andy McCall ↶ 188
        let mine = dir.join(segment(ctx.bookmark));
2594278f Andy McCall ↶ 189
        let exact = mine.join(format!("{key}.{ext}"));
82cf6e91 Andy McCall ↶ 190
        // A moved disk takes its sources with it.
191
        if fits(&exact) {
192
            let sources = read_sources(&exact);
193
            if std::fs::rename(&exact, dest).is_ok() {
194
                let _ = std::fs::remove_file(sources_of(&exact));
195
                return Some(Taken { how: format!("cache {name}: this bookmark's, same key"), sources });
196
            }
d60aac6c Andy McCall ↶ 197
        }
2594278f Andy McCall ↶ 198
        if let Some(p) = newest(&mine, form, |p| fits(p)) {
82cf6e91 Andy McCall ↶ 199
            let sources = read_sources(&p);
d60aac6c Andy McCall ↶ 200
            if std::fs::rename(&p, dest).is_ok() {
82cf6e91 Andy McCall ↶ 201
                let _ = std::fs::remove_file(sources_of(&p));
202
                return Some(Taken { how: format!("cache {name}: this bookmark's, an older key"), sources });
d60aac6c Andy McCall ↶ 203
            }
204
        }
205
        let others: Vec<PathBuf> =
206
            std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir() && *p != mine).collect();
207
        let pick = others
208
            .iter()
2594278f Andy McCall ↶ 209
            .map(|d| d.join(format!("{key}.{ext}")))
d60aac6c Andy McCall ↶ 210
            .filter(|p| fits(p))
211
            .max_by_key(|p| modified(p))
212
            .map(|p| (p, "same key"))
2594278f Andy McCall ↶ 213
            .or_else(|| {
214
                others.iter().filter_map(|d| newest(d, form, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key"))
215
            });
d60aac6c Andy McCall ↶ 216
        if let Some((p, which)) = pick {
217
            let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
2594278f Andy McCall ↶ 218
            if copy(form, &p, dest).is_ok() {
82cf6e91 Andy McCall ↶ 219
                return Some(Taken {
220
                    how: format!("cache {name}: copied from another bookmark's ({from}), {which}"),
221
                    sources: read_sources(&p),
222
                });
d60aac6c Andy McCall ↶ 223
            }
224
        }
225
        None
226
    }
227
228
    /// Keep a passing job's disk for mount `m` under its key, then keep
229
    /// the pool under its limit.
82cf6e91 Andy McCall ↶ 230
    pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources) -> Result<()> {
d60aac6c Andy McCall ↶ 231
        let Some(name) = m.cache.as_deref() else { return Ok(()) };
2594278f Andy McCall ↶ 232
        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).
235
        if ctx.form == Form::Dir {
236
            seal(disk).with_context(|| format!("seal {}", disk.display()))?;
237
        }
d60aac6c Andy McCall ↶ 238
        let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark));
3554afef Andy McCall ↶ 239
        crate::portable::create_private_dir(&mine)?;
240
        crate::portable::set_mode(&self.root, 0o700)?;
2594278f Andy McCall ↶ 241
        let dest = mine.join(format!("{key}.{}", ctx.form.ext()));
242
        if ctx.form == Form::Dir {
243
            // Another job of this slot kept one since: this one is newer.
244
            crate::workspace::remove_tree(&dest)?;
245
        }
d60aac6c Andy McCall ↶ 246
        if std::fs::rename(disk, &dest).is_err() {
2594278f Andy McCall ↶ 247
            copy(ctx.form, disk, &dest).with_context(|| format!("keep {}", disk.display()))?;
d60aac6c Andy McCall ↶ 248
        }
249
        // Moved disks keep their old time: mark it used now.
2594278f Andy McCall ↶ 250
        std::fs::File::open(&dest)?.set_modified(SystemTime::now())?;
251
        let sidecar = sources_of(&dest);
252
        let tmp = sidecar.with_extension("sources-part");
253
        let _ = std::fs::remove_file(&tmp);
254
        {
255
            use std::io::Write;
3554afef Andy McCall ↶ 256
            crate::portable::private(std::fs::OpenOptions::new().write(true).create_new(true))
257
                .open(&tmp)?
258
                .write_all(&serde_json::to_vec(sources)?)?;
2594278f Andy McCall ↶ 259
        }
260
        std::fs::rename(&tmp, sidecar)?;
d60aac6c Andy McCall ↶ 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(())
2594278f Andy McCall ↶ 265
    }
266
077a6524 Andy McCall ↶ 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>> {
2594278f Andy McCall ↶ 282
        if passed {
077a6524 Andy McCall ↶ 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));
2594278f Andy McCall ↶ 292
        }
077a6524 Andy McCall ↶ 293
        discard(ctx.form, disk)?;
294
        Ok(None)
82cf6e91 Andy McCall ↶ 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
1e6b9fb1 Andy McCall ↶ 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
2594278f Andy McCall ↶ 301
    /// build (oxygenflow#54). Images and directories alike, so a job goes
302
    /// where its cache is whichever tier keeps it.
1e6b9fb1 Andy McCall ↶ 303
    pub fn slot_times(&self) -> std::collections::BTreeMap<String, u64> {
304
        let mut out = std::collections::BTreeMap::new();
82cf6e91 Andy McCall ↶ 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() {
1e6b9fb1 Andy McCall ↶ 307
                let mut newest: Option<SystemTime> = None;
2594278f Andy McCall ↶ 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)));
1e6b9fb1 Andy McCall ↶ 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);
82cf6e91 Andy McCall ↶ 316
                }
317
            }
318
        }
319
        out
d60aac6c Andy McCall ↶ 320
    }
321
2594278f Andy McCall ↶ 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 {
d60aac6c Andy McCall ↶ 326
        let mut disks: Vec<(SystemTime, u64, PathBuf)> = Vec::new();
2594278f Andy McCall ↶ 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 {
3554afef Andy McCall ↶ 331
                    Form::Image => crate::portable::disk_bytes(&md),
2594278f Andy McCall ↶ 332
                    Form::Dir => tree_bytes(p),
333
                };
334
                disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), bytes, p.to_path_buf()));
d60aac6c Andy McCall ↶ 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
            }
2594278f Andy McCall ↶ 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() {
82cf6e91 Andy McCall ↶ 349
                let _ = std::fs::remove_file(sources_of(&p));
d60aac6c Andy McCall ↶ 350
                total -= bytes;
351
                freed += bytes;
352
            }
353
        }
354
        freed
077a6524 Andy McCall ↶ 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
        )
d60aac6c Andy McCall ↶ 440
    }
441
}
442
2594278f Andy McCall ↶ 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
d60aac6c Andy McCall ↶ 453
fn modified(p: &Path) -> SystemTime {
2594278f Andy McCall ↶ 454
    std::fs::symlink_metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)
d60aac6c Andy McCall ↶ 455
}
456
2594278f Andy McCall ↶ 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))
d60aac6c Andy McCall ↶ 459
}
460
2594278f Andy McCall ↶ 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)) {
d60aac6c Andy McCall ↶ 464
    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
465
        let p = e.path();
2594278f Andy McCall ↶ 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 };
3554afef Andy McCall ↶ 484
        if crate::portable::shared_identity(&md).is_some_and(|id| !seen.insert(id)) {
2594278f Andy McCall ↶ 485
            return 0;
486
        }
3554afef Andy McCall ↶ 487
        let mut bytes = crate::portable::disk_bytes(&md);
2594278f Andy McCall ↶ 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
    }
3554afef Andy McCall ↶ 508
    let mode = crate::portable::mode(&md);
2594278f Andy McCall ↶ 509
    let want = (mode & !0o7022) | if md.is_dir() { 0o700 } else { 0o400 };
510
    if want != mode {
3554afef Andy McCall ↶ 511
        crate::portable::set_mode(p, want)?;
2594278f Andy McCall ↶ 512
    }
513
    if md.is_dir() {
514
        for e in std::fs::read_dir(p)? {
515
            seal(&e?.path())?;
d60aac6c Andy McCall ↶ 516
        }
2594278f Andy McCall ↶ 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),
d60aac6c Andy McCall ↶ 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
2594278f Andy McCall ↶ 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
3554afef Andy McCall ↶ 561
#[cfg(all(test, unix))]
d60aac6c Andy McCall ↶ 562
mod tests {
563
    use super::*;
564
    use flow_core::pipeline::MountSource;
3554afef Andy McCall ↶ 565
    use std::os::unix::fs::{MetadataExt, PermissionsExt};
d60aac6c Andy McCall ↶ 566
077a6524 Andy McCall ↶ 567
    /// Pruning that never prunes: the cap is the whole disk.
568
    fn keep_all() -> Prune<'static> {
569
        Prune { started: SystemTime::now(), percent: 100, guest: None }
570
    }
571
d60aac6c Andy McCall ↶ 572
    fn mount(key: &[&str]) -> Mount {
573
        Mount {
574
            source: MountSource::Disk,
575
            target: "/src/target".into(),
576
            size_mib: 1,
577
            cache: Some("target".into()),
578
            key: key.iter().map(|s| s.to_string()).collect(),
579
        }
580
    }
581
582
    fn disk(path: &Path, fill: &[u8]) {
583
        let f = std::fs::File::create(path).unwrap();
584
        f.set_len(1 << 20).unwrap();
585
        std::os::unix::fs::FileExt::write_at(&f, fill, 0).unwrap();
586
    }
587
588
    fn first(path: &Path) -> Vec<u8> {
589
        std::fs::read(path).unwrap()[..4].to_vec()
590
    }
591
592
    #[test]
593
    fn a_kept_disk_starts_the_next_job_of_its_bookmark_then_others_copy_it() {
594
        let home = tempfile::tempdir().unwrap();
595
        let work = tempfile::tempdir().unwrap();
596
        let pool = Pool::new(home.path(), None);
597
        let m = mount(&["Cargo.lock"]);
2594278f Andy McCall ↶ 598
        let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img", form: Form::Image };
d60aac6c Andy McCall ↶ 599
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
600
        // Nothing yet: the caller makes an empty disk.
82cf6e91 Andy McCall ↶ 601
        assert!(pool.take(&main, &m, &key, &work.path().join("a.ext4")).is_none());
d60aac6c Andy McCall ↶ 602
        // A passing job's disk is kept and moved into the next job's place.
603
        disk(&work.path().join("a.ext4"), b"MAIN");
82cf6e91 Andy McCall ↶ 604
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
605
        pool.keep(&main, &m, &key, &work.path().join("a.ext4"), &built).unwrap();
d60aac6c Andy McCall ↶ 606
        assert!(!work.path().join("a.ext4").exists(), "moved, not copied");
82cf6e91 Andy McCall ↶ 607
        let taken = pool.take(&main, &m, &key, &work.path().join("b.ext4")).unwrap();
608
        assert!(taken.how.contains("same key"), "{}", taken.how);
609
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
d60aac6c Andy McCall ↶ 610
        assert_eq!(first(&work.path().join("b.ext4")), b"MAIN");
82cf6e91 Andy McCall ↶ 611
        pool.keep(&main, &m, &key, &work.path().join("b.ext4"), &built).unwrap();
d60aac6c Andy McCall ↶ 612
        // A changed key takes the bookmark's newest disk all the same.
613
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
614
        assert_ne!(key, key2);
82cf6e91 Andy McCall ↶ 615
        let taken = pool.take(&main, &m, &key2, &work.path().join("c.ext4")).unwrap();
616
        assert!(taken.how.contains("older key"), "{}", taken.how);
617
        assert_eq!(taken.sources.as_ref(), Some(&built));
618
        pool.keep(&main, &m, &key2, &work.path().join("c.ext4"), &built).unwrap();
1e6b9fb1 Andy McCall ↶ 619
        assert_eq!(
620
            pool.slot_times().into_keys().collect::<Vec<_>>(),
621
            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.
625
        let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64;
626
        let kept = pool.slot_times()[&flow_core::api::cache_slot("andy/hub", "test")];
627
        assert!(now.abs_diff(kept) < 60_000, "kept {kept}, now {now}");
d60aac6c Andy McCall ↶ 628
        // Another bookmark copies it, leaving main's in place.
629
        let feature = Ctx { bookmark: "feature", ..main };
82cf6e91 Andy McCall ↶ 630
        let taken = pool.take(&feature, &m, &key2, &work.path().join("d.ext4")).unwrap();
631
        assert!(taken.how.contains("copied from another bookmark"), "{}", taken.how);
632
        assert_eq!(taken.sources.as_ref(), Some(&built), "a copy reads them and leaves them");
d60aac6c Andy McCall ↶ 633
        assert_eq!(first(&work.path().join("d.ext4")), b"MAIN");
82cf6e91 Andy McCall ↶ 634
        let taken = pool.take(&main, &m, &key2, &work.path().join("e.ext4")).unwrap();
635
        assert!(taken.how.contains("this bookmark's") && taken.sources.is_some(), "main's is still there, with its sources");
d60aac6c Andy McCall ↶ 636
        // Another job or a different size never shares it.
637
        let other_job = Ctx { job: "build", ..main };
82cf6e91 Andy McCall ↶ 638
        assert!(pool.take(&other_job, &m, &key2, &work.path().join("f.ext4")).is_none());
d60aac6c Andy McCall ↶ 639
        let mut bigger = mount(&["Cargo.lock"]);
640
        bigger.size_mib = 2;
82cf6e91 Andy McCall ↶ 641
        assert!(pool.take(&feature, &bigger, &key2, &work.path().join("g.ext4")).is_none());
d60aac6c Andy McCall ↶ 642
    }
643
644
    #[test]
645
    fn the_key_follows_the_image_the_job_and_the_key_files() {
2594278f Andy McCall ↶ 646
        let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1", form: Form::Image };
d60aac6c Andy McCall ↶ 647
        let m = mount(&["Cargo.lock", "rust-toolchain.toml"]);
648
        let k = Pool::key(&ctx, &m, |p| Some(format!("{p}-v1")));
649
        assert_eq!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v1"))));
650
        assert_ne!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v2"))), "a key file changed");
651
        assert_ne!(k, Pool::key(&Ctx { image: "img-2", ..ctx }, &m, |p| Some(format!("{p}-v1"))), "another image");
652
        assert_ne!(k, Pool::key(&ctx, &m, |_| None), "a key file missing");
653
    }
654
655
    #[test]
656
    fn the_pool_drops_the_disks_used_longest_ago() {
657
        let home = tempfile::tempdir().unwrap();
658
        let work = tempfile::tempdir().unwrap();
659
        let pool = Pool { root: home.path().join("cache"), limit: 0 };
660
        let m = mount(&[]);
2594278f Andy McCall ↶ 661
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
d60aac6c Andy McCall ↶ 662
        let full = |p: &Path| {
663
            let f = std::fs::File::create(p).unwrap();
664
            std::os::unix::fs::FileExt::write_at(&f, &vec![7u8; 1 << 20], 0).unwrap();
665
        };
666
        full(&work.path().join("a.ext4"));
667
        // Over a limit of nothing: kept, then dropped at once.
82cf6e91 Andy McCall ↶ 668
        pool.keep(&ctx, &m, "k1", &work.path().join("a.ext4"), &Sources::new()).unwrap();
669
        assert!(pool.take(&ctx, &m, "k1", &work.path().join("b.ext4")).is_none());
d60aac6c Andy McCall ↶ 670
        let roomy = Pool { root: home.path().join("cache"), limit: 1 << 30 };
671
        full(&work.path().join("c.ext4"));
82cf6e91 Andy McCall ↶ 672
        roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4"), &Sources::new()).unwrap();
d60aac6c Andy McCall ↶ 673
        assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays");
2594278f Andy McCall ↶ 674
    }
675
676
    /// A directory as the syscall tier leaves one: `marker` in
677
    /// `debug/marker`, and a file of `kib` KiB.
678
    fn tree(path: &Path, marker: &[u8], kib: usize) {
679
        std::fs::create_dir_all(path.join("debug")).unwrap();
680
        std::fs::write(path.join("debug/marker"), marker).unwrap();
681
        // Not all one byte, so a compressing filesystem stores it whole.
682
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
683
        std::fs::write(path.join("debug/blob"), bytes).unwrap();
684
    }
685
686
    fn dir_ctx<'a>(bookmark: &'a str) -> Ctx<'a> {
687
        Ctx { repo: "andy/flow", bookmark, job: "test", image: "img", form: Form::Dir }
688
    }
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]
693
    fn a_kept_directory_starts_the_next_syscall_job_and_others_copy_it() {
694
        let home = tempfile::tempdir().unwrap();
695
        let work = tempfile::tempdir().unwrap();
696
        let pool = Pool::new(home.path(), None);
697
        let m = mount(&["Cargo.lock"]);
698
        let main = dir_ctx("main");
699
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
700
        let w = |n: &str| work.path().join(n);
701
        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.
703
        tree(&w("a"), b"FIRST", 4);
704
        // Named like an image: the pool never looks inside a directory.
705
        std::fs::write(w("a/debug/inner.ext4"), b"not a disk").unwrap();
706
        let old = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
707
        std::fs::File::open(w("a/debug/marker")).unwrap().set_modified(old).unwrap();
708
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
077a6524 Andy McCall ↶ 709
        pool.settle(&main, &m, &key, &w("a"), &built, true, &keep_all()).unwrap();
2594278f Andy McCall ↶ 710
        assert!(!w("a").exists(), "moved, not copied");
711
        assert_eq!(
712
            pool.slot_times().into_keys().collect::<Vec<_>>(),
713
            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.
718
        let taken = pool.take(&main, &m, &key, &w("b")).unwrap();
719
        assert!(taken.how.contains("this bookmark's, same key"), "{}", taken.how);
720
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
721
        assert_eq!(std::fs::read(w("b/debug/marker")).unwrap(), b"FIRST");
722
        assert_eq!(modified(&w("b/debug/marker")), old, "its files keep their times");
723
        assert_eq!(std::fs::read(w("b/debug/inner.ext4")).unwrap(), b"not a disk");
724
        assert!(pool.take(&main, &m, &key, &w("c")).is_none(), "taken once");
725
        assert!(pool.slot_times().is_empty());
726
        // Kept again, and under a changed key the newest is taken all the same.
727
        std::fs::write(w("b/debug/marker"), b"SECOND").unwrap();
077a6524 Andy McCall ↶ 728
        pool.settle(&main, &m, &key, &w("b"), &built, true, &keep_all()).unwrap();
2594278f Andy McCall ↶ 729
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
730
        let taken = pool.take(&main, &m, &key2, &w("d")).unwrap();
731
        assert!(taken.how.contains("older key"), "{}", taken.how);
732
        assert_eq!(std::fs::read(w("d/debug/marker")).unwrap(), b"SECOND");
077a6524 Andy McCall ↶ 733
        pool.settle(&main, &m, &key2, &w("d"), &built, true, &keep_all()).unwrap();
2594278f Andy McCall ↶ 734
        // Another bookmark copies it, with its times, and main keeps its own.
735
        let feature = dir_ctx("feature");
736
        let taken = pool.take(&feature, &m, &key2, &w("e")).unwrap();
737
        assert!(taken.how.contains("copied from another bookmark's (main-"), "{}", taken.how);
738
        assert_eq!(taken.sources.as_ref(), Some(&built));
739
        assert_eq!(std::fs::read(w("e/debug/marker")).unwrap(), b"SECOND");
740
        assert!(pool.take(&main, &m, &key2, &w("f")).is_some(), "main's is still there");
741
        assert_eq!(modified(&w("e/debug/marker")), modified(&w("f/debug/marker")), "a copy keeps the times");
077a6524 Andy McCall ↶ 742
        pool.settle(&main, &m, &key2, &w("f"), &built, true, &keep_all()).unwrap();
2594278f Andy McCall ↶ 743
        // A microVM job of the same slot never takes a directory, nor a
744
        // syscall job an image.
745
        let image = Ctx { form: Form::Image, ..main };
746
        assert!(pool.take(&image, &m, &key2, &w("g.ext4")).is_none());
747
        disk(&w("h.ext4"), b"DISK");
748
        pool.keep(&image, &m, &key2, &w("h.ext4"), &built).unwrap();
749
        let taken = pool.take(&main, &m, &key2, &w("i")).unwrap();
750
        assert!(w("i/debug/marker").is_file(), "{}", taken.how);
751
        assert!(pool.take(&main, &m, &key2, &w("j")).is_none(), "the image is not a directory");
752
        assert!(pool.take(&image, &m, &key2, &w("k.ext4")).is_some(), "and is still there for a microVM");
753
    }
754
755
    #[test]
756
    fn a_failing_jobs_directory_is_thrown_away() {
757
        let home = tempfile::tempdir().unwrap();
758
        let work = tempfile::tempdir().unwrap();
759
        let pool = Pool::new(home.path(), None);
760
        let m = mount(&[]);
761
        let main = dir_ctx("main");
762
        tree(&work.path().join("a"), b"GOOD", 1);
077a6524 Andy McCall ↶ 763
        pool.settle(&main, &m, "k", &work.path().join("a"), &Sources::new(), true, &keep_all()).unwrap();
2594278f Andy McCall ↶ 764
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
765
        assert!(taken.is_some());
766
        // The job that took it fails after writing to it: not kept, gone.
767
        std::fs::write(work.path().join("b/debug/marker"), b"POISON").unwrap();
077a6524 Andy McCall ↶ 768
        pool.settle(&main, &m, "k", &work.path().join("b"), &Sources::new(), false, &keep_all()).unwrap();
2594278f Andy McCall ↶ 769
        assert!(!work.path().join("b").exists());
770
        assert!(pool.take(&main, &m, "k", &work.path().join("c")).is_none(), "nothing it wrote reaches a later job");
771
        assert!(pool.slot_times().is_empty());
772
        // A failed image is thrown away the same way.
773
        let image = Ctx { form: Form::Image, ..main };
774
        disk(&work.path().join("d.ext4"), b"BAD!");
077a6524 Andy McCall ↶ 775
        pool.settle(&image, &m, "k", &work.path().join("d.ext4"), &Sources::new(), false, &keep_all()).unwrap();
2594278f Andy McCall ↶ 776
        assert!(!work.path().join("d.ext4").exists() && pool.slot_times().is_empty());
777
    }
778
779
    #[test]
780
    fn directories_are_evicted_by_their_size_oldest_first() {
781
        let home = tempfile::tempdir().unwrap();
782
        let work = tempfile::tempdir().unwrap();
783
        // Room for one 512 KiB directory, not two.
784
        let pool = Pool { root: home.path().join("cache"), limit: 800 << 10 };
785
        let m = mount(&[]);
786
        let (main, feature) = (dir_ctx("main"), dir_ctx("feature"));
787
        tree(&work.path().join("a"), b"OLD", 512);
788
        pool.keep(&main, &m, "k1", &work.path().join("a"), &Sources::new()).unwrap();
789
        let kept = pool.cache_dir(&main, "target").join(segment("main")).join("k1.tree");
790
        assert!(kept.is_dir(), "under the limit it stays");
791
        assert!(tree_bytes(&kept) >= 512 << 10, "a directory counts what is in it");
792
        // Older than the next one, however fast this test runs.
793
        std::fs::File::open(&kept).unwrap().set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1)).unwrap();
794
        tree(&work.path().join("b"), b"NEW", 512);
795
        pool.keep(&feature, &m, "k1", &work.path().join("b"), &Sources::new()).unwrap();
796
        assert!(!kept.exists() && !kept.with_extension("tree-sources").exists(), "the one used longest ago goes, sources and all");
797
        let taken = pool.take(&main, &m, "k1", &work.path().join("c")).unwrap();
798
        assert!(taken.how.contains("bookmark's (feature-"), "{}", taken.how);
799
        assert_eq!(std::fs::read(work.path().join("c/debug/marker")).unwrap(), b"NEW");
800
    }
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]
805
    fn nothing_kept_is_writable_by_others_or_set_id() {
806
        let home = tempfile::tempdir().unwrap();
807
        let work = tempfile::tempdir().unwrap();
808
        let pool = Pool::new(home.path(), None);
809
        let m = mount(&[]);
810
        let main = dir_ctx("main");
811
        let a = work.path().join("a");
812
        tree(&a, b"X", 1);
813
        let mode = |p: &Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap();
814
        std::fs::write(a.join("open"), b"").unwrap();
815
        mode(&a.join("open"), 0o777);
816
        std::fs::write(a.join("suid"), b"").unwrap();
817
        mode(&a.join("suid"), 0o4755);
818
        std::fs::write(a.join("debug/sealed"), b"secret").unwrap();
819
        mode(&a.join("debug/sealed"), 0o000);
820
        std::os::unix::fs::symlink("/etc/passwd", a.join("link")).unwrap();
821
        let when = modified(&a.join("debug/marker"));
822
        mode(&a.join("debug"), 0o1777);
823
        mode(&a, 0o500);
824
        pool.keep(&main, &m, "k", &a, &Sources::new()).unwrap();
825
        let root = home.path().join("cache");
826
        assert_eq!(std::fs::metadata(&root).unwrap().mode() & 0o777, 0o700, "the pool is the runner's alone");
827
        let mut seen = 0;
828
        fn each(p: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) {
829
            let md = std::fs::symlink_metadata(p).unwrap();
830
            f(p, &md);
831
            if md.is_dir() {
832
                for e in std::fs::read_dir(p).unwrap() {
833
                    each(&e.unwrap().path(), f);
834
                }
835
            }
836
        }
837
        each(&root, &mut |p, md| {
838
            seen += 1;
839
            if !md.file_type().is_symlink() {
840
                assert_eq!(md.mode() & 0o7022, 0, "{} is {:o}", p.display(), md.mode());
841
            }
842
        });
843
        assert!(seen >= 10, "{seen}");
844
        // Still readable, with its times, so the next job builds on it.
845
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
846
        assert!(taken.is_some());
847
        let b = work.path().join("b");
848
        assert_eq!(std::fs::read(b.join("debug/sealed")).unwrap(), b"secret");
849
        assert_eq!(std::fs::metadata(b.join("open")).unwrap().mode() & 0o777, 0o755);
850
        assert_eq!(std::fs::read_link(b.join("link")).unwrap(), Path::new("/etc/passwd"), "a link is kept, not followed");
851
        assert_eq!(modified(&b.join("debug/marker")), when);
077a6524 Andy McCall ↶ 852
    }
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`]).
856
    fn aged(root: &Path, rel: &str, kib: usize, secs: u64) -> PathBuf {
857
        let p = root.join(rel);
858
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
859
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
860
        std::fs::write(&p, bytes).unwrap();
861
        let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
862
        std::fs::File::options()
863
            .write(true)
864
            .open(&p)
865
            .unwrap()
866
            .set_times(std::fs::FileTimes::new().set_accessed(t).set_modified(t))
867
            .unwrap();
868
        p
869
    }
870
871
    const T: u64 = 4_000_000_000;
872
873
    fn at_t() -> SystemTime {
874
        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(T)
875
    }
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]
881
    fn a_directory_is_pruned_of_what_the_job_did_not_use_before_it_is_kept() {
882
        let home = tempfile::tempdir().unwrap();
883
        let work = tempfile::tempdir().unwrap();
884
        let pool = Pool::new(home.path(), None);
885
        let main = dir_ctx("main");
886
        let mut m = mount(&[]);
887
        m.size_mib = 8;
888
        let a = work.path().join("a");
889
        // This run's build, and an older lockfile's that it did not touch.
890
        aged(&a, "debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib", 2 << 10, T + 10);
891
        aged(&a, "debug/deps/libold-bbbbbbbbbbbbbbbb.rlib", 3 << 10, T - 1_000);
892
        let prune = Prune { started: at_t(), percent: 50, guest: None };
893
        let line = pool.settle(&main, &m, "k", &a, &Sources::new(), true, &prune).unwrap().unwrap();
894
        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
        );
898
        let b = work.path().join("b");
899
        assert!(pool.take(&main, &m, "k", &b).unwrap().how.contains("same key"));
900
        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.
902
        forget_reads(Form::Dir, &b).unwrap();
903
        let md = std::fs::metadata(b.join("debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib")).unwrap();
904
        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.
907
        aged(&b, "debug/deps/libbig-cccccccccccccccc.rlib", 3 << 10, T + 20);
908
        let line = pool.settle(&main, &m, "k", &b, &Sources::new(), true, &prune).unwrap().unwrap();
909
        assert!(
910
            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
        );
913
        assert!(!b.exists() && pool.take(&main, &m, "k", &work.path().join("c")).is_none());
914
    }
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]
919
    fn an_image_is_kept_by_what_its_guest_pruned_or_else_by_its_size() {
920
        let home = tempfile::tempdir().unwrap();
921
        let work = tempfile::tempdir().unwrap();
922
        let pool = Pool::new(home.path(), None);
923
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
924
        let mut m = mount(&[]);
925
        m.size_mib = 16;
926
        let fits = Pruned { size: 16 << 20, percent: 60, before: 12 << 20, after: 6 << 20, freed: 6 << 20, files: 40, unpruned: None };
927
        let over = Pruned { after: 11 << 20, ..fits.clone() };
928
        disk(&work.path().join("a.ext4"), b"KEEP");
929
        let line = pool
930
            .settle(&ctx, &m, "k", &work.path().join("a.ext4"), &Sources::new(), true, &Prune { guest: Some(&fits), ..keep_all() })
931
            .unwrap()
932
            .unwrap();
933
        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
        );
937
        assert!(pool.take(&ctx, &m, "k", &work.path().join("b.ext4")).is_none(), "a 1 MiB disk is not the mount's 16 MiB");
938
        disk(&work.path().join("c.ext4"), b"OVER");
939
        let line = pool
940
            .settle(&ctx, &m, "k", &work.path().join("c.ext4"), &Sources::new(), true, &Prune { guest: Some(&over), ..keep_all() })
941
            .unwrap()
942
            .unwrap();
943
        assert!(line.starts_with("cache target: not kept: 11 MiB after pruning 6 MiB in 40 files"), "{line}");
944
        assert!(!work.path().join("c.ext4").exists());
945
        // No word from the guest: an empty file system is well under.
946
        let img = work.path().join("d.ext4");
947
        crate::workspace::empty_ext4(&img, 16, "scratch").unwrap();
948
        let line = pool.settle(&ctx, &m, "k", &img, &Sources::new(), true, &Prune { percent: 60, ..keep_all() }).unwrap().unwrap();
949
        assert!(line.starts_with("cache target: kept ") && line.contains("under its cap of 60% of its 16 MiB disk"), "{line}");
950
        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.
952
        let line = pool
953
            .settle(&ctx, &m, "k", &work.path().join("e.ext4"), &Sources::new(), true, &Prune { percent: 1, ..keep_all() })
954
            .unwrap()
955
            .unwrap();
956
        assert!(line.contains("not kept") && line.contains("(the guest did not prune it)"), "{line}");
957
    }
958
959
    #[test]
960
    fn the_superblock_says_what_is_in_use() {
961
        let h = "Filesystem volume name:   scratch\nBlock count:              4096\nFree blocks:              1024\nBlock size:               4096\n";
962
        assert_eq!(superblock_usage(h), Some((3072 * 4096, 4096 * 4096)));
963
        assert_eq!(superblock_usage("Block count: 1\n"), None);
d60aac6c Andy McCall ↶ 964
    }
965
}