//! A job's workspace: the files of the state being built, written to a //! scratch directory and packed into an ext4 image for the microVM //! (docs/m0.md ยง2). Returns each file's content hash for the scope hash. use anyhow::{bail, Context, Result}; use flow_core::Hash; use oxy_core::hash::ChangeHash; use oxy_core::objects::NodeKind; use oxy_core::repo::Repo; use oxy_core::state::FileContent; use std::path::Path; pub type Files = Vec<(String, Hash)>; /// Write the state `heads` of `repo` under `dir`. pub fn export_state(repo: &Repo, heads: &[ChangeHash], dir: &Path) -> Result { let snapshot = repo.load_state(heads).context("load the state")?.materialize(); let mut files = Vec::new(); for (path, f) in &snapshot.files { if path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") { continue; } let target = dir.join(path); match (&f.kind, &f.content) { (NodeKind::Dir, _) | (_, FileContent::Dir) => std::fs::create_dir_all(&target)?, (NodeKind::Subrepo, _) => {} (kind, content) => { let bytes = match content { FileContent::Text { bytes, .. } => bytes.clone(), FileContent::Opaque { blobs } => match blobs.first() { Some(b) => repo.read_blob(b)?, None => Vec::new(), }, FileContent::Dir => unreachable!(), }; if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } if *kind == NodeKind::Symlink { let to = String::from_utf8_lossy(&bytes).into_owned(); crate::portable::symlink(Path::new(&to), &target).with_context(|| format!("symlink {path}"))?; } else { std::fs::write(&target, &bytes).with_context(|| format!("write {path}"))?; if f.mode.exec { crate::portable::set_mode(&target, 0o755)?; } } files.push((path.clone(), *blake3::hash(&bytes).as_bytes())); } } } Ok(files) } /// Copy a working directory (without `.oxy` and what `.oxyignore` and /// `.gitignore` exclude) under `dir`, for `flow run`. pub fn copy_tree(src: &Path, dir: &Path) -> Result { let mut files = Vec::new(); // A file at the top would otherwise find no directory to go in. std::fs::create_dir_all(dir)?; let walk = ignore::WalkBuilder::new(src) .hidden(false) .git_ignore(true) .require_git(false) .add_custom_ignore_filename(".oxyignore") .filter_entry(|e| e.file_name() != ".oxy" && e.file_name() != ".git") .build(); for entry in walk { let entry = entry?; let rel = entry.path().strip_prefix(src)?; if rel.as_os_str().is_empty() { continue; } let rel_s = rel.to_string_lossy().replace('\\', "/"); let target = dir.join(rel); let ft = entry.file_type().context("file type")?; if ft.is_dir() { std::fs::create_dir_all(&target)?; } else if ft.is_symlink() { let to = std::fs::read_link(entry.path())?; crate::portable::symlink(&to, &target)?; files.push((rel_s, *blake3::hash(to.to_string_lossy().as_bytes()).as_bytes())); } else if ft.is_file() { std::fs::copy(entry.path(), &target)?; files.push((rel_s, *blake3::hash(&std::fs::read(entry.path())?).as_bytes())); } } Ok(files) } /// Pack `dir` into an ext4 image whose root belongs to this user, so the /// job (which runs as the owner of `/src`) can write to it. pub fn image(dir: &Path, out: &Path) -> Result<()> { let used: u64 = ignore::WalkBuilder::new(dir) .hidden(false) .ignore(false) .git_ignore(false) .build() .flatten() .filter_map(|e| e.metadata().ok()) .map(|m| m.len().max(4096)) .sum(); let mib = used * 12 / 10 / (1 << 20) + 512; let (uid, gid) = owner(); let status = std::process::Command::new("mkfs.ext4") .args(["-q", "-F", "-L", "workspace", "-E"]) .arg(format!("root_owner={uid}:{gid}")) .arg("-d") .arg(dir) .arg(out) .arg(format!("{mib}M")) .status() .context("run mkfs.ext4 (e2fsprogs 1.43 or later)")?; if !status.success() { bail!("mkfs.ext4 failed for the workspace image"); } Ok(()) } /// Give the files of `dir` whose content a cached disk was built from /// the mtime they had then (oxygenflow#52), and describe this checkout's /// sources for the disk to keep: unchanged files are fresh to a build /// tool that judges by time, changed ones newer than anything it built. /// Whole seconds, as the workspace image keeps them. Returns the sources /// and how many files got their time back. pub fn restore_times(dir: &Path, files: &Files, before: Option<&crate::cache::Sources>) -> (crate::cache::Sources, usize) { let mut out = crate::cache::Sources::new(); let mut restored = 0; for (path, hash) in files { let p = dir.join(path); let Ok(md) = std::fs::symlink_metadata(&p) else { continue }; if !md.is_file() { continue; } let hex = hex::encode(hash); let kept = before.and_then(|b| b.get(path)).filter(|(h, _)| *h == hex).map(|(_, t)| *t); let t = match kept { Some(t) => { let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.max(0) as u64); if std::fs::File::options().write(true).open(&p).and_then(|f| f.set_modified(at)).is_err() { continue; } restored += 1; t } None => md.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs() as i64).unwrap_or(0), }; out.insert(path.clone(), (hex, t)); } (out, restored) } /// An empty ext4 filesystem of `mib` MiB at `out`, owned by this user like /// the workspace. mkfs.ext4 creates the file sparse, so only what the job /// writes takes space on the runner. pub fn empty_ext4(out: &Path, mib: u32, label: &str) -> Result<()> { let (uid, gid) = owner(); let status = std::process::Command::new("mkfs.ext4") .args(["-q", "-F", "-L", label, "-E"]) .arg(format!("root_owner={uid}:{gid}")) .arg(out) .arg(format!("{mib}M")) .status() .context("run mkfs.ext4 (e2fsprogs 1.43 or later)")?; if !status.success() { bail!("mkfs.ext4 failed for a scratch disk"); } Ok(()) } /// Remove `dir` and everything in it, even where a job took away its own /// write or search permission on a directory; links are never followed. pub fn remove_tree(dir: &Path) -> std::io::Result<()> { match std::fs::remove_dir_all(dir) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(_) => { open_up(dir); std::fs::remove_dir_all(dir) } } } /// The runner's user and group, which own a workspace image's root. fn owner() -> (u32, u32) { #[cfg(unix)] { unsafe { (libc::getuid(), libc::getgid()) } } #[cfg(not(unix))] { (0, 0) } } fn open_up(dir: &Path) { let Ok(meta) = std::fs::symlink_metadata(dir) else { return }; if !meta.is_dir() { return; } let _ = crate::portable::set_mode(dir, 0o700); if let Ok(entries) = std::fs::read_dir(dir) { for e in entries.flatten() { open_up(&e.path()); } } } #[cfg(test)] mod tests { use super::*; use std::time::UNIX_EPOCH; fn secs(p: &Path) -> i64 { std::fs::metadata(p).unwrap().modified().unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64 } /// oxygenflow#52: an unchanged file gets the time it had when the cached /// disk was built; a changed or new one keeps its checkout's, newer. #[test] fn unchanged_sources_get_their_cached_times_back() { let dir = tempfile::tempdir().unwrap(); for (name, text) in [("same.rs", "a"), ("changed.rs", "b2"), ("new.rs", "c")] { std::fs::write(dir.path().join(name), text).unwrap(); } std::os::unix::fs::symlink("same.rs", dir.path().join("link.rs")).unwrap(); let h = |t: &str| *blake3::hash(t.as_bytes()).as_bytes(); let files: Files = vec![("same.rs".into(), h("a")), ("changed.rs".into(), h("b2")), ("new.rs".into(), h("c")), ("link.rs".into(), h("same.rs"))]; let now = secs(&dir.path().join("same.rs")); let before: crate::cache::Sources = [("same.rs".to_string(), (hex::encode(h("a")), 1_000_000)), ("changed.rs".to_string(), (hex::encode(h("b1")), 1_000_000))] .into(); let (sources, restored) = restore_times(dir.path(), &files, Some(&before)); assert_eq!(restored, 1); assert_eq!(secs(&dir.path().join("same.rs")), 1_000_000, "unchanged: fresh to cargo"); assert!(secs(&dir.path().join("changed.rs")) >= now, "changed: newer than the cached build"); assert_eq!(sources["same.rs"], (hex::encode(h("a")), 1_000_000)); assert_eq!(sources["changed.rs"].0, hex::encode(h("b2")), "kept with its new content"); assert!(sources.contains_key("new.rs") && !sources.contains_key("link.rs"), "files only"); // Without a cached build nothing moves, and the sources are listed. let (again, restored) = restore_times(dir.path(), &files, None); assert_eq!((restored, again.len()), (0, 3)); } }