OXYGENHUB
# e38bb032e38b oxygenflow / crates / flow / src / outputs.rs
8.6 KiB · Rust · last changed The syscall tier: jobs without KVM 1 month ago source blame history raw
ea56c56e Andy McCall ↶ 1
//! A job's artifacts (spec §5.2): read after the job has stopped, from
2
//! its disk images with debugfs (tier 2, so the runner needs no mount
3
//! rights) or from its directories (tier 1).
a67b97e9 Andy McCall ↶ 4
5
use anyhow::{bail, Context, Result};
6
use flow_core::{Job, MountSource};
7
use std::path::{Path, PathBuf};
8
9
/// Largest artifact a runner takes: 512 MiB.
10
pub const MAX_ARTIFACT_BYTES: u64 = 512 << 20;
11
12
/// Where guest path `path` lives on the host: the disk image, and the path
13
/// inside its file system. The workspace is `/src`; each `ephemeral://disk`
14
/// mount is `mount-N.ext4` in `scratch`, numbered as `microvm::run` does.
15
pub fn locate(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
ea56c56e Andy McCall ↶ 16
    locate_in(job, workspace, path, |i| scratch.join(format!("mount-{i}.ext4")))
17
}
18
19
/// [`locate`] for the syscall tier, where the workspace and each disk
20
/// mount are directories (`crate::syscall::disk_dir`).
21
pub fn locate_dir(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
22
    locate_in(job, workspace, path, |i| crate::syscall::disk_dir(scratch, i))
23
}
24
25
fn locate_in(job: &Job, workspace: &Path, path: &str, disk: impl Fn(usize) -> PathBuf) -> (PathBuf, String) {
a67b97e9 Andy McCall ↶ 26
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
27
        if let Some(rest) = path.strip_prefix(&format!("{}/", m.target)) {
ea56c56e Andy McCall ↶ 28
            return (disk(i), format!("/{rest}"));
a67b97e9 Andy McCall ↶ 29
        }
30
    }
31
    (workspace.to_path_buf(), path.strip_prefix("/src").unwrap_or(path).to_string())
32
}
33
ea56c56e Andy McCall ↶ 34
/// Read regular file `inner` beneath directory `base`, at most
35
/// [`MAX_ARTIFACT_BYTES`]. The job wrote that tree, so no link is
36
/// followed on the way: each directory is opened with `O_NOFOLLOW`, and
37
/// so is the file.
38
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
39
    use std::ffi::CString;
40
    use std::io::Read;
41
    use std::os::fd::FromRawFd;
42
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
43
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
44
        bail!("{inner} is not a clean path");
45
    }
46
    let c = |s: &str| CString::new(s).context("a path holds NUL");
47
    let mut dir = unsafe { libc::open(c(&base.to_string_lossy())?.as_ptr(), libc::O_DIRECTORY | libc::O_RDONLY | libc::O_CLOEXEC) };
48
    if dir < 0 {
49
        bail!("open {}: {}", base.display(), std::io::Error::last_os_error());
50
    }
51
    let (last, dirs) = parts.split_last().unwrap();
52
    for d in dirs {
53
        let next = unsafe { libc::openat(dir, c(d)?.as_ptr(), libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC) };
54
        let err = std::io::Error::last_os_error();
55
        unsafe { libc::close(dir) };
56
        if next < 0 {
57
            bail!("{inner}: {d} is not a directory in the job's files ({err})");
58
        }
59
        dir = next;
60
    }
61
    let fd = unsafe { libc::openat(dir, c(last)?.as_ptr(), libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NONBLOCK) };
62
    let err = std::io::Error::last_os_error();
63
    unsafe { libc::close(dir) };
64
    if fd < 0 {
65
        bail!("{inner} is not a regular file in the job's files ({err})");
66
    }
67
    let file = unsafe { std::fs::File::from_raw_fd(fd) };
68
    let meta = file.metadata()?;
69
    if !meta.is_file() {
70
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
71
    }
72
    if meta.len() > MAX_ARTIFACT_BYTES {
73
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
74
    }
75
    let mut bytes = Vec::with_capacity(meta.len() as usize);
76
    file.take(MAX_ARTIFACT_BYTES + 1).read_to_end(&mut bytes)?;
77
    if bytes.len() as u64 > MAX_ARTIFACT_BYTES {
78
        bail!("{inner} grew past {} MiB while it was read", MAX_ARTIFACT_BYTES >> 20);
79
    }
80
    Ok(bytes)
