|
|
… |
17 unchanged lines |
| 18 |
18 |
|
//! receipt names what the job started from. The pool is kept under its |
| 19 |
19 |
|
//! size limit by dropping the disks used longest ago. |
| 20 |
20 |
|
//! |
| 21 |
|
- |
//! Beside each disk, `<key>.sources` lists the sources it was built from |
| 22 |
|
- |
//! (oxygenflow#52): each file's content hash and the mtime it had. A job |
| 23 |
|
- |
//! that takes the disk gives every file whose content is unchanged that |
| 24 |
|
- |
//! mtime back, so a build tool that judges freshness by time (cargo) |
| 25 |
|
- |
//! rebuilds only what changed; a changed file keeps the time of its |
| 26 |
|
- |
//! checkout, newer than anything on the disk, so it is always rebuilt. |
|
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. |
| 27 |
35 |
|
|
| 28 |
36 |
|
use anyhow::{Context, Result}; |
| 29 |
37 |
|
use flow_core::pipeline::Mount; |
| 30 |
|
- |
use std::os::unix::fs::MetadataExt; |
|
38 |
+ |
use std::os::unix::fs::{MetadataExt, PermissionsExt}; |
| 31 |
39 |
|
use std::path::{Path, PathBuf}; |
| 32 |
40 |
|
use std::time::SystemTime; |
| 33 |
41 |
|
|
|
|
… |
10 unchanged lines |
| 44 |
52 |
|
pub how: String, |
| 45 |
53 |
|
/// The sources it was built from, when they were kept with it. |
| 46 |
54 |
|
pub sources: Option<Sources>, |
|
55 |
+ |
} |
|
56 |
+ |
|
|
57 |
+ |
/// How a tier holds a disk mount's files (oxygenflow#70). |
|
58 |
+ |
#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
59 |
+ |
pub enum Form { |
|
60 |
+ |
/// An ext4 image of the mount's size, attached to a microVM. |
|
61 |
+ |
Image, |
|
62 |
+ |
/// A host directory, bound into the syscall sandbox. |
|
63 |
+ |
Dir, |
|
64 |
+ |
} |
|
65 |
+ |
|
|
66 |
+ |
impl Form { |
|
67 |
+ |
fn ext(self) -> &'static str { |
|
68 |
+ |
match self { |
|
69 |
+ |
Form::Image => "ext4", |
|
70 |
+ |
Form::Dir => "tree", |
|
71 |
+ |
} |
|
72 |
+ |
} |
|
73 |
+ |
} |
|
74 |
+ |
|
|
75 |
+ |
/// Which form a kept entry of the pool is in, by its name. |
|
76 |
+ |
fn form_of(p: &Path) -> Option<Form> { |
|
77 |
+ |
match p.extension()?.to_str()? { |
|
78 |
+ |
"ext4" => Some(Form::Image), |
|
79 |
+ |
"tree" => Some(Form::Dir), |
|
80 |
+ |
_ => None, |
|
81 |
+ |
} |
| 47 |
82 |
|
} |
| 48 |
83 |
|
|
| 49 |
84 |
|
fn sources_of(disk: &Path) -> PathBuf { |
| 50 |
|
- |
disk.with_extension("sources") |
|
85 |
+ |
match form_of(disk) { |
|
86 |
+ |
Some(Form::Dir) => disk.with_extension("tree-sources"), |
|
87 |
+ |
_ => disk.with_extension("sources"), |
|
88 |
+ |
} |
| 51 |
89 |
|
} |
| 52 |
90 |
|
|
| 53 |
91 |
|
fn read_sources(disk: &Path) -> Option<Sources> { |
|
|
… |
6 unchanged lines |
| 60 |
98 |
|
limit: u64, |
| 61 |
99 |
|
} |
| 62 |
100 |
|
|
| 63 |
|
- |
/// Which job: the pool's coordinates other than the key. |
|
101 |
+ |
/// Which job: the pool's coordinates other than the key, and the form its |
|
102 |
+ |
/// tier keeps a disk in. |
| 64 |
103 |
|
pub struct Ctx<'a> { |
| 65 |
104 |
|
pub repo: &'a str, |
| 66 |
105 |
|
pub bookmark: &'a str, |
| 67 |
106 |
|
pub job: &'a str, |
| 68 |
107 |
|
pub image: &'a str, |
|
108 |
+ |
pub form: Form, |
| 69 |
109 |
|
} |
| 70 |
110 |
|
|
| 71 |
111 |
|
use flow_core::api::segment; |
|
112 |
+ |
|
|
113 |
+ |
/// Held while the pool changes: jobs run side by side, and a directory, |
|
114 |
+ |
/// unlike an image, can be half moved or half removed while another job |
|
115 |
+ |
/// copies it. |
|
116 |
+ |
static CHANGING: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 72 |
117 |
|
|
|
118 |
+ |
fn changing() -> std::sync::MutexGuard<'static, ()> { |
|
119 |
+ |
CHANGING.lock().unwrap_or_else(|p| p.into_inner()) |
|
120 |
+ |
} |
|
121 |
+ |
|
| 73 |
122 |
|
impl Pool { |
| 74 |
123 |
|
pub fn new(home_root: &Path, gb: Option<u64>) -> Pool { |
| 75 |
124 |
|
Pool { root: home_root.join("cache"), limit: gb.unwrap_or(DEFAULT_GB).saturating_mul(1 << 30) } |
|
|
… |
20 unchanged lines |
| 96 |
145 |
|
h.finalize().to_hex()[..32].to_string() |
| 97 |
146 |
|
} |
| 98 |
147 |
|
|
| 99 |
|
- |
/// Start `dest` from the pool for mount `m`, a disk of `m.size_mib`. |
| 100 |
|
- |
/// What it started from, or `None` (the caller makes an empty disk). |
|
148 |
+ |
/// Start `dest` from the pool for mount `m`: an image of `m.size_mib`, |
|
149 |
+ |
/// or a directory, as `ctx.form` says; `dest` must not exist yet. What |
|
150 |
+ |
/// it started from, or `None` (the caller makes an empty disk). |
|
151 |
+ |
/// |
|
152 |
+ |
/// A disk this job's bookmark kept is moved out of the pool, so a job |
|
153 |
+ |
/// of the same slot running beside this one can never take it too. |
| 101 |
154 |
|
pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<Taken> { |
| 102 |
155 |
|
let name = m.cache.as_deref()?; |
|
156 |
+ |
let _changing = changing(); |
| 103 |
157 |
|
let dir = self.cache_dir(ctx, name); |
| 104 |
158 |
|
let size = m.size_mib as u64 * (1 << 20); |
| 105 |
|
- |
let fits = |p: &Path| std::fs::metadata(p).is_ok_and(|md| md.len() == size); |
|
159 |
+ |
let form = ctx.form; |
|
160 |
+ |
let fits = |p: &Path| match form { |
|
161 |
+ |
Form::Image => std::fs::metadata(p).is_ok_and(|md| md.len() == size), |
|
162 |
+ |
Form::Dir => std::fs::symlink_metadata(p).is_ok_and(|md| md.is_dir()), |
|
163 |
+ |
}; |
|
164 |
+ |
let ext = form.ext(); |
| 106 |
165 |
|
let mine = dir.join(segment(ctx.bookmark)); |
| 107 |
|
- |
let exact = mine.join(format!("{key}.ext4")); |
|
166 |
+ |
let exact = mine.join(format!("{key}.{ext}")); |
| 108 |
167 |
|
// A moved disk takes its sources with it. |
| 109 |
168 |
|
if fits(&exact) { |
| 110 |
169 |
|
let sources = read_sources(&exact); |
|
|
… |
2 unchanged lines |
| 113 |
172 |
|
return Some(Taken { how: format!("cache {name}: this bookmark's, same key"), sources }); |
| 114 |
173 |
|
} |
| 115 |
174 |
|
} |
| 116 |
|
- |
if let Some(p) = newest(&mine, |p| fits(p)) { |
|
175 |
+ |
if let Some(p) = newest(&mine, form, |p| fits(p)) { |
| 117 |
176 |
|
let sources = read_sources(&p); |
| 118 |
177 |
|
if std::fs::rename(&p, dest).is_ok() { |
| 119 |
178 |
|
let _ = std::fs::remove_file(sources_of(&p)); |
|
|
… |
4 unchanged lines |
| 124 |
183 |
|
std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir() && *p != mine).collect(); |
| 125 |
184 |
|
let pick = others |
| 126 |
185 |
|
.iter() |
| 127 |
|
- |
.map(|d| d.join(format!("{key}.ext4"))) |
|
186 |
+ |
.map(|d| d.join(format!("{key}.{ext}"))) |
| 128 |
187 |
|
.filter(|p| fits(p)) |
| 129 |
188 |
|
.max_by_key(|p| modified(p)) |
| 130 |
189 |
|
.map(|p| (p, "same key")) |
| 131 |
|
- |
.or_else(|| others.iter().filter_map(|d| newest(d, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key"))); |
|
190 |
+ |
.or_else(|| { |
|
191 |
+ |
others.iter().filter_map(|d| newest(d, form, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key")) |
|
192 |
+ |
}); |
| 132 |
193 |
|
if let Some((p, which)) = pick { |
| 133 |
194 |
|
let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); |
| 134 |
|
- |
if sparse_copy(&p, dest).is_ok() { |
|
195 |
+ |
if copy(form, &p, dest).is_ok() { |
| 135 |
196 |
|
return Some(Taken { |
| 136 |
197 |
|
how: format!("cache {name}: copied from another bookmark's ({from}), {which}"), |
| 137 |
198 |
|
sources: read_sources(&p), |
|
|
… |
7 unchanged lines |
| 145 |
206 |
|
/// the pool under its limit. |
| 146 |
207 |
|
pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources) -> Result<()> { |
| 147 |
208 |
|
let Some(name) = m.cache.as_deref() else { return Ok(()) }; |
|
209 |
+ |
let _changing = changing(); |
|
210 |
+ |
// Whatever the job left, nothing in the pool is anyone else's to |
|
211 |
+ |
// write or runs as the runner (oxygenflow#70). |
|
212 |
+ |
if ctx.form == Form::Dir { |
|
213 |
+ |
seal(disk).with_context(|| format!("seal {}", disk.display()))?; |
|
214 |
+ |
} |
| 148 |
215 |
|
let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark)); |
| 149 |
|
- |
std::fs::create_dir_all(&mine)?; |
| 150 |
|
- |
let dest = mine.join(format!("{key}.ext4")); |
|
216 |
+ |
std::os::unix::fs::DirBuilderExt::mode(std::fs::DirBuilder::new().recursive(true), 0o700).create(&mine)?; |
|
217 |
+ |
std::fs::set_permissions(&self.root, std::fs::Permissions::from_mode(0o700))?; |
|
218 |
+ |
let dest = mine.join(format!("{key}.{}", ctx.form.ext())); |
|
219 |
+ |
if ctx.form == Form::Dir { |
|
220 |
+ |
// Another job of this slot kept one since: this one is newer. |
|
221 |
+ |
crate::workspace::remove_tree(&dest)?; |
|
222 |
+ |
} |
| 151 |
223 |
|
if std::fs::rename(disk, &dest).is_err() { |
| 152 |
|
- |
sparse_copy(disk, &dest).with_context(|| format!("keep {}", disk.display()))?; |
|
224 |
+ |
copy(ctx.form, disk, &dest).with_context(|| format!("keep {}", disk.display()))?; |
| 153 |
225 |
|
} |
| 154 |
226 |
|
// Moved disks keep their old time: mark it used now. |
| 155 |
|
- |
std::fs::File::options().write(true).open(&dest)?.set_modified(SystemTime::now())?; |
| 156 |
|
- |
let tmp = sources_of(&dest).with_extension("sources-part"); |
| 157 |
|
- |
std::fs::write(&tmp, serde_json::to_vec(sources)?)?; |
| 158 |
|
- |
std::fs::rename(&tmp, sources_of(&dest))?; |
|
227 |
+ |
std::fs::File::open(&dest)?.set_modified(SystemTime::now())?; |
|
228 |
+ |
let sidecar = sources_of(&dest); |
|
229 |
+ |
let tmp = sidecar.with_extension("sources-part"); |
|
230 |
+ |
let _ = std::fs::remove_file(&tmp); |
|
231 |
+ |
{ |
|
232 |
+ |
use std::io::Write; |
|
233 |
+ |
use std::os::unix::fs::OpenOptionsExt; |
|
234 |
+ |
std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&tmp)?.write_all(&serde_json::to_vec(sources)?)?; |
|
235 |
+ |
} |
|
236 |
+ |
std::fs::rename(&tmp, sidecar)?; |
| 159 |
237 |
|
// One disk per key is enough; older keys of this bookmark go once |
| 160 |
238 |
|
// the pool is over its limit. |
| 161 |
239 |
|
self.evict(); |
| 162 |
240 |
|
Ok(()) |
|
241 |
+ |
} |
|
242 |
+ |
|
|
243 |
+ |
/// After the job: a passing job's disk is kept; a failing one's is |
|
244 |
+ |
/// thrown away, so nothing it wrote reaches a later job. |
|
245 |
+ |
pub fn settle(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources, passed: bool) -> Result<()> { |
|
246 |
+ |
if passed { |
|
247 |
+ |
return self.keep(ctx, m, key, disk, sources); |
|
248 |
+ |
} |
|
249 |
+ |
let gone = match ctx.form { |
|
250 |
+ |
Form::Dir => crate::workspace::remove_tree(disk), |
|
251 |
+ |
Form::Image => std::fs::remove_file(disk).or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }), |
|
252 |
+ |
}; |
|
253 |
+ |
gone.with_context(|| format!("discard {}", disk.display())) |
| 163 |
254 |
|
} |
| 164 |
255 |
|
|
| 165 |
256 |
|
/// The repository and job slots holding at least one disk, as |
| 166 |
257 |
|
/// [`flow_core::api::cache_slot`] names them, for the claim |
| 167 |
258 |
|
/// (oxygenflow#51), each with when its newest disk was kept (Unix ms): |
| 168 |
259 |
|
/// a disk's time is set when it is kept, so the newest is the freshest |
| 169 |
|
- |
/// build (oxygenflow#54). |
|
260 |
+ |
/// build (oxygenflow#54). Images and directories alike, so a job goes |
|
261 |
+ |
/// where its cache is whichever tier keeps it. |
| 170 |
262 |
|
pub fn slot_times(&self) -> std::collections::BTreeMap<String, u64> { |
| 171 |
263 |
|
let mut out = std::collections::BTreeMap::new(); |
| 172 |
264 |
|
for repo in std::fs::read_dir(&self.root).into_iter().flatten().flatten() { |
| 173 |
265 |
|
for job in std::fs::read_dir(repo.path()).into_iter().flatten().flatten() { |
| 174 |
266 |
|
let mut newest: Option<SystemTime> = None; |
| 175 |
|
- |
walk(&job.path(), &mut |p| { |
| 176 |
|
- |
if p.extension().is_some_and(|e| e == "ext4") { |
|
267 |
+ |
// `<job>/<cache>/<bookmark>/<disk>` |
|
268 |
+ |
kept(&job.path(), 2, &mut |p, _| { |
| 177 |
269 |
|
let t = modified(p); |
| 178 |
270 |
|
newest = Some(newest.map_or(t, |n| n.max(t))); |
| 179 |
|
- |
} |
| 180 |
271 |
|
}); |
| 181 |
272 |
|
if let Some(t) = newest { |
| 182 |
273 |
|
let ms = t.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); |
|
|
… |
4 unchanged lines |
| 187 |
278 |
|
out |
| 188 |
279 |
|
} |
| 189 |
280 |
|
|
| 190 |
|
- |
/// Drop the disks used longest ago until the pool's blocks fit. |
| 191 |
|
- |
pub fn evict(&self) -> u64 { |
|
281 |
+ |
/// Drop the disks used longest ago until the pool's blocks fit: an |
|
282 |
+ |
/// image's blocks, or everything in a directory. The caller holds |
|
283 |
+ |
/// [`changing`]. |
|
284 |
+ |
fn evict(&self) -> u64 { |
| 192 |
285 |
|
let mut disks: Vec<(SystemTime, u64, PathBuf)> = Vec::new(); |
| 193 |
|
- |
walk(&self.root, &mut |p| { |
| 194 |
|
- |
if p.extension().is_some_and(|e| e == "ext4") { |
| 195 |
|
- |
if let Ok(md) = std::fs::metadata(p) { |
| 196 |
|
- |
disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), md.blocks() * 512, p.to_path_buf())); |
| 197 |
|
- |
} |
|
286 |
+ |
// `<repo>/<job>/<cache>/<bookmark>/<disk>` |
|
287 |
+ |
kept(&self.root, 4, &mut |p, form| { |
|
288 |
+ |
if let Ok(md) = std::fs::symlink_metadata(p) { |
|
289 |
+ |
let bytes = match form { |
|
290 |
+ |
Form::Image => md.blocks() * 512, |
|
291 |
+ |
Form::Dir => tree_bytes(p), |
|
292 |
+ |
}; |
|
293 |
+ |
disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), bytes, p.to_path_buf())); |
| 198 |
294 |
|
} |
| 199 |
295 |
|
}); |
| 200 |
296 |
|
let mut total: u64 = disks.iter().map(|d| d.1).sum(); |
|
|
… |
3 unchanged lines |
| 204 |
300 |
|
if total <= self.limit { |
| 205 |
301 |
|
break; |
| 206 |
302 |
|
} |
| 207 |
|
- |
if std::fs::remove_file(&p).is_ok() { |
|
303 |
+ |
let gone = match form_of(&p) { |
|
304 |
+ |
Some(Form::Dir) => crate::workspace::remove_tree(&p), |
|
305 |
+ |
_ => std::fs::remove_file(&p), |
|
306 |
+ |
}; |
|
307 |
+ |
if gone.is_ok() { |
| 208 |
308 |
|
let _ = std::fs::remove_file(sources_of(&p)); |
| 209 |
309 |
|
total -= bytes; |
| 210 |
310 |
|
freed += bytes; |
|
|
… |
3 unchanged lines |
| 214 |
314 |
|
} |
| 215 |
315 |
|
} |
| 216 |
316 |
|
|
|
317 |
+ |
/// Where a job's `n`th disk mount is in its scratch directory, in the form |
|
318 |
+ |
/// its tier mounts it: `microvm::run` attaches `mount-N.ext4`, and |
|
319 |
+ |
/// `syscall::run` binds the directory `mount-N`. |
|
320 |
+ |
pub fn disk_path(form: Form, scratch: &Path, n: usize) -> PathBuf { |
|
321 |
+ |
match form { |
|
322 |
+ |
Form::Image => scratch.join(format!("mount-{n}.ext4")), |
|
323 |
+ |
Form::Dir => crate::syscall::disk_dir(scratch, n), |
|
324 |
+ |
} |
|
325 |
+ |
} |
|
326 |
+ |
|
| 217 |
327 |
|
fn modified(p: &Path) -> SystemTime { |
| 218 |
|
- |
std::fs::metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH) |
|
328 |
+ |
std::fs::symlink_metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH) |
| 219 |
329 |
|
} |
| 220 |
330 |
|
|
| 221 |
|
- |
fn newest(dir: &Path, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> { |
| 222 |
|
- |
std::fs::read_dir(dir) |
| 223 |
|
- |
.ok()? |
| 224 |
|
- |
.flatten() |
| 225 |
|
- |
.map(|e| e.path()) |
| 226 |
|
- |
.filter(|p| p.extension().is_some_and(|e| e == "ext4") && ok(p)) |
| 227 |
|
- |
.max_by_key(|p| modified(p)) |
|
331 |
+ |
fn newest(dir: &Path, form: Form, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> { |
|
332 |
+ |
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)) |
| 228 |
333 |
|
} |
| 229 |
334 |
|
|
| 230 |
|
- |
fn walk(dir: &Path, f: &mut dyn FnMut(&Path)) { |
|
335 |
+ |
/// Each kept image or directory `depth` levels of directories below `dir`, |
|
336 |
+ |
/// with its form; never inside a kept directory. |
|
337 |
+ |
fn kept(dir: &Path, depth: usize, f: &mut dyn FnMut(&Path, Form)) { |
| 231 |
338 |
|
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { |
| 232 |
339 |
|
let p = e.path(); |
| 233 |
|
- |
if p.is_dir() { |
| 234 |
|
- |
walk(&p, f); |
| 235 |
|
- |
} else { |
| 236 |
|
- |
f(&p); |
|
340 |
+ |
let Ok(t) = e.file_type() else { continue }; |
|
341 |
+ |
if depth == 0 { |
|
342 |
+ |
match form_of(&p) { |
|
343 |
+ |
Some(Form::Image) if t.is_file() => f(&p, Form::Image), |
|
344 |
+ |
Some(Form::Dir) if t.is_dir() => f(&p, Form::Dir), |
|
345 |
+ |
_ => {} |
|
346 |
+ |
} |
|
347 |
+ |
} else if t.is_dir() { |
|
348 |
+ |
kept(&p, depth - 1, f); |
|
349 |
+ |
} |
|
350 |
+ |
} |
|
351 |
+ |
} |
|
352 |
+ |
|
|
353 |
+ |
/// The blocks a directory's files take, each file once however many links |
|
354 |
+ |
/// it has; links are not followed. |
|
355 |
+ |
fn tree_bytes(dir: &Path) -> u64 { |
|
356 |
+ |
fn walk(p: &Path, seen: &mut std::collections::HashSet<(u64, u64)>) -> u64 { |
|
357 |
+ |
let Ok(md) = std::fs::symlink_metadata(p) else { return 0 }; |
|
358 |
+ |
if md.nlink() > 1 && !md.is_dir() && !seen.insert((md.dev(), md.ino())) { |
|
359 |
+ |
return 0; |
|
360 |
+ |
} |
|
361 |
+ |
let mut bytes = md.blocks() * 512; |
|
362 |
+ |
if md.is_dir() { |
|
363 |
+ |
for e in std::fs::read_dir(p).into_iter().flatten().flatten() { |
|
364 |
+ |
bytes += walk(&e.path(), seen); |
|
365 |
+ |
} |
|
366 |
+ |
} |
|
367 |
+ |
bytes |
|
368 |
+ |
} |
|
369 |
+ |
walk(dir, &mut Default::default()) |
|
370 |
+ |
} |
|
371 |
+ |
|
|
372 |
+ |
/// Make a directory a job left fit for the pool: nothing in it writable by |
|
373 |
+ |
/// group or others, nothing set-id or sticky, and everything readable by |
|
374 |
+ |
/// the runner (directories searchable and writable too, so it can be |
|
375 |
+ |
/// copied and removed). Links are not followed. The job ran as the |
|
376 |
+ |
/// runner's user, so all of it is the runner's to change; times are kept. |
|
377 |
+ |
fn seal(p: &Path) -> std::io::Result<()> { |
|
378 |
+ |
let md = std::fs::symlink_metadata(p)?; |
|
379 |
+ |
if md.file_type().is_symlink() { |
|
380 |
+ |
return Ok(()); |
|
381 |
+ |
} |
|
382 |
+ |
let mode = md.mode() & 0o7777; |
|
383 |
+ |
let want = (mode & !0o7022) | if md.is_dir() { 0o700 } else { 0o400 }; |
|
384 |
+ |
if want != mode { |
|
385 |
+ |
std::fs::set_permissions(p, std::fs::Permissions::from_mode(want))?; |
|
386 |
+ |
} |
|
387 |
+ |
if md.is_dir() { |
|
388 |
+ |
for e in std::fs::read_dir(p)? { |
|
389 |
+ |
seal(&e?.path())?; |
| 237 |
390 |
|
} |
|
391 |
+ |
} |
|
392 |
+ |
Ok(()) |
|
393 |
+ |
} |
|
394 |
+ |
|
|
395 |
+ |
fn copy(form: Form, from: &Path, to: &Path) -> Result<()> { |
|
396 |
+ |
match form { |
|
397 |
+ |
Form::Image => sparse_copy(from, to), |
|
398 |
+ |
Form::Dir => tree_copy(from, to), |
| 238 |
399 |
|
} |
| 239 |
400 |
|
} |
| 240 |
401 |
|
|
|
|
… |
9 unchanged lines |
| 250 |
411 |
|
Ok(()) |
| 251 |
412 |
|
} |
| 252 |
413 |
|
|
|
414 |
+ |
/// Copy a directory with its modes and times (a build tool judges by |
|
415 |
+ |
/// them), sharing blocks where the filesystem can. |
|
416 |
+ |
fn tree_copy(from: &Path, to: &Path) -> Result<()> { |
|
417 |
+ |
let tmp = to.with_extension("tree-part"); |
|
418 |
+ |
crate::workspace::remove_tree(&tmp)?; |
|
419 |
+ |
let st = std::process::Command::new("cp") |
|
420 |
+ |
.args(["-a", "--reflink=auto", "--no-target-directory"]) |
|
421 |
+ |
.arg(from) |
|
422 |
+ |
.arg(&tmp) |
|
423 |
+ |
.status() |
|
424 |
+ |
.context("run cp")?; |
|
425 |
+ |
if !st.success() { |
|
426 |
+ |
let _ = crate::workspace::remove_tree(&tmp); |
|
427 |
+ |
anyhow::bail!("cp {} failed", from.display()); |
|
428 |
+ |
} |
|
429 |
+ |
std::fs::rename(&tmp, to).inspect_err(|_| { |
|
430 |
+ |
let _ = crate::workspace::remove_tree(&tmp); |
|
431 |
+ |
})?; |
|
432 |
+ |
Ok(()) |
|
433 |
+ |
} |
|
434 |
+ |
|
| 253 |
435 |
|
#[cfg(test)] |
| 254 |
436 |
|
mod tests { |
| 255 |
437 |
|
use super::*; |
|
|
… |
25 unchanged lines |
| 281 |
463 |
|
let work = tempfile::tempdir().unwrap(); |
| 282 |
464 |
|
let pool = Pool::new(home.path(), None); |
| 283 |
465 |
|
let m = mount(&["Cargo.lock"]); |
| 284 |
|
- |
let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img" }; |
|
466 |
+ |
let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img", form: Form::Image }; |
| 285 |
467 |
|
let key = Pool::key(&main, &m, |_| Some("lock-1".into())); |
| 286 |
468 |
|
// Nothing yet: the caller makes an empty disk. |
| 287 |
469 |
|
assert!(pool.take(&main, &m, &key, &work.path().join("a.ext4")).is_none()); |
|
|
… |
41 unchanged lines |
| 329 |
511 |
|
|
| 330 |
512 |
|
#[test] |
| 331 |
513 |
|
fn the_key_follows_the_image_the_job_and_the_key_files() { |
| 332 |
|
- |
let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1" }; |
|
514 |
+ |
let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1", form: Form::Image }; |
| 333 |
515 |
|
let m = mount(&["Cargo.lock", "rust-toolchain.toml"]); |
| 334 |
516 |
|
let k = Pool::key(&ctx, &m, |p| Some(format!("{p}-v1"))); |
| 335 |
517 |
|
assert_eq!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v1")))); |
|
|
… |
8 unchanged lines |
| 344 |
526 |
|
let work = tempfile::tempdir().unwrap(); |
| 345 |
527 |
|
let pool = Pool { root: home.path().join("cache"), limit: 0 }; |
| 346 |
528 |
|
let m = mount(&[]); |
| 347 |
|
- |
let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i" }; |
|
529 |
+ |
let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image }; |
| 348 |
530 |
|
let full = |p: &Path| { |
| 349 |
531 |
|
let f = std::fs::File::create(p).unwrap(); |
| 350 |
532 |
|
std::os::unix::fs::FileExt::write_at(&f, &vec![7u8; 1 << 20], 0).unwrap(); |
|
|
… |
6 unchanged lines |
| 357 |
539 |
|
full(&work.path().join("c.ext4")); |
| 358 |
540 |
|
roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4"), &Sources::new()).unwrap(); |
| 359 |
541 |
|
assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays"); |
|
542 |
+ |
} |
|
543 |
+ |
|
|
544 |
+ |
/// A directory as the syscall tier leaves one: `marker` in |
|
545 |
+ |
/// `debug/marker`, and a file of `kib` KiB. |
|
546 |
+ |
fn tree(path: &Path, marker: &[u8], kib: usize) { |
|
547 |
+ |
std::fs::create_dir_all(path.join("debug")).unwrap(); |
|
548 |
+ |
std::fs::write(path.join("debug/marker"), marker).unwrap(); |
|
549 |
+ |
// Not all one byte, so a compressing filesystem stores it whole. |
|
550 |
+ |
let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); |
|
551 |
+ |
std::fs::write(path.join("debug/blob"), bytes).unwrap(); |
|
552 |
+ |
} |
|
553 |
+ |
|
|
554 |
+ |
fn dir_ctx<'a>(bookmark: &'a str) -> Ctx<'a> { |
|
555 |
+ |
Ctx { repo: "andy/flow", bookmark, job: "test", image: "img", form: Form::Dir } |
|
556 |
+ |
} |
|
557 |
+ |
|
|
558 |
+ |
/// oxygenflow#70: the syscall tier's directories are taken and kept as |
|
559 |
+ |
/// a microVM's images are, and never mixed with them. |
|
560 |
+ |
#[test] |
|
561 |
+ |
fn a_kept_directory_starts_the_next_syscall_job_and_others_copy_it() { |
|
562 |
+ |
let home = tempfile::tempdir().unwrap(); |
|
563 |
+ |
let work = tempfile::tempdir().unwrap(); |
|
564 |
+ |
let pool = Pool::new(home.path(), None); |
|
565 |
+ |
let m = mount(&["Cargo.lock"]); |
|
566 |
+ |
let main = dir_ctx("main"); |
|
567 |
+ |
let key = Pool::key(&main, &m, |_| Some("lock-1".into())); |
|
568 |
+ |
let w = |n: &str| work.path().join(n); |
|
569 |
+ |
assert!(pool.take(&main, &m, &key, &w("a")).is_none(), "nothing kept yet"); |
|
570 |
+ |
// A passing job's directory goes into the pool, times and all. |
|
571 |
+ |
tree(&w("a"), b"FIRST", 4); |
|
572 |
+ |
// Named like an image: the pool never looks inside a directory. |
|
573 |
+ |
std::fs::write(w("a/debug/inner.ext4"), b"not a disk").unwrap(); |
|
574 |
+ |
let old = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000); |
|
575 |
+ |
std::fs::File::open(w("a/debug/marker")).unwrap().set_modified(old).unwrap(); |
|
576 |
+ |
let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into(); |
|
577 |
+ |
pool.settle(&main, &m, &key, &w("a"), &built, true).unwrap(); |
|
578 |
+ |
assert!(!w("a").exists(), "moved, not copied"); |
|
579 |
+ |
assert_eq!( |
|
580 |
+ |
pool.slot_times().into_keys().collect::<Vec<_>>(), |
|
581 |
+ |
vec![flow_core::api::cache_slot("andy/flow", "test")], |
|
582 |
+ |
"the claim reports it like an image (#51)" |
|
583 |
+ |
); |
|
584 |
+ |
// The next job of its bookmark moves it out: a job of the same |
|
585 |
+ |
// slot beside it finds nothing to take. |
|
586 |
+ |
let taken = pool.take(&main, &m, &key, &w("b")).unwrap(); |
|
587 |
+ |
assert!(taken.how.contains("this bookmark's, same key"), "{}", taken.how); |
|
588 |
+ |
assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)"); |
|
589 |
+ |
assert_eq!(std::fs::read(w("b/debug/marker")).unwrap(), b"FIRST"); |
|
590 |
+ |
assert_eq!(modified(&w("b/debug/marker")), old, "its files keep their times"); |
|
591 |
+ |
assert_eq!(std::fs::read(w("b/debug/inner.ext4")).unwrap(), b"not a disk"); |
|
592 |
+ |
assert!(pool.take(&main, &m, &key, &w("c")).is_none(), "taken once"); |
|
593 |
+ |
assert!(pool.slot_times().is_empty()); |
|
594 |
+ |
// Kept again, and under a changed key the newest is taken all the same. |
|
595 |
+ |
std::fs::write(w("b/debug/marker"), b"SECOND").unwrap(); |
|
596 |
+ |
pool.settle(&main, &m, &key, &w("b"), &built, true).unwrap(); |
|
597 |
+ |
let key2 = Pool::key(&main, &m, |_| Some("lock-2".into())); |
|
598 |
+ |
let taken = pool.take(&main, &m, &key2, &w("d")).unwrap(); |
|
599 |
+ |
assert!(taken.how.contains("older key"), "{}", taken.how); |
|
600 |
+ |
assert_eq!(std::fs::read(w("d/debug/marker")).unwrap(), b"SECOND"); |
|
601 |
+ |
pool.settle(&main, &m, &key2, &w("d"), &built, true).unwrap(); |
|
602 |
+ |
// Another bookmark copies it, with its times, and main keeps its own. |
|
603 |
+ |
let feature = dir_ctx("feature"); |
|
604 |
+ |
let taken = pool.take(&feature, &m, &key2, &w("e")).unwrap(); |
|
605 |
+ |
assert!(taken.how.contains("copied from another bookmark's (main-"), "{}", taken.how); |
|
606 |
+ |
assert_eq!(taken.sources.as_ref(), Some(&built)); |
|
607 |
+ |
assert_eq!(std::fs::read(w("e/debug/marker")).unwrap(), b"SECOND"); |
|
608 |
+ |
assert!(pool.take(&main, &m, &key2, &w("f")).is_some(), "main's is still there"); |
|
609 |
+ |
assert_eq!(modified(&w("e/debug/marker")), modified(&w("f/debug/marker")), "a copy keeps the times"); |
|
610 |
+ |
pool.settle(&main, &m, &key2, &w("f"), &built, true).unwrap(); |
|
611 |
+ |
// A microVM job of the same slot never takes a directory, nor a |
|
612 |
+ |
// syscall job an image. |
|
613 |
+ |
let image = Ctx { form: Form::Image, ..main }; |
|
614 |
+ |
assert!(pool.take(&image, &m, &key2, &w("g.ext4")).is_none()); |
|
615 |
+ |
disk(&w("h.ext4"), b"DISK"); |
|
616 |
+ |
pool.keep(&image, &m, &key2, &w("h.ext4"), &built).unwrap(); |
|
617 |
+ |
let taken = pool.take(&main, &m, &key2, &w("i")).unwrap(); |
|
618 |
+ |
assert!(w("i/debug/marker").is_file(), "{}", taken.how); |
|
619 |
+ |
assert!(pool.take(&main, &m, &key2, &w("j")).is_none(), "the image is not a directory"); |
|
620 |
+ |
assert!(pool.take(&image, &m, &key2, &w("k.ext4")).is_some(), "and is still there for a microVM"); |
|
621 |
+ |
} |
|
622 |
+ |
|
|
623 |
+ |
#[test] |
|
624 |
+ |
fn a_failing_jobs_directory_is_thrown_away() { |
|
625 |
+ |
let home = tempfile::tempdir().unwrap(); |
|
626 |
+ |
let work = tempfile::tempdir().unwrap(); |
|
627 |
+ |
let pool = Pool::new(home.path(), None); |
|
628 |
+ |
let m = mount(&[]); |
|
629 |
+ |
let main = dir_ctx("main"); |
|
630 |
+ |
tree(&work.path().join("a"), b"GOOD", 1); |
|
631 |
+ |
pool.settle(&main, &m, "k", &work.path().join("a"), &Sources::new(), true).unwrap(); |
|
632 |
+ |
let taken = pool.take(&main, &m, "k", &work.path().join("b")); |
|
633 |
+ |
assert!(taken.is_some()); |
|
634 |
+ |
// The job that took it fails after writing to it: not kept, gone. |
|
635 |
+ |
std::fs::write(work.path().join("b/debug/marker"), b"POISON").unwrap(); |
|
636 |
+ |
pool.settle(&main, &m, "k", &work.path().join("b"), &Sources::new(), false).unwrap(); |
|
637 |
+ |
assert!(!work.path().join("b").exists()); |
|
638 |
+ |
assert!(pool.take(&main, &m, "k", &work.path().join("c")).is_none(), "nothing it wrote reaches a later job"); |
|
639 |
+ |
assert!(pool.slot_times().is_empty()); |
|
640 |
+ |
// A failed image is thrown away the same way. |
|
641 |
+ |
let image = Ctx { form: Form::Image, ..main }; |
|
642 |
+ |
disk(&work.path().join("d.ext4"), b"BAD!"); |
|
643 |
+ |
pool.settle(&image, &m, "k", &work.path().join("d.ext4"), &Sources::new(), false).unwrap(); |
|
644 |
+ |
assert!(!work.path().join("d.ext4").exists() && pool.slot_times().is_empty()); |
|
645 |
+ |
} |
|
646 |
+ |
|
|
647 |
+ |
#[test] |
|
648 |
+ |
fn directories_are_evicted_by_their_size_oldest_first() { |
|
649 |
+ |
let home = tempfile::tempdir().unwrap(); |
|
650 |
+ |
let work = tempfile::tempdir().unwrap(); |
|
651 |
+ |
// Room for one 512 KiB directory, not two. |
|
652 |
+ |
let pool = Pool { root: home.path().join("cache"), limit: 800 << 10 }; |
|
653 |
+ |
let m = mount(&[]); |
|
654 |
+ |
let (main, feature) = (dir_ctx("main"), dir_ctx("feature")); |
|
655 |
+ |
tree(&work.path().join("a"), b"OLD", 512); |
|
656 |
+ |
pool.keep(&main, &m, "k1", &work.path().join("a"), &Sources::new()).unwrap(); |
|
657 |
+ |
let kept = pool.cache_dir(&main, "target").join(segment("main")).join("k1.tree"); |
|
658 |
+ |
assert!(kept.is_dir(), "under the limit it stays"); |
|
659 |
+ |
assert!(tree_bytes(&kept) >= 512 << 10, "a directory counts what is in it"); |
|
660 |
+ |
// Older than the next one, however fast this test runs. |
|
661 |
+ |
std::fs::File::open(&kept).unwrap().set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1)).unwrap(); |
|
662 |
+ |
tree(&work.path().join("b"), b"NEW", 512); |
|
663 |
+ |
pool.keep(&feature, &m, "k1", &work.path().join("b"), &Sources::new()).unwrap(); |
|
664 |
+ |
assert!(!kept.exists() && !kept.with_extension("tree-sources").exists(), "the one used longest ago goes, sources and all"); |
|
665 |
+ |
let taken = pool.take(&main, &m, "k1", &work.path().join("c")).unwrap(); |
|
666 |
+ |
assert!(taken.how.contains("bookmark's (feature-"), "{}", taken.how); |
|
667 |
+ |
assert_eq!(std::fs::read(work.path().join("c/debug/marker")).unwrap(), b"NEW"); |
|
668 |
+ |
} |
|
669 |
+ |
|
|
670 |
+ |
/// oxygenflow#70: the job wrote what it liked; the pool keeps nothing |
|
671 |
+ |
/// others may write or that runs as the runner, and is the runner's. |
|
672 |
+ |
#[test] |
|
673 |
+ |
fn nothing_kept_is_writable_by_others_or_set_id() { |
|
674 |
+ |
let home = tempfile::tempdir().unwrap(); |
|
675 |
+ |
let work = tempfile::tempdir().unwrap(); |
|
676 |
+ |
let pool = Pool::new(home.path(), None); |
|
677 |
+ |
let m = mount(&[]); |
|
678 |
+ |
let main = dir_ctx("main"); |
|
679 |
+ |
let a = work.path().join("a"); |
|
680 |
+ |
tree(&a, b"X", 1); |
|
681 |
+ |
let mode = |p: &Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap(); |
|
682 |
+ |
std::fs::write(a.join("open"), b"").unwrap(); |
|
683 |
+ |
mode(&a.join("open"), 0o777); |
|
684 |
+ |
std::fs::write(a.join("suid"), b"").unwrap(); |
|
685 |
+ |
mode(&a.join("suid"), 0o4755); |
|
686 |
+ |
std::fs::write(a.join("debug/sealed"), b"secret").unwrap(); |
|
687 |
+ |
mode(&a.join("debug/sealed"), 0o000); |
|
688 |
+ |
std::os::unix::fs::symlink("/etc/passwd", a.join("link")).unwrap(); |
|
689 |
+ |
let when = modified(&a.join("debug/marker")); |
|
690 |
+ |
mode(&a.join("debug"), 0o1777); |
|
691 |
+ |
mode(&a, 0o500); |
|
692 |
+ |
pool.keep(&main, &m, "k", &a, &Sources::new()).unwrap(); |
|
693 |
+ |
let root = home.path().join("cache"); |
|
694 |
+ |
assert_eq!(std::fs::metadata(&root).unwrap().mode() & 0o777, 0o700, "the pool is the runner's alone"); |
|
695 |
+ |
let mut seen = 0; |
|
696 |
+ |
fn each(p: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) { |
|
697 |
+ |
let md = std::fs::symlink_metadata(p).unwrap(); |
|
698 |
+ |
f(p, &md); |
|
699 |
+ |
if md.is_dir() { |
|
700 |
+ |
for e in std::fs::read_dir(p).unwrap() { |
|
701 |
+ |
each(&e.unwrap().path(), f); |
|
702 |
+ |
} |
|
703 |
+ |
} |
|
704 |
+ |
} |
|
705 |
+ |
each(&root, &mut |p, md| { |
|
706 |
+ |
seen += 1; |
|
707 |
+ |
if !md.file_type().is_symlink() { |
|
708 |
+ |
assert_eq!(md.mode() & 0o7022, 0, "{} is {:o}", p.display(), md.mode()); |
|
709 |
+ |
} |
|
710 |
+ |
}); |
|
711 |
+ |
assert!(seen >= 10, "{seen}"); |
|
712 |
+ |
// Still readable, with its times, so the next job builds on it. |
|
713 |
+ |
let taken = pool.take(&main, &m, "k", &work.path().join("b")); |
|
714 |
+ |
assert!(taken.is_some()); |
|
715 |
+ |
let b = work.path().join("b"); |
|
716 |
+ |
assert_eq!(std::fs::read(b.join("debug/sealed")).unwrap(), b"secret"); |
|
717 |
+ |
assert_eq!(std::fs::metadata(b.join("open")).unwrap().mode() & 0o777, 0o755); |
|
718 |
+ |
assert_eq!(std::fs::read_link(b.join("link")).unwrap(), Path::new("/etc/passwd"), "a link is kept, not followed"); |
|
719 |
+ |
assert_eq!(modified(&b.join("debug/marker")), when); |
| 360 |
720 |
|
} |
| 361 |
721 |
|
} |