81
}
82
a67b97e9 Andy McCall ↶ 83
fn debugfs(image: &Path, request: &str) -> Result<String> {
84
    let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?;
85
    if !out.status.success() {
86
        bail!("debugfs {request:?}: {}", String::from_utf8_lossy(&out.stderr).trim());
87
    }
88
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
89
}
90
91
/// Read regular file `inner` of ext4 image `image`, at most
92
/// [`MAX_ARTIFACT_BYTES`]. Paths come from a checked pipeline: absolute,
93
/// clean, and free of quotes and spaces.
94
pub fn read(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<u8>> {
95
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
96
    if !stat.contains("Type: regular") {
97
        bail!("{inner} is not a regular file in the job's disk (it may be missing or a link)");
98
    }
99
    let size: u64 = stat
100
        .split("Size: ")
101
        .nth(1)
102
        .and_then(|s| s.split_whitespace().next())
103
        .and_then(|s| s.parse().ok())
104
        .context("debugfs gave no size")?;
105
    if size > MAX_ARTIFACT_BYTES {
106
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", size >> 20, MAX_ARTIFACT_BYTES >> 20);
107
    }
108
    let dest = scratch.join("artifact.out");
109
    let _ = std::fs::remove_file(&dest);
110
    debugfs(image, &format!("dump \"{inner}\" \"{}\"", dest.display()))?;
111
    let bytes = std::fs::read(&dest).with_context(|| format!("{inner}: debugfs wrote nothing"))?;
112
    let _ = std::fs::remove_file(&dest);
113
    if bytes.len() as u64 != size {
114
        bail!("{inner}: read {} bytes of {size}", bytes.len());
115
    }
116
    Ok(bytes)
117
}
118
119
#[cfg(test)]
120
mod tests {
121
    use super::*;
122
    use flow_core::Mount;
123
124
    fn job() -> Job {
125
        Job {
126
            name: "build".into(),
127
            sandbox: "rust".into(),
128
            scope: vec![],
129
            depends_on: vec![],
130
            env: Default::default(),
131
            exec: vec!["make".into()],
e2c12561 Andy McCall ↶ 132
            secrets: vec![],
720eb819 Andy McCall ↶ 133
            identities: vec![],
a67b97e9 Andy McCall ↶ 134
            mounts: vec![
135
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64 },
136
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024 },
137
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024 },
138
            ],
139
            outputs: vec![],
e576e086 Andy McCall ↶ 140
            runs_on: vec![],
02b127d2 Andy McCall ↶ 141
            timeout_s: None,
bf7bb1b8 Andy McCall ↶ 142
            on: None,
a67b97e9 Andy McCall ↶ 143
        }
144
    }
145
146
    #[test]
147
    fn paths_are_found_on_the_disk_that_holds_them() {
148
        let (w, s) = (Path::new("/w.ext4"), Path::new("/scratch"));
149
        assert_eq!(locate(&job(), w, s, "/src/target/release/oxy"), (s.join("mount-0.ext4"), "/release/oxy".into()));
150
        assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into()));
151
        assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into()));
152
        assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent");
ea56c56e Andy McCall ↶ 153
    }
154
155
    #[test]
156
    fn files_are_read_from_directories_without_following_links() {
157
        let dir = tempfile::tempdir().unwrap();
158
        let tree = dir.path().join("tree");
159
        std::fs::create_dir_all(tree.join("release")).unwrap();
160
        std::fs::write(tree.join("release/tool"), b"built").unwrap();
161
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
162
        std::os::unix::fs::symlink(dir.path().join("secret"), tree.join("release/link")).unwrap();
163
        std::os::unix::fs::symlink(dir.path(), tree.join("up")).unwrap();
164
        assert_eq!(read_dir_file(&tree, "/release/tool").unwrap(), b"built");
165
        assert!(read_dir_file(&tree, "/release/link").is_err(), "a link to a host file");
166
        assert!(read_dir_file(&tree, "/up/secret").is_err(), "a linked directory");
167
        assert!(read_dir_file(&tree, "/release/../release/tool").is_err());
168
        assert!(read_dir_file(&tree, "/release").is_err(), "a directory");
169
        let s = Path::new("/scratch");
170
        assert_eq!(locate_dir(&job(), &tree, s, "/src/target/a"), (s.join("mount-0"), "/a".into()));
a67b97e9 Andy McCall ↶ 171
    }
172
173
    #[test]
174
    fn files_are_read_back_from_an_ext4_image() {
175
        let dir = tempfile::tempdir().unwrap();
176
        let tree = dir.path().join("tree");
177
        std::fs::create_dir_all(tree.join("release")).unwrap();
178
        let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
179
        std::fs::write(tree.join("release/tool"), &body).unwrap();
180
        std::os::unix::fs::symlink("tool", tree.join("release/link")).unwrap();
181
        let image = dir.path().join("disk.ext4");
182
        crate::workspace::image(&tree, &image).unwrap();
183
        assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body);
184
        assert!(read(&image, "/release/missing", dir.path()).is_err());
185
        assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file"));
186
    }
187
}