The syscall tier: jobs without KVM ea56c56eaf90
runtime "syscall" jobs run in user, mount, PID, network, IPC and UTS namespaces on the image's tree (unpacked once per hash with debugfs, read-only), with the workspace directory at /src. Landlock limits writes to /src, /tmp and /dev; seccomp refuses sockets other than AF_UNIX and ptrace, mounts, bpf, perf events, keys, modules and io_uring; a cgroup v2 group of the runner's delegated cgroup sets memory.max and cpu.max. The runner offers the tier only where all of that works, and `flow tiers` says which tiers a machine can run and why not. flow-init's job-starting code is shared with the new helper. refs #36
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 00:26 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
ea56c56eaf90763e11badbeb8f43fc79d955b6be6c0fa800e7b8f69935877c30- Depends on
- 280ef951d193 9e8f4a56630b a67b97e9a717 a72abeeedd78 bb643c252515 c3db750ed96e c4285535b03a c58efc6a74db c5b702e59c1f e0918b887850 e2c125610f43 eb5c4db673e5 fc10d9e80383
- Depended on by
- 21a3ea4aab88 1215f05ff8d0 494a1b9a8ac2 e6949d87205b 3554afefb6aa cc462b56fbbb 337afb8f6043 fa963b1b7eb6 5cf1e8aefbb6 28cc603f737d 077a65242359 2594278f49b2 46fe2c815cc3 ea81796bb757 de1d7926185f 82cf6e91d398 d60aac6c3f8a b14aeaae683a 8f211e20bea5 62829d15ef09 6d53fb7d00ec 9a6fac2b40a9 b11e30f5031d
- In bookmarks
- main newer-pipeline push-uses-login
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 37 atoms, 3 tree operations, 10 files
Browse the tree at this change
added crates/flow-init/src/job.rs +136 −0
| 1 | + | //! What a job's sandbox needs from inside, in either tier: its |
|
| 2 | + | //! environment, its program found in its own `PATH`, the usual `/dev` |
|
| 3 | + | //! links and `lo`. Compiled into `flow-init` (tier 2, the microVM's PID 1) |
|
| 4 | + | //! and into `flow` itself (tier 1, the syscall sandbox), so both start a |
|
| 5 | + | //! job the same way. |
|
| 6 | + | ||
| 7 | + | pub const PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
|
| 8 | + | ||
| 9 | + | /// The file `program` names: itself when it holds a `/`, otherwise the first |
|
| 10 | + | /// executable regular file of that name in `path`'s directories. |
|
| 11 | + | pub fn resolve_program(program: &str, path: &str) -> Option<String> { |
|
| 12 | + | use std::os::unix::fs::PermissionsExt; |
|
| 13 | + | if program.is_empty() { |
|
| 14 | + | return None; |
|
| 15 | + | } |
|
| 16 | + | if program.contains('/') { |
|
| 17 | + | return Some(program.to_string()); |
|
| 18 | + | } |
|
| 19 | + | path.split(':') |
|
| 20 | + | .filter(|d| !d.is_empty()) |
|
| 21 | + | .map(|d| format!("{d}/{program}")) |
|
| 22 | + | .find(|p| std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)) |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /// A job's environment: `PATH` and `HOME=/tmp` by default, then the |
|
| 26 | + | /// image's own (`/etc/flow/env`, written by `flow rootfs import --env`), |
|
| 27 | + | /// then the job's (its sandbox's and its own, already merged by the host). |
|
| 28 | + | /// Later ones win, so a pipeline can always set `PATH`. |
|
| 29 | + | pub fn job_env(image: Option<&str>, job: &[(String, String)]) -> std::collections::BTreeMap<String, String> { |
|
| 30 | + | let mut env = std::collections::BTreeMap::new(); |
|
| 31 | + | env.insert("PATH".to_string(), PATH.to_string()); |
|
| 32 | + | env.insert("HOME".to_string(), "/tmp".to_string()); |
|
| 33 | + | for line in image.unwrap_or("").lines() { |
|
| 34 | + | if let Some((k, v)) = line.split_once('=') { |
|
| 35 | + | if !k.is_empty() { |
|
| 36 | + | env.insert(k.to_string(), v.to_string()); |
|
| 37 | + | } |
|
| 38 | + | } |
|
| 39 | + | } |
|
| 40 | + | for (k, v) in job { |
|
| 41 | + | env.insert(k.clone(), v.clone()); |
|
| 42 | + | } |
|
| 43 | + | env |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// The links every Linux system has under /dev and devtmpfs does not |
|
| 47 | + | /// make: `/dev/fd` (bash's process substitution, `<(…)`) and the standard |
|
| 48 | + | /// streams, all through the job's own /proc. |
|
| 49 | + | pub fn dev_links(dev: &str) -> Result<(), String> { |
|
| 50 | + | for (name, target) in DEV_LINKS { |
|
| 51 | + | let link = format!("{dev}/{name}"); |
|
| 52 | + | if std::fs::symlink_metadata(&link).is_err() { |
|
| 53 | + | std::os::unix::fs::symlink(target, &link).map_err(|e| format!("link {link}: {e}"))?; |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | Ok(()) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | const DEV_LINKS: [(&str, &str); 4] = |
|
| 60 | + | [("fd", "/proc/self/fd"), ("stdin", "/proc/self/fd/0"), ("stdout", "/proc/self/fd/1"), ("stderr", "/proc/self/fd/2")]; |
|
| 61 | + | ||
| 62 | + | /// Set `lo` up; the kernel gives it 127.0.0.1 and ::1 itself. |
|
| 63 | + | pub fn loopback_up() -> Result<(), String> { |
|
| 64 | + | let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; |
|
| 65 | + | if fd < 0 { |
|
| 66 | + | return Err(format!("socket for lo: {}", std::io::Error::last_os_error())); |
|
| 67 | + | } |
|
| 68 | + | let mut req: libc::ifreq = unsafe { std::mem::zeroed() }; |
|
| 69 | + | for (i, b) in b"lo".iter().enumerate() { |
|
| 70 | + | req.ifr_name[i] = *b as libc::c_char; |
|
| 71 | + | } |
|
| 72 | + | let ok = unsafe { |
|
| 73 | + | libc::ioctl(fd, libc::SIOCGIFFLAGS, &mut req) == 0 && { |
|
| 74 | + | req.ifr_ifru.ifru_flags |= libc::IFF_UP as libc::c_short; |
|
| 75 | + | libc::ioctl(fd, libc::SIOCSIFFLAGS, &req) == 0 |
|
| 76 | + | } |
|
| 77 | + | }; |
|
| 78 | + | let err = std::io::Error::last_os_error(); |
|
| 79 | + | unsafe { libc::close(fd) }; |
|
| 80 | + | if !ok { |
|
| 81 | + | return Err(format!("bring up lo: {err}")); |
|
| 82 | + | } |
|
| 83 | + | Ok(()) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | #[cfg(test)] |
|
| 87 | + | mod tests { |
|
| 88 | + | use super::{dev_links, job_env, resolve_program}; |
|
| 89 | + | ||
| 90 | + | #[test] |
|
| 91 | + | fn the_image_environment_sits_under_the_jobs() { |
|
| 92 | + | let image = "PATH=/opt/llvm-mos/bin:/usr/bin:/bin\nX16EMU=/opt/x16emu\nnot a line\n"; |
|
| 93 | + | let env = job_env(Some(image), &[("X16EMU".into(), "/job".into()), ("LANG".into(), "C".into())]); |
|
| 94 | + | assert_eq!(env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin", "the image's PATH replaces the default"); |
|
| 95 | + | assert_eq!(env["X16EMU"], "/job", "the job's value wins"); |
|
| 96 | + | assert_eq!(env["HOME"], "/tmp"); |
|
| 97 | + | assert_eq!(env["LANG"], "C"); |
|
| 98 | + | assert_eq!(job_env(None, &[])["PATH"], super::PATH); |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | #[test] |
|
| 102 | + | fn dev_gets_the_usual_links_once() { |
|
| 103 | + | let dir = std::env::temp_dir().join(format!("flow-init-dev-{}", std::process::id())); |
|
| 104 | + | let _ = std::fs::remove_dir_all(&dir); |
|
| 105 | + | std::fs::create_dir_all(&dir).unwrap(); |
|
| 106 | + | let d = dir.to_str().unwrap(); |
|
| 107 | + | dev_links(d).unwrap(); |
|
| 108 | + | dev_links(d).unwrap(); |
|
| 109 | + | assert_eq!(std::fs::read_link(dir.join("fd")).unwrap(), std::path::Path::new("/proc/self/fd")); |
|
| 110 | + | assert_eq!(std::fs::read_link(dir.join("stdout")).unwrap(), std::path::Path::new("/proc/self/fd/1")); |
|
| 111 | + | // Through the host's /proc, the link works as in a job. |
|
| 112 | + | assert!(dir.join("fd").join("0").exists() || !std::path::Path::new("/proc/self/fd/0").exists()); |
|
| 113 | + | std::fs::remove_dir_all(&dir).unwrap(); |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | use std::os::unix::fs::PermissionsExt; |
|
| 117 | + | ||
| 118 | + | #[test] |
|
| 119 | + | fn programs_are_found_in_the_jobs_path() { |
|
| 120 | + | let dir = std::env::temp_dir().join(format!("flow-init-test-{}", std::process::id())); |
|
| 121 | + | let (a, b) = (dir.join("a"), dir.join("b")); |
|
| 122 | + | std::fs::create_dir_all(&a).unwrap(); |
|
| 123 | + | std::fs::create_dir_all(&b).unwrap(); |
|
| 124 | + | std::fs::write(a.join("tool"), "not executable").unwrap(); |
|
| 125 | + | std::fs::write(b.join("tool"), "#!/bin/sh\n").unwrap(); |
|
| 126 | + | std::fs::set_permissions(b.join("tool"), std::fs::Permissions::from_mode(0o755)).unwrap(); |
|
| 127 | + | std::fs::create_dir_all(a.join("dir")).unwrap(); |
|
| 128 | + | let path = format!("{}::{}", a.display(), b.display()); |
|
| 129 | + | assert_eq!(resolve_program("tool", &path), Some(format!("{}/tool", b.display())), "skips a file that is not executable"); |
|
| 130 | + | assert_eq!(resolve_program("dir", &path), None, "and directories"); |
|
| 131 | + | assert_eq!(resolve_program("missing", &path), None); |
|
| 132 | + | assert_eq!(resolve_program("./x", &path).as_deref(), Some("./x"), "a path is used as given"); |
|
| 133 | + | assert_eq!(resolve_program("", &path), None); |
|
| 134 | + | std::fs::remove_dir_all(&dir).unwrap(); |
|
| 135 | + | } |
|
| 136 | + | } |
added crates/flow/src/syscall.rs +857 −0
| 1 | + | //! Tier 1 (spec §4.2, docs/m0.md §3): the syscall sandbox, for machines |
|
| 2 | + | //! without hardware virtualisation. The job runs in new user, mount, PID, |
|
| 3 | + | //! network, IPC and UTS namespaces, on the rootfs image's tree (unpacked |
|
| 4 | + | //! once per hash, read-only) with the workspace directory at `/src` and a |
|
| 5 | + | //! tmpfs at `/tmp`. Landlock limits writes to `/src`, `/tmp` and `/dev`; a |
|
| 6 | + | //! seccomp filter refuses sockets other than `AF_UNIX` and the calls that |
|
| 7 | + | //! reach past the sandbox (`ptrace`, `mount`, `bpf`, `perf_event_open`, |
|
| 8 | + | //! keys, modules, io_uring); and the sandbox's CPU and memory limits apply |
|
| 9 | + | //! through a cgroup v2 group of the runner's own delegated cgroup. Without |
|
| 10 | + | //! that delegation the tier is not offered, so no job runs unbounded. |
|
| 11 | + | //! |
|
| 12 | + | //! The runner starts `flow __sandbox <spec>` (this same binary, through |
|
| 13 | + | //! `/proc/self/exe`) for each job. That helper joins the job's cgroup, |
|
| 14 | + | //! makes the namespaces and forks the sandbox's PID 1, which builds the new |
|
| 15 | + | //! root, pivots into it and runs the job as an unprivileged user, the way |
|
| 16 | + | //! `flow-init` does in a microVM. Records, receipts and artifacts are the |
|
| 17 | + | //! same as tier 2's. |
|
| 18 | + | ||
| 19 | + | use crate::guest::{dev_links, job_env, loopback_up, resolve_program, PATH}; |
|
| 20 | + | use crate::home::Home; |
|
| 21 | + | use crate::microvm::{Outcome, Sink}; |
|
| 22 | + | use anyhow::{bail, Context, Result}; |
|
| 23 | + | use flow_core::api::{Record, SecretInput}; |
|
| 24 | + | use flow_core::{Environment, Job, MountSource, Network, Sandbox}; |
|
| 25 | + | use serde::{Deserialize, Serialize}; |
|
| 26 | + | use std::ffi::CString; |
|
| 27 | + | use std::io::{BufRead, BufReader, Read}; |
|
| 28 | + | use std::path::{Path, PathBuf}; |
|
| 29 | + | use std::sync::{mpsc, OnceLock}; |
|
| 30 | + | use std::time::{Duration, Instant}; |
|
| 31 | + | ||
| 32 | + | /// The user and group a job runs as inside the sandbox; the runner's own |
|
| 33 | + | /// on the host. |
|
| 34 | + | const JOB_ID: u32 = 1000; |
|
| 35 | + | ||
| 36 | + | /// The receipt environment for a job in this tier: the sandbox is this |
|
| 37 | + | /// binary, so its hash stands where tier 2 has Firecracker's and |
|
| 38 | + | /// flow-init's, with the host kernel's release. |
|
| 39 | + | pub fn environment(sandbox: &Sandbox, job: &Job) -> Environment { |
|
| 40 | + | Environment { |
|
| 41 | + | runtime: "syscall".into(), |
|
| 42 | + | parts: vec![ |
|
| 43 | + | ("flow".into(), self_hash().to_string()), |
|
| 44 | + | ("kernel".into(), kernel_release()), |
|
| 45 | + | ("memory_mib".into(), sandbox.memory_mib.to_string()), |
|
| 46 | + | ("cpus".into(), sandbox.cpus.to_string()), |
|
| 47 | + | ] |
|
| 48 | + | .into_iter() |
|
| 49 | + | .chain(job.mounts.iter().map(|m| (format!("mount {}", m.target), format!("{} {} MiB", m.source.as_str(), m.size_mib)))) |
|
| 50 | + | .collect(), |
|
| 51 | + | rootfs: sandbox.rootfs.clone(), |
|
| 52 | + | argv: job.exec.clone(), |
|
| 53 | + | env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | fn self_hash() -> &'static str { |
|
| 58 | + | static HASH: OnceLock<String> = OnceLock::new(); |
|
| 59 | + | HASH.get_or_init(|| crate::home::hash_file(Path::new("/proc/self/exe")).unwrap_or_else(|_| "unknown".into())) |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | fn kernel_release() -> String { |
|
| 63 | + | std::fs::read_to_string("/proc/sys/kernel/osrelease").map(|s| s.trim().to_string()).unwrap_or_else(|_| "unknown".into()) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /// Whether this machine can run the tier now: a cgroup this runner may |
|
| 67 | + | /// manage, and namespaces, Landlock and seccomp that work for this binary |
|
| 68 | + | /// (Ubuntu's AppArmor can deny unprivileged user namespaces to programs |
|
| 69 | + | /// without a profile). `Err` says what is missing. |
|
| 70 | + | pub fn available() -> std::result::Result<(), String> { |
|
| 71 | + | Cgroups::delegated()?; |
|
| 72 | + | let out = std::process::Command::new("/proc/self/exe") |
|
| 73 | + | .arg("__sandbox-probe") |
|
| 74 | + | .stdin(std::process::Stdio::null()) |
|
| 75 | + | .output() |
|
| 76 | + | .map_err(|e| format!("cannot start the probe: {e}"))?; |
|
| 77 | + | if out.status.success() { |
|
| 78 | + | Ok(()) |
|
| 79 | + | } else { |
|
| 80 | + | Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | /// `flow __sandbox-probe`: try each kernel feature the sandbox uses, in a |
|
| 85 | + | /// throwaway process, and say which one failed. |
|
| 86 | + | pub fn probe_main() -> i32 { |
|
| 87 | + | let r = (|| -> std::result::Result<(), String> { |
|
| 88 | + | let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) }; |
|
| 89 | + | enter_namespaces(libc::CLONE_NEWUSER | libc::CLONE_NEWNS, uid, gid) |
|
| 90 | + | .map_err(|e| format!("user namespaces are not allowed for this program ({e}); on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1 denies them"))?; |
|
| 91 | + | mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "") |
|
| 92 | + | .and_then(|_| mount("tmpfs", "/tmp", "tmpfs", libc::MS_NOSUID | libc::MS_NODEV, "")) |
|
| 93 | + | .map_err(|e| { |
|
| 94 | + | format!( |
|
| 95 | + | "user namespaces give no mount rights here ({e}); on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1 does this" |
|
| 96 | + | ) |
|
| 97 | + | })?; |
|
| 98 | + | let abi = landlock_abi(); |
|
| 99 | + | if abi < 1 { |
|
| 100 | + | return Err("Landlock is not enabled in this kernel (it must be in the lsm= list)".into()); |
|
| 101 | + | } |
|
| 102 | + | no_new_privs()?; |
|
| 103 | + | seccomp(&[]).map_err(|e| format!("seccomp filters are not available: {e}")) |
|
| 104 | + | })(); |
|
| 105 | + | match r { |
|
| 106 | + | Ok(()) => 0, |
|
| 107 | + | Err(e) => { |
|
| 108 | + | eprintln!("{e}"); |
|
| 109 | + | 1 |
|
| 110 | + | } |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | /// What the helper reads from the spec file the runner wrote (0600, in |
|
| 115 | + | /// the job's scratch directory, which the job never sees); it removes it |
|
| 116 | + | /// once read. |
|
| 117 | + | #[derive(Serialize, Deserialize)] |
|
| 118 | + | struct Spec { |
|
| 119 | + | /// The image's unpacked tree. |
|
| 120 | + | rootfs: PathBuf, |
|
| 121 | + | /// An empty directory to build the new root on. |
|
| 122 | + | root: PathBuf, |
|
| 123 | + | workspace: PathBuf, |
|
| 124 | + | /// The job's cgroup, joined before anything else. |
|
| 125 | + | cgroup: PathBuf, |
|
| 126 | + | argv: Vec<String>, |
|
| 127 | + | env: Vec<(String, String)>, |
|
| 128 | + | mounts: Vec<MountSpec>, |
|
| 129 | + | loopback: bool, |
|
| 130 | + | secrets: Vec<(String, String)>, |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | #[derive(Serialize, Deserialize)] |
|
| 134 | + | struct MountSpec { |
|
| 135 | + | target: String, |
|
| 136 | + | size_mib: u32, |
|
| 137 | + | /// A host directory bound at `target` (`ephemeral://disk`); a tmpfs |
|
| 138 | + | /// of `size_mib` when there is none. |
|
| 139 | + | dir: Option<PathBuf>, |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /// Run `job` with `workspace` (a directory) as `/src`. `scratch` is a |
|
| 143 | + | /// directory this call may fill; `name` names the job's cgroup. |
|
| 144 | + | #[allow(clippy::too_many_arguments)] |
|
| 145 | + | pub fn run( |
|
| 146 | + | home: &Home, |
|
| 147 | + | sandbox: &Sandbox, |
|
| 148 | + | job: &Job, |
|
| 149 | + | secrets: &[SecretInput], |
|
| 150 | + | workspace: &Path, |
|
| 151 | + | scratch: &Path, |
|
| 152 | + | timeout: Duration, |
|
| 153 | + | sink: &mut dyn Sink, |
|
| 154 | + | name: &str, |
|
| 155 | + | ) -> Result<Outcome> { |
|
| 156 | + | let cgroups = Cgroups::delegated().map_err(|e| anyhow::anyhow!("the syscall tier cannot run here: {e}"))?; |
|
| 157 | + | let image = home.rootfs(&sandbox.rootfs); |
|
| 158 | + | if !image.is_file() { |
|
| 159 | + | bail!("rootfs {} is not on this runner (import it with `flow rootfs import`)", sandbox.rootfs); |
|
| 160 | + | } |
|
| 161 | + | let image = home.verified(&image, &sandbox.rootfs)?; |
|
| 162 | + | let rootfs = unpacked(home, &sandbox.rootfs, &image)?; |
|
| 163 | + | let environment = environment(sandbox, job); |
|
| 164 | + | ||
| 165 | + | let mut mounts = Vec::new(); |
|
| 166 | + | for (i, m) in job.mounts.iter().enumerate() { |
|
| 167 | + | let dir = match m.source { |
|
| 168 | + | MountSource::Tmpfs => None, |
|
| 169 | + | MountSource::Disk => { |
|
| 170 | + | let d = disk_dir(scratch, job.mounts.iter().take(i).filter(|m| m.source == MountSource::Disk).count()); |
|
| 171 | + | std::fs::create_dir_all(&d)?; |
|
| 172 | + | Some(d) |
|
| 173 | + | } |
|
| 174 | + | }; |
|
| 175 | + | mounts.push(MountSpec { target: m.target.clone(), size_mib: m.size_mib, dir }); |
|
| 176 | + | } |
|
| 177 | + | let root = scratch.join("root"); |
|
| 178 | + | std::fs::create_dir_all(&root)?; |
|
| 179 | + | let group = cgroups.job(name, sandbox.memory_mib, sandbox.cpus)?; |
|
| 180 | + | let spec = Spec { |
|
| 181 | + | rootfs, |
|
| 182 | + | root, |
|
| 183 | + | workspace: workspace.to_path_buf(), |
|
| 184 | + | cgroup: group.dir.clone(), |
|
| 185 | + | argv: job.exec.clone(), |
|
| 186 | + | env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), |
|
| 187 | + | mounts, |
|
| 188 | + | loopback: sandbox.network == Network::Loopback, |
|
| 189 | + | secrets: secrets.iter().map(|s| (s.path.clone(), s.value.clone())).collect(), |
|
| 190 | + | }; |
|
| 191 | + | let spec_path = scratch.join("sandbox.json"); |
|
| 192 | + | { |
|
| 193 | + | use std::io::Write; |
|
| 194 | + | use std::os::unix::fs::OpenOptionsExt; |
|
| 195 | + | let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&spec_path)?; |
|
| 196 | + | f.write_all(&serde_json::to_vec(&spec)?)?; |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | let started = Instant::now(); |
|
| 200 | + | let mut child = std::process::Command::new("/proc/self/exe") |
|
| 201 | + | .arg("__sandbox") |
|
| 202 | + | .arg(&spec_path) |
|
| 203 | + | .env_clear() |
|
| 204 | + | .current_dir(scratch) |
|
| 205 | + | .stdin(std::process::Stdio::null()) |
|
| 206 | + | .stdout(std::process::Stdio::piped()) |
|
| 207 | + | .stderr(std::process::Stdio::piped()) |
|
| 208 | + | .spawn() |
|
| 209 | + | .context("start the sandbox")?; |
|
| 210 | + | let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>(); |
|
| 211 | + | let out = child.stdout.take().unwrap(); |
|
| 212 | + | let err = child.stderr.take().unwrap(); |
|
| 213 | + | let tx2 = tx.clone(); |
|
| 214 | + | let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))]; |
|
| 215 | + | let mut failed_to_start = false; |
|
| 216 | + | let mut timed_out = false; |
|
| 217 | + | let mut cancelled = false; |
|
| 218 | + | let deadline = started + timeout; |
|
| 219 | + | let t = |s: &Instant| s.elapsed().as_millis() as u64; |
|
| 220 | + | let stop = |child: &mut std::process::Child| { |
|
| 221 | + | let _ = child.kill(); |
|
| 222 | + | group.kill(); |
|
| 223 | + | }; |
|
| 224 | + | loop { |
|
| 225 | + | match rx.recv_timeout(Duration::from_millis(200)) { |
|
| 226 | + | Ok((stdout, line)) => { |
|
| 227 | + | let text = String::from_utf8_lossy(&line).into_owned(); |
|
| 228 | + | let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() }; |
|
| 229 | + | match text.strip_prefix(FAILED) { |
|
| 230 | + | Some(why) if !stdout => { |
|
| 231 | + | failed_to_start = true; |
|
| 232 | + | sink.record(rec("flow", &format!("the sandbox failed to start the job: {why}"))) |
|
| 233 | + | } |
|
| 234 | + | _ => sink.record(rec(if stdout { "out" } else { "err" }, &text)), |
|
| 235 | + | } |
|
| 236 | + | } |
|
| 237 | + | Err(mpsc::RecvTimeoutError::Timeout) => {} |
|
| 238 | + | Err(mpsc::RecvTimeoutError::Disconnected) => break, |
|
| 239 | + | } |
|
| 240 | + | if !timed_out && !cancelled && sink.cancelled() { |
|
| 241 | + | cancelled = true; |
|
| 242 | + | stop(&mut child); |
|
| 243 | + | sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() }); |
|
| 244 | + | } |
|
| 245 | + | if Instant::now() >= deadline && !timed_out && !cancelled { |
|
| 246 | + | timed_out = true; |
|
| 247 | + | stop(&mut child); |
|
| 248 | + | sink.record(Record { |
|
| 249 | + | t: t(&started), |
|
| 250 | + | stream: "flow".into(), |
|
| 251 | + | text: format!("stopped at the time limit of {} s", timeout.as_secs()), |
|
| 252 | + | }); |
|
| 253 | + | } |
|
| 254 | + | } |
|
| 255 | + | let status = child.wait()?; |
|
| 256 | + | for r in readers { |
|
| 257 | + | let _ = r.join(); |
|
| 258 | + | } |
|
| 259 | + | if group.out_of_memory() { |
|
| 260 | + | sink.record(Record { |
|
| 261 | + | t: t(&started), |
|
| 262 | + | stream: "flow".into(), |
|
| 263 | + | text: format!("the job ran out of memory: the sandbox allows {} MiB", sandbox.memory_mib), |
|
| 264 | + | }); |
|
| 265 | + | } |
|
| 266 | + | let exit_code = status.code(); |
|
| 267 | + | if exit_code.is_none() && !timed_out && !cancelled && !failed_to_start { |
|
| 268 | + | sink.record(Record { |
|
| 269 | + | t: t(&started), |
|
| 270 | + | stream: "flow".into(), |
|
| 271 | + | text: format!("the sandbox ended without the job's exit code ({status})"), |
|
| 272 | + | }); |
|
| 273 | + | } |
|
| 274 | + | drop(group); |
|
| 275 | + | let _ = std::fs::remove_file(&spec_path); |
|
| 276 | + | Ok(Outcome { |
|
| 277 | + | exit_code: if timed_out || cancelled { None } else { exit_code }, |
|
| 278 | + | duration_ms: t(&started), |
|
| 279 | + | environment, |
|
| 280 | + | timed_out, |
|
| 281 | + | cancelled, |
|
| 282 | + | }) |
|
| 283 | + | } |
|
| 284 | + | ||
| 285 | + | /// Where the `n`th `ephemeral://disk` mount of a job keeps its files. |
|
| 286 | + | pub fn disk_dir(scratch: &Path, n: usize) -> PathBuf { |
|
| 287 | + | scratch.join(format!("mount-{n}")) |
|
| 288 | + | } |
|
| 289 | + | ||
| 290 | + | /// How the helper says the job never started, on its stderr. |
|
| 291 | + | const FAILED: &str = "\u{1e}F "; |
|
| 292 | + | ||
| 293 | + | fn lines(r: impl Read, stdout: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) { |
|
| 294 | + | let mut r = BufReader::new(r); |
|
| 295 | + | let mut buf = Vec::new(); |
|
| 296 | + | loop { |
|
| 297 | + | buf.clear(); |
|
| 298 | + | // A long line is cut at 16 KiB, as flow-init cuts it. |
|
| 299 | + | match (&mut r).take(16 * 1024).read_until(b'\n', &mut buf) { |
|
| 300 | + | Ok(0) | Err(_) => break, |
|
| 301 | + | Ok(_) => { |
|
| 302 | + | if buf.last() == Some(&b'\n') { |
|
| 303 | + | buf.pop(); |
|
| 304 | + | } |
|
| 305 | + | if tx.send((stdout, buf.clone())).is_err() { |
|
| 306 | + | break; |
|
| 307 | + | } |
|
| 308 | + | } |
|
| 309 | + | } |
|
| 310 | + | } |
|
| 311 | + | } |
|
| 312 | + | ||
| 313 | + | /// The image's tree, unpacked from its ext4 file once per hash with |
|
| 314 | + | /// debugfs (no mount rights needed). Files belong to the runner; set-id |
|
| 315 | + | /// bits and device nodes are dropped, which a sandboxed job cannot use |
|
| 316 | + | /// anyway. |
|
| 317 | + | fn unpacked(home: &Home, hash: &str, image: &Path) -> Result<PathBuf> { |
|
| 318 | + | let trees = home.root.join("trees"); |
|
| 319 | + | let dir = trees.join(hash); |
|
| 320 | + | if dir.is_dir() { |
|
| 321 | + | return Ok(dir); |
|
| 322 | + | } |
|
| 323 | + | std::fs::create_dir_all(&trees)?; |
|
| 324 | + | let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); |
|
| 325 | + | let tmp = trees.join(format!(".{hash}.{}.{nanos}", std::process::id())); |
|
| 326 | + | std::fs::create_dir_all(&tmp)?; |
|
| 327 | + | let out = std::process::Command::new("debugfs") |
|
| 328 | + | .arg("-R") |
|
| 329 | + | .arg(format!("rdump / \"{}\"", tmp.display())) |
|
| 330 | + | .arg(image) |
|
| 331 | + | .output() |
|
| 332 | + | .context("run debugfs (e2fsprogs) to unpack the rootfs")?; |
|
| 333 | + | let empty = std::fs::read_dir(&tmp).map(|mut d| d.next().is_none()).unwrap_or(true); |
|
| 334 | + | if !out.status.success() || empty { |
|
| 335 | + | let _ = crate::workspace::remove_tree(&tmp); |
|
| 336 | + | bail!("unpacking rootfs {}: {}", &hash[..12.min(hash.len())], String::from_utf8_lossy(&out.stderr).trim()); |
|
| 337 | + | } |
|
| 338 | + | match std::fs::rename(&tmp, &dir) { |
|
| 339 | + | Ok(()) => Ok(dir), |
|
| 340 | + | // Another job unpacked it first. |
|
| 341 | + | Err(_) if dir.is_dir() => { |
|
| 342 | + | let _ = crate::workspace::remove_tree(&tmp); |
|
| 343 | + | Ok(dir) |
|
| 344 | + | } |
|
| 345 | + | Err(e) => Err(e).with_context(|| format!("keep the unpacked rootfs at {}", dir.display())), |
|
| 346 | + | } |
|
| 347 | + | } |
|
| 348 | + | ||
| 349 | + | // ---- cgroups ---- |
|
| 350 | + | ||
| 351 | + | /// The runner's own cgroup v2 group, when it may manage it (a systemd |
|
| 352 | + | /// service with `Delegate=yes`, or a user service). The runner moves |
|
| 353 | + | /// itself into a `flow-main` leaf so the group can hold job groups with |
|
| 354 | + | /// the cpu and memory controllers on. |
|
| 355 | + | #[derive(Clone)] |
|
| 356 | + | pub struct Cgroups { |
|
| 357 | + | base: PathBuf, |
|
| 358 | + | } |
|
| 359 | + | ||
| 360 | + | /// The leaf the runner process moves itself into. |
|
| 361 | + | const MAIN_LEAF: &str = "flow-main"; |
|
| 362 | + | ||
| 363 | + | impl Cgroups { |
|
| 364 | + | pub fn delegated() -> std::result::Result<Cgroups, String> { |
|
| 365 | + | static ONCE: OnceLock<std::result::Result<Cgroups, String>> = OnceLock::new(); |
|
| 366 | + | ONCE.get_or_init(Self::set_up).clone() |
|
| 367 | + | } |
|
| 368 | + | ||
| 369 | + | fn set_up() -> std::result::Result<Cgroups, String> { |
|
| 370 | + | let own = std::fs::read_to_string("/proc/self/cgroup").map_err(|e| format!("read /proc/self/cgroup: {e}"))?; |
|
| 371 | + | let rel = own |
|
| 372 | + | .lines() |
|
| 373 | + | .find_map(|l| l.strip_prefix("0::")) |
|
| 374 | + | .ok_or("this machine does not use cgroup v2 (the unified hierarchy)")? |
|
| 375 | + | .trim() |
|
| 376 | + | .to_string(); |
|
| 377 | + | let mut base = Path::new("/sys/fs/cgroup").join(rel.trim_start_matches('/')); |
|
| 378 | + | if base.file_name().is_some_and(|n| n == MAIN_LEAF) { |
|
| 379 | + | base.pop(); |
|
| 380 | + | } |
|
| 381 | + | let controllers = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default(); |
|
| 382 | + | let has = |c: &str| controllers.split_whitespace().any(|x| x == c); |
|
| 383 | + | let delegate_hint = "run the runner as a systemd service with Delegate=yes, or as a user service; for `flow run`, use `systemd-run --user --scope -p Delegate=yes flow run …`"; |
|
| 384 | + | if !has("cpu") || !has("memory") { |
|
| 385 | + | return Err(format!("the cgroup {} has no cpu and memory controllers to give jobs; {delegate_hint}", base.display())); |
|
| 386 | + | } |
|
| 387 | + | let writable = |
|
| 388 | + | |p: &Path| CString::new(p.as_os_str().as_encoded_bytes()).is_ok_and(|c| unsafe { libc::access(c.as_ptr(), libc::W_OK) } == 0); |
|
| 389 | + | if !writable(&base) || !writable(&base.join("cgroup.subtree_control")) || !writable(&base.join("cgroup.procs")) { |
|
| 390 | + | return Err(format!("this runner may not manage its cgroup {}; {delegate_hint}", base.display())); |
|
| 391 | + | } |
|
| 392 | + | // Only this process may be in the group: anything else would block |
|
| 393 | + | // the controllers, and nothing is changed for a shared group. |
|
| 394 | + | let me = std::process::id().to_string(); |
|
| 395 | + | let others = std::fs::read_to_string(base.join("cgroup.procs")).unwrap_or_default(); |
|
| 396 | + | if others.lines().any(|p| p.trim() != me) { |
|
| 397 | + | return Err(format!("the cgroup {} holds other processes; {delegate_hint}", base.display())); |
|
| 398 | + | } |
|
| 399 | + | let leaf = base.join(MAIN_LEAF); |
|
| 400 | + | if !leaf.is_dir() { |
|
| 401 | + | std::fs::create_dir(&leaf).map_err(|e| format!("make {}: {e}", leaf.display()))?; |
|
| 402 | + | } |
|
| 403 | + | std::fs::write(leaf.join("cgroup.procs"), "0").map_err(|e| format!("move into {}: {e}", leaf.display()))?; |
|
| 404 | + | let want: Vec<&str> = ["cpu", "memory", "pids"].into_iter().filter(|c| has(c)).collect(); |
|
| 405 | + | let enable = want.iter().map(|c| format!("+{c}")).collect::<Vec<_>>().join(" "); |
|
| 406 | + | std::fs::write(base.join("cgroup.subtree_control"), &enable) |
|
| 407 | + | .map_err(|e| format!("enable {enable} in {}: {e} (another process may still be in it; {delegate_hint})", base.display()))?; |
|
| 408 | + | Ok(Cgroups { base }) |
|
| 409 | + | } |
|
| 410 | + | ||
| 411 | + | /// A fresh group for one job, with its limits set. |
|
| 412 | + | pub fn job(&self, name: &str, memory_mib: u32, cpus: u32) -> Result<JobGroup> { |
|
| 413 | + | let dir = self.base.join(name); |
|
| 414 | + | if dir.is_dir() { |
|
| 415 | + | // Left by a runner that stopped mid-job. |
|
| 416 | + | drop(JobGroup { dir: dir.clone() }); |
|
| 417 | + | } |
|
| 418 | + | std::fs::create_dir(&dir).with_context(|| format!("make the job's cgroup {}", dir.display()))?; |
|
| 419 | + | let group = JobGroup { dir }; |
|
| 420 | + | let set = |file: &str, value: String| { |
|
| 421 | + | std::fs::write(group.dir.join(file), &value).with_context(|| format!("set {file} to {value} in {}", group.dir.display())) |
|
| 422 | + | }; |
|
| 423 | + | set("memory.max", (u64::from(memory_mib) << 20).to_string())?; |
|
| 424 | + | if group.dir.join("memory.swap.max").exists() { |
|
| 425 | + | set("memory.swap.max", "0".into())?; |
|
| 426 | + | } |
|
| 427 | + | set("cpu.max", format!("{} 100000", u64::from(cpus.max(1)) * 100_000))?; |
|
| 428 | + | Ok(group) |
|
| 429 | + | } |
|
| 430 | + | } |
|
| 431 | + | ||
| 432 | + | /// One job's cgroup; dropping it kills what is left in it and removes it. |
|
| 433 | + | pub struct JobGroup { |
|
| 434 | + | dir: PathBuf, |
|
| 435 | + | } |
|
| 436 | + | ||
| 437 | + | impl JobGroup { |
|
| 438 | + | fn kill(&self) { |
|
| 439 | + | let _ = std::fs::write(self.dir.join("cgroup.kill"), "1"); |
|
| 440 | + | } |
|
| 441 | + | ||
| 442 | + | fn out_of_memory(&self) -> bool { |
|
| 443 | + | std::fs::read_to_string(self.dir.join("memory.events")) |
|
| 444 | + | .ok() |
|
| 445 | + | .and_then(|s| s.lines().find_map(|l| l.strip_prefix("oom_kill ")).and_then(|n| n.trim().parse::<u64>().ok())) |
|
| 446 | + | .is_some_and(|n| n > 0) |
|
| 447 | + | } |
|
| 448 | + | } |
|
| 449 | + | ||
| 450 | + | impl Drop for JobGroup { |
|
| 451 | + | fn drop(&mut self) { |
|
| 452 | + | self.kill(); |
|
| 453 | + | for _ in 0..100 { |
|
| 454 | + | if std::fs::remove_dir(&self.dir).is_ok() || !self.dir.exists() { |
|
| 455 | + | return; |
|
| 456 | + | } |
|
| 457 | + | std::thread::sleep(Duration::from_millis(50)); |
|
| 458 | + | } |
|
| 459 | + | eprintln!("flow: could not remove the cgroup {}", self.dir.display()); |
|
| 460 | + | } |
|
| 461 | + | } |
|
| 462 | + | ||
| 463 | + | // ---- the helper: `flow __sandbox <spec>` ---- |
|
| 464 | + | ||
| 465 | + | /// The helper's entry point; returns the job's exit code (125 when the |
|
| 466 | + | /// sandbox could not start it, after a `FAILED` line on stderr). |
|
| 467 | + | pub fn sandbox_main(spec_path: &Path) -> i32 { |
|
| 468 | + | match helper(spec_path) { |
|
| 469 | + | Ok(code) => code, |
|
| 470 | + | Err(e) => { |
|
| 471 | + | eprintln!("{FAILED}{e}"); |
|
| 472 | + | 125 |
|
| 473 | + | } |
|
| 474 | + | } |
|
| 475 | + | } |
|
| 476 | + | ||
| 477 | + | fn helper(spec_path: &Path) -> std::result::Result<i32, String> { |
|
| 478 | + | let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path).map_err(|e| format!("read the spec: {e}"))?) |
|
| 479 | + | .map_err(|e| format!("parse the spec: {e}"))?; |
|
| 480 | + | let _ = std::fs::remove_file(spec_path); |
|
| 481 | + | if spec.argv.is_empty() { |
|
| 482 | + | return Err("empty argv".into()); |
|
| 483 | + | } |
|
| 484 | + | std::fs::write(spec.cgroup.join("cgroup.procs"), "0").map_err(|e| format!("join the job's cgroup: {e}"))?; |
|
| 485 | + | let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) }; |
|
| 486 | + | enter_namespaces( |
|
| 487 | + | libc::CLONE_NEWUSER | libc::CLONE_NEWNS | libc::CLONE_NEWPID | libc::CLONE_NEWNET | libc::CLONE_NEWIPC | libc::CLONE_NEWUTS, |
|
| 488 | + | uid, |
|
| 489 | + | gid, |
|
| 490 | + | )?; |
|
| 491 | + | // The first child is PID 1 of the new PID namespace; everything in it |
|
| 492 | + | // dies with it, and it dies with this helper. |
|
| 493 | + | let pid = unsafe { libc::fork() }; |
|
| 494 | + | if pid < 0 { |
|
| 495 | + | return Err(format!("fork: {}", std::io::Error::last_os_error())); |
|
| 496 | + | } |
|
| 497 | + | if pid == 0 { |
|
| 498 | + | let code = match init(&spec) { |
|
| 499 | + | Ok(code) => code, |
|
| 500 | + | Err(e) => { |
|
| 501 | + | eprintln!("{FAILED}{e}"); |
|
| 502 | + | 125 |
|
| 503 | + | } |
|
| 504 | + | }; |
|
| 505 | + | unsafe { libc::_exit(code) }; |
|
| 506 | + | } |
|
| 507 | + | Ok(wait_for(pid)) |
|
| 508 | + | } |
|
| 509 | + | ||
| 510 | + | /// Wait for `pid`, reaping any other child on the way: its exit code, or |
|
| 511 | + | /// 128 + the signal that killed it. |
|
| 512 | + | fn wait_for(pid: libc::pid_t) -> i32 { |
|
| 513 | + | let mut status = 0; |
|
| 514 | + | loop { |
|
| 515 | + | let r = unsafe { libc::waitpid(-1, &mut status, 0) }; |
|
| 516 | + | if r == pid { |
|
| 517 | + | break; |
|
| 518 | + | } |
|
| 519 | + | if r < 0 && std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { |
|
| 520 | + | return 125; |
|
| 521 | + | } |
|
| 522 | + | } |
|
| 523 | + | if libc::WIFEXITED(status) { |
|
| 524 | + | libc::WEXITSTATUS(status) |
|
| 525 | + | } else { |
|
| 526 | + | 128 + libc::WTERMSIG(status) |
|
| 527 | + | } |
|
| 528 | + | } |
|
| 529 | + | ||
| 530 | + | /// New namespaces of `flags` (which include a user namespace) where |
|
| 531 | + | /// [`JOB_ID`] is the caller's `uid` and `gid`. |
|
| 532 | + | fn enter_namespaces(flags: libc::c_int, uid: u32, gid: u32) -> std::result::Result<(), String> { |
|
| 533 | + | if unsafe { libc::unshare(flags) } != 0 { |
|
| 534 | + | return Err(format!("unshare: {}", std::io::Error::last_os_error())); |
|
| 535 | + | } |
|
| 536 | + | let write = |file: &str, body: String| std::fs::write(file, body).map_err(|e| format!("write {file}: {e}")); |
|
| 537 | + | write("/proc/self/setgroups", "deny".into())?; |
|
| 538 | + | write("/proc/self/uid_map", format!("{JOB_ID} {uid} 1\n"))?; |
|
| 539 | + | write("/proc/self/gid_map", format!("{JOB_ID} {gid} 1\n")) |
|
| 540 | + | } |
|
| 541 | + | ||
| 542 | + | /// PID 1 of the sandbox: build the root, enter it, run the job, wait. |
|
| 543 | + | fn init(spec: &Spec) -> std::result::Result<i32, String> { |
|
| 544 | + | // Dies with the helper; the job's cgroup is killed at the end as well. |
|
| 545 | + | unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) }; |
|
| 546 | + | let nosuid = libc::MS_NOSUID | libc::MS_NODEV; |
|
| 547 | + | mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "")?; |
|
| 548 | + | let root = spec.root.to_str().ok_or("the root path is not UTF-8")?; |
|
| 549 | + | let at = |p: &str| format!("{root}{p}"); |
|
| 550 | + | bind(spec.rootfs.to_str().ok_or("the rootfs path is not UTF-8")?, root)?; |
|
| 551 | + | for d in ["/src", "/tmp", "/proc", "/dev"] { |
|
| 552 | + | if !Path::new(&at(d)).is_dir() { |
|
| 553 | + | return Err(format!("the image has no {d} directory")); |
|
| 554 | + | } |
|
| 555 | + | } |
|
| 556 | + | remount_bind(root, libc::MS_RDONLY | nosuid)?; |
|
| 557 | + | bind(spec.workspace.to_str().ok_or("the workspace path is not UTF-8")?, &at("/src"))?; |
|
| 558 | + | mount("tmpfs", &at("/tmp"), "tmpfs", nosuid, "mode=1777")?; |
|
| 559 | + | mount("proc", &at("/proc"), "proc", nosuid | libc::MS_NOEXEC, "")?; |
|
| 560 | + | // A /dev of the harmless devices, bound from the host's. |
|
| 561 | + | mount("tmpfs", &at("/dev"), "tmpfs", libc::MS_NOSUID | libc::MS_NOEXEC, "mode=755")?; |
|
| 562 | + | for d in ["null", "zero", "full", "random", "urandom"] { |
|
| 563 | + | let target = at(&format!("/dev/{d}")); |
|
| 564 | + | std::fs::File::create(&target).map_err(|e| format!("make {target}: {e}"))?; |
|
| 565 | + | bind(&format!("/dev/{d}"), &target)?; |
|
| 566 | + | } |
|
| 567 | + | dev_links(&at("/dev"))?; |
|
| 568 | + | std::fs::create_dir(at("/dev/shm")).map_err(|e| format!("make /dev/shm: {e}"))?; |
|
| 569 | + | mount("tmpfs", &at("/dev/shm"), "tmpfs", nosuid, "mode=1777")?; |
|
| 570 | + | if Path::new(&at("/run")).is_dir() { |
|
| 571 | + | mount("tmpfs", &at("/run"), "tmpfs", nosuid, "mode=755")?; |
|
| 572 | + | } |
|
| 573 | + | // Ephemeral mounts: made under the writable /src or /tmp. |
|
| 574 | + | for m in &spec.mounts { |
|
| 575 | + | let target = at(&m.target); |
|
| 576 | + | std::fs::create_dir_all(&target).map_err(|e| format!("make {}: {e}", m.target))?; |
|
| 577 | + | match &m.dir { |
|
| 578 | + | Some(dir) => bind(dir.to_str().ok_or("a mount path is not UTF-8")?, &target)?, |
|
| 579 | + | None => mount("tmpfs", &target, "tmpfs", nosuid, &format!("size={}m,mode=755", m.size_mib))?, |
|
| 580 | + | } |
|
| 581 | + | } |
|
| 582 | + | // Secrets: an in-memory mount of their own, each file 0400. |
|
| 583 | + | if !spec.secrets.is_empty() { |
|
| 584 | + | if !Path::new(&at("/run")).is_dir() { |
|
| 585 | + | return Err("the image has no /run, where secrets are mounted".into()); |
|
| 586 | + | } |
|
| 587 | + | let dir = at("/run/secrets"); |
|
| 588 | + | std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {dir}: {e}"))?; |
|
| 589 | + | mount("tmpfs", &dir, "tmpfs", nosuid | libc::MS_NOEXEC, "size=1m,mode=700")?; |
|
| 590 | + | for (path, value) in &spec.secrets { |
|
| 591 | + | let Some(rel) = path.strip_prefix("/run/secrets/") else { |
|
| 592 | + | return Err(format!("secret path {path:?} is not under /run/secrets/")); |
|
| 593 | + | }; |
|
| 594 | + | let full = format!("{dir}/{rel}"); |
|
| 595 | + | if let Some(parent) = Path::new(&full).parent() { |
|
| 596 | + | std::fs::create_dir_all(parent).map_err(|e| format!("mkdir for {path}: {e}"))?; |
|
| 597 | + | } |
|
| 598 | + | std::fs::write(&full, value.as_bytes()).map_err(|e| format!("write {path}: {e}"))?; |
|
| 599 | + | std::fs::set_permissions(&full, std::os::unix::fs::PermissionsExt::from_mode(0o400)) |
|
| 600 | + | .map_err(|e| format!("chmod {path}: {e}"))?; |
|
| 601 | + | } |
|
| 602 | + | } |
|
| 603 | + | let host = b"flow"; |
|
| 604 | + | unsafe { libc::sethostname(host.as_ptr() as *const libc::c_char, host.len()) }; |
|
| 605 | + | if spec.loopback { |
|
| 606 | + | loopback_up()?; |
|
| 607 | + | } |
|
| 608 | + | // Into the new root; the old one is detached, so the host's files, the |
|
| 609 | + | // spec and the scratch directory are out of reach. |
|
| 610 | + | std::env::set_current_dir(root).map_err(|e| format!("cd {root}: {e}"))?; |
|
| 611 | + | let dot = c"."; |
|
| 612 | + | if unsafe { libc::syscall(libc::SYS_pivot_root, dot.as_ptr(), dot.as_ptr()) } != 0 { |
|
| 613 | + | return Err(format!("pivot_root: {}", std::io::Error::last_os_error())); |
|
| 614 | + | } |
|
| 615 | + | if unsafe { libc::umount2(dot.as_ptr(), libc::MNT_DETACH) } != 0 { |
|
| 616 | + | return Err(format!("detach the old root: {}", std::io::Error::last_os_error())); |
|
| 617 | + | } |
|
| 618 | + | std::env::set_current_dir("/src").map_err(|e| format!("cd /src: {e}"))?; |
|
| 619 | + | ||
| 620 | + | let vars = job_env(std::fs::read_to_string("/etc/flow/env").ok().as_deref(), &spec.env); |
|
| 621 | + | let path = vars.get("PATH").map(String::as_str).unwrap_or(PATH); |
|
| 622 | + | let program = |
|
| 623 | + | cstr(&resolve_program(&spec.argv[0], path).ok_or_else(|| format!("cannot run {:?}: not found in {path}", spec.argv[0]))?)?; |
|
| 624 | + | let argv: Vec<CString> = spec.argv.iter().map(|a| cstr(a)).collect::<Result<_, _>>()?; |
|
| 625 | + | let env: Vec<CString> = vars.iter().map(|(k, v)| cstr(&format!("{k}={v}"))).collect::<Result<_, _>>()?; |
|
| 626 | + | let mut writable: Vec<String> = ["/src", "/tmp", "/dev"].into_iter().map(String::from).collect(); |
|
| 627 | + | writable.extend(spec.mounts.iter().map(|m| m.target.clone())); |
|
| 628 | + | let pid = unsafe { libc::fork() }; |
|
| 629 | + | if pid < 0 { |
|
| 630 | + | return Err(format!("fork: {}", std::io::Error::last_os_error())); |
|
| 631 | + | } |
|
| 632 | + | if pid == 0 { |
|
| 633 | + | // The job: no new privileges, then the walls, then the program. |
|
| 634 | + | let confined = (|| { |
|
| 635 | + | unsafe { |
|
| 636 | + | let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY); |
|
| 637 | + | libc::dup2(null, 0); |
|
| 638 | + | libc::setsid(); |
|
| 639 | + | } |
|
| 640 | + | no_new_privs()?; |
|
| 641 | + | landlock(&writable)?; |
|
| 642 | + | seccomp(DENIED) |
|
| 643 | + | })(); |
|
| 644 | + | unsafe { |
|
| 645 | + | if let Err(e) = confined { |
|
| 646 | + | let msg = format!("{FAILED}{e}\n"); |
|
| 647 | + | libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len()); |
|
| 648 | + | libc::_exit(125); |
|
| 649 | + | } |
|
| 650 | + | let mut a: Vec<*const libc::c_char> = argv.iter().map(|c| c.as_ptr()).collect(); |
|
| 651 | + | a.push(std::ptr::null()); |
|
| 652 | + | let mut e: Vec<*const libc::c_char> = env.iter().map(|c| c.as_ptr()).collect(); |
|
| 653 | + | e.push(std::ptr::null()); |
|
| 654 | + | libc::execve(program.as_ptr(), a.as_ptr(), e.as_ptr()); |
|
| 655 | + | let msg = format!("flow: cannot run {:?}: {}\n", spec.argv[0], std::io::Error::last_os_error()); |
|
| 656 | + | libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len()); |
|
| 657 | + | libc::_exit(127); |
|
| 658 | + | } |
|
| 659 | + | } |
|
| 660 | + | Ok(wait_for(pid)) |
|
| 661 | + | } |
|
| 662 | + | ||
| 663 | + | fn cstr(s: &str) -> std::result::Result<CString, String> { |
|
| 664 | + | CString::new(s).map_err(|_| format!("{s:?} contains NUL")) |
|
| 665 | + | } |
|
| 666 | + | ||
| 667 | + | fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> std::result::Result<(), String> { |
|
| 668 | + | let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?); |
|
| 669 | + | let fstype = if fstype.is_empty() { std::ptr::null() } else { f.as_ptr() }; |
|
| 670 | + | let data = if data.is_empty() { std::ptr::null() } else { d.as_ptr() as *const libc::c_void }; |
|
| 671 | + | if unsafe { libc::mount(s.as_ptr(), t.as_ptr(), fstype, flags, data) } != 0 { |
|
| 672 | + | return Err(format!("mount {src} on {target}: {}", std::io::Error::last_os_error())); |
|
| 673 | + | } |
|
| 674 | + | Ok(()) |
|
| 675 | + | } |
|
| 676 | + | ||
| 677 | + | fn bind(src: &str, target: &str) -> std::result::Result<(), String> { |
|
| 678 | + | mount(src, target, "", libc::MS_BIND | libc::MS_REC, "") |
|
| 679 | + | } |
|
| 680 | + | ||
| 681 | + | /// Remount a bind mount with `flags` added. In a user namespace the |
|
| 682 | + | /// flags the mount already had are locked, so they are kept. |
|
| 683 | + | fn remount_bind(target: &str, flags: libc::c_ulong) -> std::result::Result<(), String> { |
|
| 684 | + | let t = cstr(target)?; |
|
| 685 | + | let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; |
|
| 686 | + | if unsafe { libc::statvfs(t.as_ptr(), &mut st) } != 0 { |
|
| 687 | + | return Err(format!("statvfs {target}: {}", std::io::Error::last_os_error())); |
|
| 688 | + | } |
|
| 689 | + | let kept = [ |
|
| 690 | + | (libc::ST_NOSUID, libc::MS_NOSUID), |
|
| 691 | + | (libc::ST_NODEV, libc::MS_NODEV), |
|
| 692 | + | (libc::ST_NOEXEC, libc::MS_NOEXEC), |
|
| 693 | + | (libc::ST_NOATIME, libc::MS_NOATIME), |
|
| 694 | + | (libc::ST_NODIRATIME, libc::MS_NODIRATIME), |
|
| 695 | + | (libc::ST_RELATIME, libc::MS_RELATIME), |
|
| 696 | + | ] |
|
| 697 | + | .into_iter() |
|
| 698 | + | .filter(|(st_flag, _)| st.f_flag & *st_flag != 0) |
|
| 699 | + | .fold(0, |acc, (_, ms)| acc | ms); |
|
| 700 | + | mount("none", target, "", libc::MS_BIND | libc::MS_REMOUNT | flags | kept, "") |
|
| 701 | + | } |
|
| 702 | + | ||
| 703 | + | fn no_new_privs() -> std::result::Result<(), String> { |
|
| 704 | + | if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { |
|
| 705 | + | return Err(format!("no_new_privs: {}", std::io::Error::last_os_error())); |
|
| 706 | + | } |
|
| 707 | + | Ok(()) |
|
| 708 | + | } |
|
| 709 | + | ||
| 710 | + | // ---- Landlock ---- |
|
| 711 | + | ||
| 712 | + | const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1; |
|
| 713 | + | const LANDLOCK_RULE_PATH_BENEATH: libc::c_int = 1; |
|
| 714 | + | const ACCESS_EXECUTE: u64 = 1; |
|
| 715 | + | const ACCESS_READ_FILE: u64 = 1 << 2; |
|
| 716 | + | const ACCESS_READ_DIR: u64 = 1 << 3; |
|
| 717 | + | /// Every right of Landlock ABI 1 (execute .. make_sym). |
|
| 718 | + | const ACCESS_ABI1: u64 = (1 << 13) - 1; |
|
| 719 | + | const ACCESS_REFER: u64 = 1 << 13; |
|
| 720 | + | const ACCESS_TRUNCATE: u64 = 1 << 14; |
|
| 721 | + | ||
| 722 | + | #[repr(C)] |
|
| 723 | + | struct RulesetAttr { |
|
| 724 | + | handled_access_fs: u64, |
|
| 725 | + | } |
|
| 726 | + | ||
| 727 | + | #[repr(C, packed)] |
|
| 728 | + | struct PathBeneath { |
|
| 729 | + | allowed_access: u64, |
|
| 730 | + | parent_fd: i32, |
|
| 731 | + | } |
|
| 732 | + | ||
| 733 | + | /// The kernel's Landlock ABI version; below 1 when there is none. |
|
| 734 | + | fn landlock_abi() -> i64 { |
|
| 735 | + | unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, std::ptr::null::<RulesetAttr>(), 0usize, LANDLOCK_CREATE_RULESET_VERSION) } |
|
| 736 | + | } |
|
| 737 | + | ||
| 738 | + | /// Read and execute everywhere; write only beneath `writable`. |
|
| 739 | + | fn landlock(writable: &[String]) -> std::result::Result<(), String> { |
|
| 740 | + | let abi = landlock_abi(); |
|
| 741 | + | if abi < 1 { |
|
| 742 | + | return Err("Landlock is not available".into()); |
|
| 743 | + | } |
|
| 744 | + | let mut all = ACCESS_ABI1; |
|
| 745 | + | if abi >= 2 { |
|
| 746 | + | all |= ACCESS_REFER; |
|
| 747 | + | } |
|
| 748 | + | if abi >= 3 { |
|
| 749 | + | all |= ACCESS_TRUNCATE; |
|
| 750 | + | } |
|
| 751 | + | let attr = RulesetAttr { handled_access_fs: all }; |
|
| 752 | + | let ruleset = unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, &attr, std::mem::size_of::<RulesetAttr>(), 0) }; |
|
| 753 | + | if ruleset < 0 { |
|
| 754 | + | return Err(format!("landlock ruleset: {}", std::io::Error::last_os_error())); |
|
| 755 | + | } |
|
| 756 | + | let ruleset = ruleset as libc::c_int; |
|
| 757 | + | let rule = |path: &str, access: u64| -> std::result::Result<(), String> { |
|
| 758 | + | let p = cstr(path)?; |
|
| 759 | + | let fd = unsafe { libc::open(p.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) }; |
|
| 760 | + | if fd < 0 { |
|
| 761 | + | return Err(format!("open {path} for Landlock: {}", std::io::Error::last_os_error())); |
|
| 762 | + | } |
|
| 763 | + | let beneath = PathBeneath { allowed_access: access, parent_fd: fd }; |
|
| 764 | + | let rc = unsafe { libc::syscall(libc::SYS_landlock_add_rule, ruleset, LANDLOCK_RULE_PATH_BENEATH, &beneath, 0) }; |
|
| 765 | + | unsafe { libc::close(fd) }; |
|
| 766 | + | if rc != 0 { |
|
| 767 | + | return Err(format!("landlock rule for {path}: {}", std::io::Error::last_os_error())); |
|
| 768 | + | } |
|
| 769 | + | Ok(()) |
|
| 770 | + | }; |
|
| 771 | + | rule("/", ACCESS_EXECUTE | ACCESS_READ_FILE | ACCESS_READ_DIR)?; |
|
| 772 | + | for w in writable { |
|
| 773 | + | rule(w, all)?; |
|
| 774 | + | } |
|
| 775 | + | if unsafe { libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0) } != 0 { |
|
| 776 | + | return Err(format!("landlock restrict: {}", std::io::Error::last_os_error())); |
|
| 777 | + | } |
|
| 778 | + | unsafe { libc::close(ruleset) }; |
|
| 779 | + | Ok(()) |
|
| 780 | + | } |
|
| 781 | + | ||
| 782 | + | // ---- seccomp ---- |
|
| 783 | + | ||
| 784 | + | #[cfg(target_arch = "x86_64")] |
|
| 785 | + | const AUDIT_ARCH: u32 = 0xC000_003E; |
|
| 786 | + | #[cfg(target_arch = "aarch64")] |
|
| 787 | + | const AUDIT_ARCH: u32 = 0xC000_00B7; |
|
| 788 | + | #[cfg(target_arch = "riscv64")] |
|
| 789 | + | const AUDIT_ARCH: u32 = 0xC000_00F3; |
|
| 790 | + | ||
| 791 | + | /// Calls a job never makes: they reach past the sandbox (tracing, mounts, |
|
| 792 | + | /// kernel programs and keys, modules, kexec, swap, reboot) or around the |
|
| 793 | + | /// socket check (io_uring). |
|
| 794 | + | const DENIED: &[libc::c_long] = &[ |
|
| 795 | + | libc::SYS_ptrace, |
|
| 796 | + | libc::SYS_process_vm_readv, |
|
| 797 | + | libc::SYS_process_vm_writev, |
|
| 798 | + | libc::SYS_mount, |
|
| 799 | + | libc::SYS_umount2, |
|
| 800 | + | libc::SYS_pivot_root, |
|
| 801 | + | libc::SYS_open_tree, |
|
| 802 | + | libc::SYS_move_mount, |
|
| 803 | + | libc::SYS_fsopen, |
|
| 804 | + | libc::SYS_fsconfig, |
|
| 805 | + | libc::SYS_fsmount, |
|
| 806 | + | libc::SYS_fspick, |
|
| 807 | + | libc::SYS_bpf, |
|
| 808 | + | libc::SYS_perf_event_open, |
|
| 809 | + | libc::SYS_keyctl, |
|
| 810 | + | libc::SYS_add_key, |
|
| 811 | + | libc::SYS_request_key, |
|
| 812 | + | libc::SYS_init_module, |
|
| 813 | + | libc::SYS_finit_module, |
|
| 814 | + | libc::SYS_delete_module, |
|
| 815 | + | libc::SYS_kexec_load, |
|
| 816 | + | libc::SYS_swapon, |
|
| 817 | + | libc::SYS_swapoff, |
|
| 818 | + | libc::SYS_reboot, |
|
| 819 | + | libc::SYS_io_uring_setup, |
|
| 820 | + | libc::SYS_io_uring_enter, |
|
| 821 | + | libc::SYS_io_uring_register, |
|
| 822 | + | ]; |
|
| 823 | + | ||
| 824 | + | /// Install a filter that fails each of `denied` with EPERM and `socket` |
|
| 825 | + | /// for any family but `AF_UNIX` with EAFNOSUPPORT (an empty `denied` |
|
| 826 | + | /// still checks sockets). Another architecture's calls kill the process. |
|
| 827 | + | fn seccomp(denied: &[libc::c_long]) -> std::result::Result<(), String> { |
|
| 828 | + | const LD: u16 = (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16; |
|
| 829 | + | const JEQ: u16 = (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16; |
|
| 830 | + | const RET: u16 = (libc::BPF_RET | libc::BPF_K) as u16; |
|
| 831 | + | let op = |code: u16, k: u32, jt: u8, jf: u8| libc::sock_filter { code, jt, jf, k }; |
|
| 832 | + | let errno = |e: libc::c_int| libc::SECCOMP_RET_ERRNO | (e as u32 & libc::SECCOMP_RET_DATA); |
|
| 833 | + | // struct seccomp_data: nr at 0, arch at 4, args[0] at 16 (its low |
|
| 834 | + | // half on these little-endian machines). |
|
| 835 | + | let mut f = vec![op(LD, 4, 0, 0), op(JEQ, AUDIT_ARCH, 1, 0), op(RET, libc::SECCOMP_RET_KILL_PROCESS, 0, 0), op(LD, 0, 0, 0)]; |
|
| 836 | + | #[cfg(target_arch = "x86_64")] |
|
| 837 | + | { |
|
| 838 | + | // x32 calls carry this bit; there are none in a 64-bit job. |
|
| 839 | + | const JGE: u16 = (libc::BPF_JMP | libc::BPF_JGE | libc::BPF_K) as u16; |
|
| 840 | + | f.push(op(JGE, 0x4000_0000, 0, 1)); |
|
| 841 | + | f.push(op(RET, errno(libc::EPERM), 0, 0)); |
|
| 842 | + | } |
|
| 843 | + | for nr in denied { |
|
| 844 | + | f.push(op(JEQ, *nr as u32, 0, 1)); |
|
| 845 | + | f.push(op(RET, errno(libc::EPERM), 0, 0)); |
|
| 846 | + | } |
|
| 847 | + | f.push(op(JEQ, libc::SYS_socket as u32, 0, 3)); |
|
| 848 | + | f.push(op(LD, 16, 0, 0)); |
|
| 849 | + | f.push(op(JEQ, libc::AF_UNIX as u32, 1, 0)); |
|
| 850 | + | f.push(op(RET, errno(libc::EAFNOSUPPORT), 0, 0)); |
|
| 851 | + | f.push(op(RET, libc::SECCOMP_RET_ALLOW, 0, 0)); |
|
| 852 | + | let prog = libc::sock_fprog { len: f.len() as u16, filter: f.as_mut_ptr() }; |
|
| 853 | + | if unsafe { libc::syscall(libc::SYS_seccomp, libc::SECCOMP_SET_MODE_FILTER, 0, &prog) } != 0 { |
|
| 854 | + | return Err(format!("seccomp: {}", std::io::Error::last_os_error())); |
|
| 855 | + | } |
|
| 856 | + | Ok(()) |
|
| 857 | + | } |
added crates/flow/tests/syscall.rs +125 −0
| 1 | + | //! Tier 1 conformance (docs/m0.md §3): a pipeline on a busybox rootfs in |
|
| 2 | + | //! the syscall sandbox. Runs where `flow tiers` offers the syscall tier |
|
| 3 | + | //! (user namespaces, Landlock, seccomp and a delegated cgroup: each `flow` |
|
| 4 | + | //! runs in a delegated systemd user scope when one can be made) and a static |
|
| 5 | + | //! busybox is at /usr/bin/busybox; skipped otherwise. |
|
| 6 | + | ||
| 7 | + | use std::path::Path; |
|
| 8 | + | use std::process::Command; |
|
| 9 | + | ||
| 10 | + | const APPLETS: &[&str] = &[ |
|
| 11 | + | "sh", "echo", "cat", "id", "touch", "wget", "tail", "cut", "tr", "true", "false", "ls", "grep", "wc", "mount", "hostname", "head", |
|
| 12 | + | "sed", |
|
| 13 | + | ]; |
|
| 14 | + | ||
| 15 | + | /// `flow`, in a delegated cgroup scope of its own when systemd's user |
|
| 16 | + | /// manager can make one (the test itself shares cargo's). |
|
| 17 | + | fn flow(home: &Path) -> Command { |
|
| 18 | + | static SCOPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); |
|
| 19 | + | let scope = *SCOPE |
|
| 20 | + | .get_or_init(|| Command::new("systemd-run").args(["--user", "--scope", "-q", "true"]).output().is_ok_and(|o| o.status.success())); |
|
| 21 | + | let mut c = if scope { |
|
| 22 | + | let mut c = Command::new("systemd-run"); |
|
| 23 | + | c.args(["--user", "--scope", "-q", "-p", "Delegate=yes", env!("CARGO_BIN_EXE_flow")]); |
|
| 24 | + | c |
|
| 25 | + | } else { |
|
| 26 | + | Command::new(env!("CARGO_BIN_EXE_flow")) |
|
| 27 | + | }; |
|
| 28 | + | c.arg("--home").arg(home); |
|
| 29 | + | c |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | #[test] |
|
| 33 | + | fn a_pipeline_runs_confined_in_the_syscall_sandbox() { |
|
| 34 | + | let busybox = Path::new("/usr/bin/busybox"); |
|
| 35 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 36 | + | let home = dir.path().join("home"); |
|
| 37 | + | let tiers = flow(&home).arg("tiers").output().unwrap(); |
|
| 38 | + | let tiers = String::from_utf8_lossy(&tiers.stdout).into_owned(); |
|
| 39 | + | if !tiers.contains("syscall: available") || !busybox.is_file() { |
|
| 40 | + | eprintln!("skipped: the syscall tier is not available here or there is no static busybox\n{tiers}"); |
|
| 41 | + | return; |
|
| 42 | + | } |
|
| 43 | + | // A rootfs of busybox and its applets. |
|
| 44 | + | let tree = dir.path().join("tree"); |
|
| 45 | + | for d in ["bin", "src", "tmp", "proc", "dev", "etc", "run"] { |
|
| 46 | + | std::fs::create_dir_all(tree.join(d)).unwrap(); |
|
| 47 | + | } |
|
| 48 | + | std::fs::copy(busybox, tree.join("bin/busybox")).unwrap(); |
|
| 49 | + | for a in APPLETS { |
|
| 50 | + | std::os::unix::fs::symlink("busybox", tree.join("bin").join(a)).unwrap(); |
|
| 51 | + | } |
|
| 52 | + | let tarball = dir.path().join("rootfs.tar"); |
|
| 53 | + | assert!(Command::new("tar").arg("-cf").arg(&tarball).arg("-C").arg(&tree).arg(".").status().unwrap().success()); |
|
| 54 | + | let import = flow(&home).args(["rootfs", "import"]).arg(&tarball).output().unwrap(); |
|
| 55 | + | let said = String::from_utf8_lossy(&import.stdout).to_string() + &String::from_utf8_lossy(&import.stderr); |
|
| 56 | + | assert!(import.status.success(), "{said}"); |
|
| 57 | + | let rootfs = said.split(|c: char| !c.is_ascii_hexdigit()).find(|w| w.len() == 64).expect("the import names the hash").to_string(); |
|
| 58 | + | ||
| 59 | + | let work = dir.path().join("work"); |
|
| 60 | + | std::fs::create_dir_all(work.join(".flow")).unwrap(); |
|
| 61 | + | std::fs::write( |
|
| 62 | + | work.join(".flow/pipeline.kdl"), |
|
| 63 | + | format!( |
|
| 64 | + | r#"schema-version 1 |
|
| 65 | + | sandbox "bb" {{ |
|
| 66 | + | runtime "syscall" |
|
| 67 | + | rootfs "cas://blob/{rootfs}" |
|
| 68 | + | memory-limit "256MiB" |
|
| 69 | + | }} |
|
| 70 | + | job "literal" {{ |
|
| 71 | + | sandbox "bb" |
|
| 72 | + | exec "echo" "a; touch /src/pwned && $(id) | `id`" |
|
| 73 | + | }} |
|
| 74 | + | job "isolated" {{ |
|
| 75 | + | sandbox "bb" |
|
| 76 | + | exec "sh" "-c" "cat /proc/net/dev | tail -n +3 | cut -d: -f1 | tr -d ' '; wget -q -T 2 -O /dev/null http://1.1.1.1 && echo NETWORK || echo no-network" |
|
| 77 | + | }} |
|
| 78 | + | job "readonly" {{ |
|
| 79 | + | sandbox "bb" |
|
| 80 | + | exec "sh" "-c" "touch /etc/x 2>/dev/null && echo ROOTFS-WRITABLE; touch /run/x 2>/dev/null && echo RUN-WRITABLE; echo ok > /src/w && cat /src/w; id -u; hostname" |
|
| 81 | + | }} |
|
| 82 | + | job "walls" {{ |
|
| 83 | + | sandbox "bb" |
|
| 84 | + | exec "sh" "-c" "mount -t tmpfs t /tmp 2>/dev/null && echo MOUNTED; ls /proc | grep -c '^[0-9]' | sed 's/^/procs /'; ls / | tr '\n' ' '; echo; echo done" |
|
| 85 | + | }} |
|
| 86 | + | job "greedy" {{ |
|
| 87 | + | sandbox "bb" |
|
| 88 | + | exec "sh" "-c" "x=$(head -c 400000000 /dev/zero | tr '\\0' a); echo SURVIVED" |
|
| 89 | + | }} |
|
| 90 | + | job "fails" {{ |
|
| 91 | + | sandbox "bb" |
|
| 92 | + | exec "false" |
|
| 93 | + | }} |
|
| 94 | + | job "after-failure" {{ |
|
| 95 | + | sandbox "bb" |
|
| 96 | + | depends-on "fails" |
|
| 97 | + | exec "echo" "SHOULD-NOT-RUN" |
|
| 98 | + | }} |
|
| 99 | + | "# |
|
| 100 | + | ), |
|
| 101 | + | ) |
|
| 102 | + | .unwrap(); |
|
| 103 | + | let out = flow(&home).arg("run").current_dir(&work).output().unwrap(); |
|
| 104 | + | let stdout = String::from_utf8_lossy(&out.stdout); |
|
| 105 | + | let stderr = String::from_utf8_lossy(&out.stderr); |
|
| 106 | + | let all = format!("{stdout}\n{stderr}"); |
|
| 107 | + | assert!(!out.status.success(), "a failed job fails the run: {all}"); |
|
| 108 | + | assert!(stdout.contains(" a; touch /src/pwned && $(id) | `id`\n"), "argv is literal: {all}"); |
|
| 109 | + | assert!(!work.join("pwned").exists()); |
|
| 110 | + | assert!(stdout.contains(" lo\n") && stdout.contains("no-network") && !stdout.contains(" NETWORK\n"), "only loopback: {all}"); |
|
| 111 | + | assert!(stdout.contains(" ok\n") && !stdout.contains(" ROOTFS-WRITABLE\n"), "rootfs read-only, workspace writable: {all}"); |
|
| 112 | + | assert!(stdout.contains(" 1000\n") && !stdout.contains(" 0\n"), "the job never runs as root: {all}"); |
|
| 113 | + | assert!(stdout.contains(" flow\n"), "its own host name: {all}"); |
|
| 114 | + | assert!(!stdout.contains(" MOUNTED\n") && stdout.contains(" done\n"), "no mounts: {all}"); |
|
| 115 | + | assert!(stderr.contains("Address family not supported"), "sockets other than AF_UNIX are refused: {all}"); |
|
| 116 | + | assert!(!work.join("w").exists(), "jobs write to their own copy: {all}"); |
|
| 117 | + | let procs: u32 = stdout.lines().find_map(|l| l.trim().strip_prefix("procs ")).and_then(|n| n.parse().ok()).expect(&all); |
|
| 118 | + | assert!(procs < 10, "only the sandbox's processes are visible: {all}"); |
|
| 119 | + | assert!(!stdout.contains("home "), "none of the host's files: {all}"); |
|
| 120 | + | assert!(!stdout.contains(" RUN-WRITABLE\n"), "Landlock keeps a writable mount outside /src and /tmp read-only: {all}"); |
|
| 121 | + | assert!(stdout.contains("── greedy failed") && !stdout.contains(" SURVIVED\n"), "{all}"); |
|
| 122 | + | assert!(stderr.contains("the job ran out of memory: the sandbox allows 256 MiB"), "{all}"); |
|
| 123 | + | assert!(stdout.contains("── fails failed (exit 1)"), "{all}"); |
|
| 124 | + | assert!(stdout.contains("── after-failure skipped: fails failed") && !stdout.contains(" SHOULD-NOT-RUN\n"), "{all}"); |
|
| 125 | + | } |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **The syscall tier (#36, docs/m0.md §3).** `runtime "syscall"` jobs run without KVM, so boards such as the OrangePi RV2 can take them. The sandbox uses user, mount, PID, network, IPC and UTS namespaces on the image's unpacked tree, read-only. Landlock limits writes to `/src` and `/tmp`, and a seccomp filter refuses network sockets, tracing, mounts, BPF, keys, modules and io_uring. A delegated cgroup v2 group enforces the CPU and memory limits, and a job killed for memory is told so. A runner offers the tier only where all of this works. `flow tiers` shows which tiers a machine can run and why the others cannot, and `flow run` runs syscall jobs too. |
|
| 8 | + | ||
| 7 | 9 | - **A runner offers only the tiers it can run (#36).** It checks that `/dev/kvm` opens before offering the MicroVM tier, says which tiers it serves when it starts, and refuses to start with none, naming why, rather than claiming jobs it would fail. |
|
| 8 | 10 | ||
| 9 | 11 | - **A runner tries a fetch again after a network hiccup (oxygenhub#111).** Fetching or cloning the job's repository or a subrepo is retried on DNS, connection, timeout and 502/503/504 errors, waiting 2, 4, 8, 16 and 30 seconds; any other error still ends the job at once. |
|
| … | 20 unchanged lines |
modified crates/flow-init/src/main.rs +3 −131
| … | 14 unchanged lines |
||
| 15 | 15 | use std::os::fd::FromRawFd; |
|
| 16 | 16 | use std::sync::{Arc, Mutex}; |
|
| 17 | 17 | ||
| 18 | + | mod job; |
|
| 19 | + | use job::{dev_links, job_env, loopback_up, resolve_program, PATH}; |
|
| 20 | + | ||
| 18 | 21 | #[derive(Deserialize)] |
|
| 19 | 22 | struct JobSpec { |
|
| 20 | 23 | argv: Vec<String>, |
|
| … | 25 unchanged lines |
||
| 46 | 49 | #[serde(default)] |
|
| 47 | 50 | device: Option<String>, |
|
| 48 | 51 | } |
|
| 49 | - | ||
| 50 | - | const PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
|
| 51 | 52 | ||
| 52 | 53 | fn main() { |
|
| 53 | 54 | let console = Arc::new(Mutex::new(std::io::stdout())); |
|
| … | 48 unchanged lines |
||
| 102 | 103 | for d in missing.iter().rev() { |
|
| 103 | 104 | std::fs::create_dir(d).map_err(|e| format!("mkdir {}: {e}", d.display()))?; |
|
| 104 | 105 | chown(&d.to_string_lossy(), uid, gid)?; |
|
| 105 | - | } |
|
| 106 | - | Ok(()) |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | /// The file `program` names: itself when it holds a `/`, otherwise the first |
|
| 110 | - | /// executable regular file of that name in `path`'s directories. |
|
| 111 | - | fn resolve_program(program: &str, path: &str) -> Option<String> { |
|
| 112 | - | use std::os::unix::fs::PermissionsExt; |
|
| 113 | - | if program.is_empty() { |
|
| 114 | - | return None; |
|
| 115 | - | } |
|
| 116 | - | if program.contains('/') { |
|
| 117 | - | return Some(program.to_string()); |
|
| 118 | - | } |
|
| 119 | - | path.split(':') |
|
| 120 | - | .filter(|d| !d.is_empty()) |
|
| 121 | - | .map(|d| format!("{d}/{program}")) |
|
| 122 | - | .find(|p| std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)) |
|
| 123 | - | } |
|
| 124 | - | ||
| 125 | - | /// A job's environment: `PATH` and `HOME=/tmp` by default, then the |
|
| 126 | - | /// image's own (`/etc/flow/env`, written by `flow rootfs import --env`), |
|
| 127 | - | /// then the job's (its sandbox's and its own, already merged by the host). |
|
| 128 | - | /// Later ones win, so a pipeline can always set `PATH`. |
|
| 129 | - | fn job_env(image: Option<&str>, job: &[(String, String)]) -> std::collections::BTreeMap<String, String> { |
|
| 130 | - | let mut env = std::collections::BTreeMap::new(); |
|
| 131 | - | env.insert("PATH".to_string(), PATH.to_string()); |
|
| 132 | - | env.insert("HOME".to_string(), "/tmp".to_string()); |
|
| 133 | - | for line in image.unwrap_or("").lines() { |
|
| 134 | - | if let Some((k, v)) = line.split_once('=') { |
|
| 135 | - | if !k.is_empty() { |
|
| 136 | - | env.insert(k.to_string(), v.to_string()); |
|
| 137 | - | } |
|
| 138 | - | } |
|
| 139 | 106 | } |
|
| 140 | - | for (k, v) in job { |
|
| 141 | - | env.insert(k.clone(), v.clone()); |
|
| 142 | - | } |
|
| 143 | - | env |
|
| 144 | - | } |
|
| 145 | - | ||
| 146 | - | /// The links every Linux system has under /dev and devtmpfs does not |
|
| 147 | - | /// make: `/dev/fd` (bash's process substitution, `<(…)`) and the standard |
|
| 148 | - | /// streams, all through the job's own /proc. |
|
| 149 | - | fn dev_links(dev: &str) -> Result<(), String> { |
|
| 150 | - | for (name, target) in DEV_LINKS { |
|
| 151 | - | let link = format!("{dev}/{name}"); |
|
| 152 | - | if std::fs::symlink_metadata(&link).is_err() { |
|
| 153 | - | std::os::unix::fs::symlink(target, &link).map_err(|e| format!("link {link}: {e}"))?; |
|
| 154 | - | } |
|
| 155 | - | } |
|
| 156 | 107 | Ok(()) |
|
| 157 | 108 | } |
|
| 158 | - | ||
| 159 | - | const DEV_LINKS: [(&str, &str); 4] = |
|
| 160 | - | [("fd", "/proc/self/fd"), ("stdin", "/proc/self/fd/0"), ("stdout", "/proc/self/fd/1"), ("stderr", "/proc/self/fd/2")]; |
|
| 161 | 109 | ||
| 162 | - | /// Set `lo` up; the kernel gives it 127.0.0.1 and ::1 itself. |
|
| 163 | - | fn loopback_up() -> Result<(), String> { |
|
| 164 | - | let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; |
|
| 165 | - | if fd < 0 { |
|
| 166 | - | return Err(format!("socket for lo: {}", std::io::Error::last_os_error())); |
|
| 167 | - | } |
|
| 168 | - | let mut req: libc::ifreq = unsafe { std::mem::zeroed() }; |
|
| 169 | - | for (i, b) in b"lo".iter().enumerate() { |
|
| 170 | - | req.ifr_name[i] = *b as libc::c_char; |
|
| 171 | - | } |
|
| 172 | - | let ok = unsafe { |
|
| 173 | - | libc::ioctl(fd, libc::SIOCGIFFLAGS, &mut req) == 0 && { |
|
| 174 | - | req.ifr_ifru.ifru_flags |= libc::IFF_UP as libc::c_short; |
|
| 175 | - | libc::ioctl(fd, libc::SIOCSIFFLAGS, &req) == 0 |
|
| 176 | - | } |
|
| 177 | - | }; |
|
| 178 | - | let err = std::io::Error::last_os_error(); |
|
| 179 | - | unsafe { libc::close(fd) }; |
|
| 180 | - | if !ok { |
|
| 181 | - | return Err(format!("bring up lo: {err}")); |
|
| 182 | - | } |
|
| 183 | - | Ok(()) |
|
| 184 | - | } |
|
| 185 | - | ||
| 186 | 110 | fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> Result<(), String> { |
|
| 187 | 111 | let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?); |
|
| 188 | 112 | let rc = unsafe { libc::mount(s.as_ptr(), t.as_ptr(), f.as_ptr(), flags, d.as_ptr() as *const libc::c_void) }; |
|
| … | 164 unchanged lines |
||
| 353 | 277 | } |
|
| 354 | 278 | if !line.is_empty() { |
|
| 355 | 279 | frame(console, kind, &line); |
|
| 356 | - | } |
|
| 357 | - | } |
|
| 358 | - | ||
| 359 | - | #[cfg(test)] |
|
| 360 | - | mod tests { |
|
| 361 | - | use super::{dev_links, job_env, resolve_program}; |
|
| 362 | - | ||
| 363 | - | #[test] |
|
| 364 | - | fn the_image_environment_sits_under_the_jobs() { |
|
| 365 | - | let image = "PATH=/opt/llvm-mos/bin:/usr/bin:/bin\nX16EMU=/opt/x16emu\nnot a line\n"; |
|
| 366 | - | let env = job_env(Some(image), &[("X16EMU".into(), "/job".into()), ("LANG".into(), "C".into())]); |
|
| 367 | - | assert_eq!(env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin", "the image's PATH replaces the default"); |
|
| 368 | - | assert_eq!(env["X16EMU"], "/job", "the job's value wins"); |
|
| 369 | - | assert_eq!(env["HOME"], "/tmp"); |
|
| 370 | - | assert_eq!(env["LANG"], "C"); |
|
| 371 | - | assert_eq!(job_env(None, &[])["PATH"], super::PATH); |
|
| 372 | - | } |
|
| 373 | - | ||
| 374 | - | #[test] |
|
| 375 | - | fn dev_gets_the_usual_links_once() { |
|
| 376 | - | let dir = std::env::temp_dir().join(format!("flow-init-dev-{}", std::process::id())); |
|
| 377 | - | let _ = std::fs::remove_dir_all(&dir); |
|
| 378 | - | std::fs::create_dir_all(&dir).unwrap(); |
|
| 379 | - | let d = dir.to_str().unwrap(); |
|
| 380 | - | dev_links(d).unwrap(); |
|
| 381 | - | dev_links(d).unwrap(); |
|
| 382 | - | assert_eq!(std::fs::read_link(dir.join("fd")).unwrap(), std::path::Path::new("/proc/self/fd")); |
|
| 383 | - | assert_eq!(std::fs::read_link(dir.join("stdout")).unwrap(), std::path::Path::new("/proc/self/fd/1")); |
|
| 384 | - | // Through the host's /proc, the link works as in a job. |
|
| 385 | - | assert!(dir.join("fd").join("0").exists() || !std::path::Path::new("/proc/self/fd/0").exists()); |
|
| 386 | - | std::fs::remove_dir_all(&dir).unwrap(); |
|
| 387 | - | } |
|
| 388 | - | ||
| 389 | - | use std::os::unix::fs::PermissionsExt; |
|
| 390 | - | ||
| 391 | - | #[test] |
|
| 392 | - | fn programs_are_found_in_the_jobs_path() { |
|
| 393 | - | let dir = std::env::temp_dir().join(format!("flow-init-test-{}", std::process::id())); |
|
| 394 | - | let (a, b) = (dir.join("a"), dir.join("b")); |
|
| 395 | - | std::fs::create_dir_all(&a).unwrap(); |
|
| 396 | - | std::fs::create_dir_all(&b).unwrap(); |
|
| 397 | - | std::fs::write(a.join("tool"), "not executable").unwrap(); |
|
| 398 | - | std::fs::write(b.join("tool"), "#!/bin/sh\n").unwrap(); |
|
| 399 | - | std::fs::set_permissions(b.join("tool"), std::fs::Permissions::from_mode(0o755)).unwrap(); |
|
| 400 | - | std::fs::create_dir_all(a.join("dir")).unwrap(); |
|
| 401 | - | let path = format!("{}::{}", a.display(), b.display()); |
|
| 402 | - | assert_eq!(resolve_program("tool", &path), Some(format!("{}/tool", b.display())), "skips a file that is not executable"); |
|
| 403 | - | assert_eq!(resolve_program("dir", &path), None, "and directories"); |
|
| 404 | - | assert_eq!(resolve_program("missing", &path), None); |
|
| 405 | - | assert_eq!(resolve_program("./x", &path).as_deref(), Some("./x"), "a path is used as given"); |
|
| 406 | - | assert_eq!(resolve_program("", &path), None); |
|
| 407 | - | std::fs::remove_dir_all(&dir).unwrap(); |
|
| 408 | 280 | } |
|
| 409 | 281 | } |
modified crates/flow/src/main.rs +43 −6
| … | 1 unchanged lines |
||
| 2 | 2 | //! (docs/spec.md, docs/m0.md). |
|
| 3 | 3 | ||
| 4 | 4 | mod cpio; |
|
| 5 | + | /// The job-starting code `flow-init` uses in a microVM, shared with the |
|
| 6 | + | /// syscall tier. |
|
| 7 | + | #[path = "../../flow-init/src/job.rs"] |
|
| 8 | + | mod guest; |
|
| 5 | 9 | mod home; |
|
| 6 | 10 | mod microvm; |
|
| 7 | 11 | mod outputs; |
|
| 8 | 12 | mod runner; |
|
| 13 | + | mod syscall; |
|
| 9 | 14 | mod wait; |
|
| 10 | 15 | mod workspace; |
|
| 11 | 16 | ||
| … | 88 unchanged lines |
||
| 100 | 105 | #[arg(long, default_value_t = 60)] |
|
| 101 | 106 | timeout: u64, |
|
| 102 | 107 | }, |
|
| 108 | + | /// Show which isolation tiers this machine can run jobs in, and why |
|
| 109 | + | /// the others cannot |
|
| 110 | + | Tiers, |
|
| 111 | + | /// The syscall tier's sandbox helper, started by the runner for each job |
|
| 112 | + | #[command(name = "__sandbox", hide = true)] |
|
| 113 | + | Sandbox { spec: PathBuf }, |
|
| 114 | + | /// Check that this machine can run the syscall tier |
|
| 115 | + | #[command(name = "__sandbox-probe", hide = true)] |
|
| 116 | + | SandboxProbe, |
|
| 103 | 117 | } |
|
| 104 | 118 | ||
| 105 | 119 | #[derive(Subcommand)] |
|
| … | 24 unchanged lines |
||
| 130 | 144 | ||
| 131 | 145 | fn run(cli: Cli) -> Result<()> { |
|
| 132 | 146 | match cli.cmd { |
|
| 147 | + | Cmd::Sandbox { spec } => std::process::exit(syscall::sandbox_main(&spec)), |
|
| 148 | + | Cmd::SandboxProbe => std::process::exit(syscall::probe_main()), |
|
| 133 | 149 | Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))), |
|
| 134 | 150 | Cmd::Hash { file } => { |
|
| 135 | 151 | println!("{}", home::hash_file(&file)?); |
|
| … | 57 unchanged lines |
||
| 193 | 209 | } |
|
| 194 | 210 | runner::serve(&home) |
|
| 195 | 211 | } |
|
| 212 | + | Cmd::Tiers => { |
|
| 213 | + | let home = Home::locate(cli.home.as_deref())?; |
|
| 214 | + | match runner::tiers(&home) { |
|
| 215 | + | Ok((have, missing)) => { |
|
| 216 | + | for t in have { |
|
| 217 | + | println!("{t}: available"); |
|
| 218 | + | } |
|
| 219 | + | for why in missing { |
|
| 220 | + | println!("{}", why.replacen(": ", ": unavailable: ", 1)); |
|
| 221 | + | } |
|
| 222 | + | } |
|
| 223 | + | Err(why) => println!("{why}"), |
|
| 224 | + | } |
|
| 225 | + | Ok(()) |
|
| 226 | + | } |
|
| 196 | 227 | Cmd::Wait { url, state, timeout } => { |
|
| 197 | 228 | let token = std::env::var("OXY_TOKEN").ok(); |
|
| 198 | 229 | let ca = std::env::var_os("OXY_CA").map(PathBuf::from); |
|
| … | 179 unchanged lines |
||
| 378 | 409 | continue; |
|
| 379 | 410 | } |
|
| 380 | 411 | let sb = &p.sandboxes[&job.sandbox]; |
|
| 381 | - | if sb.runtime != Runtime::Microvm { |
|
| 412 | + | if sb.runtime == Runtime::Wasm { |
|
| 382 | 413 | bail!("job {}: runtime {} is not available in this build yet", job.name, sb.runtime.as_str()); |
|
| 383 | 414 | } |
|
| 384 | 415 | println!("── {} ({})", job.name, job.exec.join(" ")); |
|
| 385 | 416 | let js = scratch.join(&job.name); |
|
| 386 | 417 | std::fs::create_dir_all(&js)?; |
|
| 387 | - | // Each job starts from the same files: jobs do not share writes. |
|
| 388 | - | workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?; |
|
| 389 | 418 | let job = &job |
|
| 390 | 419 | .in_sandbox(sb) |
|
| 391 | 420 | .with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())]) |
|
| … | 13 unchanged lines |
||
| 405 | 434 | .ok_or_else(|| anyhow::anyhow!("job {}: secret {:?} is not given; pass --secret {}=VALUE", job.name, s.name, s.name)) |
|
| 406 | 435 | }) |
|
| 407 | 436 | .collect::<Result<Vec<_>>>()?; |
|
| 408 | - | let out = microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?; |
|
| 409 | - | let _ = std::fs::remove_dir_all(&js); |
|
| 437 | + | // Each job starts from the same files: jobs do not share writes. |
|
| 438 | + | let out = if sb.runtime == Runtime::Syscall { |
|
| 439 | + | workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?; |
|
| 440 | + | let group = format!("local-{}-{}", std::process::id(), js.file_name().unwrap_or_default().to_string_lossy()); |
|
| 441 | + | syscall::run(home, sb, job, &inputs, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r), &group)? |
|
| 442 | + | } else { |
|
| 443 | + | workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?; |
|
| 444 | + | microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))? |
|
| 445 | + | }; |
|
| 446 | + | let _ = workspace::remove_tree(&js); |
|
| 410 | 447 | match out.exit_code { |
|
| 411 | 448 | Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms), |
|
| 412 | 449 | other => { |
|
| … | 8 unchanged lines |
||
| 421 | 458 | } |
|
| 422 | 459 | } |
|
| 423 | 460 | } |
|
| 424 | - | let _ = std::fs::remove_dir_all(&scratch); |
|
| 461 | + | let _ = workspace::remove_tree(&scratch); |
|
| 425 | 462 | if worst != 0 { |
|
| 426 | 463 | std::process::exit(worst); |
|
| 427 | 464 | } |
|
| … | 25 unchanged lines |
modified crates/flow/src/outputs.rs +81 −3
| 1 | - | //! A job's artifacts (spec §5.2): read from its disks after the microVM |
|
| 2 | - | //! has stopped, with debugfs, so the runner needs no mount rights. |
|
| 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). |
|
| 3 | 4 | ||
| 4 | 5 | use anyhow::{bail, Context, Result}; |
|
| 5 | 6 | use flow_core::{Job, MountSource}; |
|
| … | 6 unchanged lines |
||
| 12 | 13 | /// inside its file system. The workspace is `/src`; each `ephemeral://disk` |
|
| 13 | 14 | /// mount is `mount-N.ext4` in `scratch`, numbered as `microvm::run` does. |
|
| 14 | 15 | pub fn locate(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) { |
|
| 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) { |
|
| 15 | 26 | for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() { |
|
| 16 | 27 | if let Some(rest) = path.strip_prefix(&format!("{}/", m.target)) { |
|
| 17 | - | return (scratch.join(format!("mount-{i}.ext4")), format!("/{rest}")); |
|
| 28 | + | return (disk(i), format!("/{rest}")); |
|
| 18 | 29 | } |
|
| 19 | 30 | } |
|
| 20 | 31 | (workspace.to_path_buf(), path.strip_prefix("/src").unwrap_or(path).to_string()) |
|
| 21 | 32 | } |
|
| 22 | 33 | ||
| 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 | + | ||
| 23 | 83 | fn debugfs(image: &Path, request: &str) -> Result<String> { |
|
| 24 | 84 | let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?; |
|
| 25 | 85 | if !out.status.success() { |
|
| … | 64 unchanged lines |
||
| 90 | 150 | assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into())); |
|
| 91 | 151 | assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into())); |
|
| 92 | 152 | assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent"); |
|
| 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())); |
|
| 93 | 171 | } |
|
| 94 | 172 | ||
| 95 | 173 | #[test] |
|
| … | 14 unchanged lines |
modified crates/flow/src/runner.rs +50 −25
| … | 279 unchanged lines |
||
| 280 | 280 | } |
|
| 281 | 281 | ||
| 282 | 282 | /// The isolation tiers this machine can honour now (spec §11.1): the |
|
| 283 | - | /// MicroVM tier only when `/dev/kvm` opens for reading and writing and |
|
| 284 | - | /// flow.toml pins Firecracker. `Err` says why there are none. |
|
| 285 | - | pub fn tiers(home: &Home) -> std::result::Result<Vec<String>, String> { |
|
| 283 | + | /// MicroVM tier when `/dev/kvm` opens for reading and writing and |
|
| 284 | + | /// flow.toml pins Firecracker; the syscall tier when this binary may use |
|
| 285 | + | /// user namespaces, Landlock and seccomp and manage a delegated cgroup. |
|
| 286 | + | /// `Err` says why there are none; the reasons for missing tiers come back |
|
| 287 | + | /// with the ones there are. |
|
| 288 | + | pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> { |
|
| 289 | + | let mut have = Vec::new(); |
|
| 290 | + | let mut missing = Vec::new(); |
|
| 286 | 291 | let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm"); |
|
| 287 | 292 | match (kvm, home.config.microvm.is_some()) { |
|
| 288 | - | (Ok(_), true) => Ok(vec!["microvm".into()]), |
|
| 289 | - | (Ok(_), false) => Err("flow.toml has no [microvm] section, so no tier can run".into()), |
|
| 290 | - | (Err(e), _) => Err(format!( |
|
| 291 | - | "/dev/kvm cannot be opened ({e}): the MicroVM tier needs hardware virtualisation and this user in the kvm group, and no other tier is in this build" |
|
| 293 | + | (Ok(_), true) => have.push("microvm".to_string()), |
|
| 294 | + | (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()), |
|
| 295 | + | (Err(e), _) => missing.push(format!( |
|
| 296 | + | "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group" |
|
| 292 | 297 | )), |
|
| 293 | 298 | } |
|
| 299 | + | match crate::syscall::available() { |
|
| 300 | + | Ok(()) => have.push("syscall".to_string()), |
|
| 301 | + | Err(e) => missing.push(format!("syscall: {e}")), |
|
| 302 | + | } |
|
| 303 | + | if have.is_empty() { |
|
| 304 | + | return Err(format!("no isolation tier can run here: {}", missing.join("; "))); |
|
| 305 | + | } |
|
| 306 | + | Ok((have, missing)) |
|
| 294 | 307 | } |
|
| 295 | 308 | ||
| 296 | 309 | pub fn serve(home: &Home) -> Result<()> { |
|
| 297 | 310 | let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?; |
|
| 298 | 311 | // A runner offers only what it can run: a machine without KVM claims |
|
| 299 | 312 | // nothing rather than taking MicroVM jobs and failing them (#36). |
|
| 300 | - | let runtimes = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?; |
|
| 313 | + | let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?; |
|
| 314 | + | for why in &missing { |
|
| 315 | + | eprintln!("flow runner: not offering {why}"); |
|
| 316 | + | } |
|
| 301 | 317 | let hub = Hub::new(&cfg)?; |
|
| 302 | 318 | let name = cfg.name.clone().unwrap_or_else(|| "flow".into()); |
|
| 303 | 319 | let budget = Budget::of(&cfg); |
|
| … | 206 unchanged lines |
||
| 510 | 526 | files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h))); |
|
| 511 | 527 | } |
|
| 512 | 528 | let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h))); |
|
| 513 | - | workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?; |
|
| 514 | - | std::fs::remove_dir_all(scratch.join("tree"))?; |
|
| 529 | + | // The syscall tier runs on the tree itself; a microVM gets an image. |
|
| 530 | + | let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall; |
|
| 531 | + | if !syscall_tier { |
|
| 532 | + | workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?; |
|
| 533 | + | std::fs::remove_dir_all(scratch.join("tree"))?; |
|
| 534 | + | } |
|
| 515 | 535 | preparing.take(); |
|
| 516 | - | // Cancelled while it was being prepared: no microVM. |
|
| 536 | + | // Cancelled while it was being prepared: no sandbox. |
|
| 517 | 537 | if microvm::Sink::cancelled(&mut sink) { |
|
| 518 | 538 | return Ok(cancelled()); |
|
| 519 | 539 | } |
|
| 520 | 540 | note( |
|
| 521 | 541 | &mut sink, |
|
| 522 | 542 | format!( |
|
| 523 | - | "booting a microVM: {} vCPU, {} MiB, rootfs {}", |
|
| 543 | + | "{}: {} {}, {} MiB, rootfs {}", |
|
| 544 | + | if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" }, |
|
| 524 | 545 | claim.sandbox.cpus, |
|
| 546 | + | if syscall_tier { "CPU" } else { "vCPU" }, |
|
| 525 | 547 | claim.sandbox.memory_mib, |
|
| 526 | 548 | &claim.sandbox.rootfs[..12] |
|
| 527 | 549 | ), |
|
| 528 | 550 | ); |
|
| 529 | 551 | let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env()); |
|
| 530 | - | let outcome = microvm::run( |
|
| 531 | - | home, |
|
| 532 | - | &claim.sandbox, |
|
| 533 | - | &job, |
|
| 534 | - | &claim.secrets, |
|
| 535 | - | &scratch.join("workspace.ext4"), |
|
| 536 | - | &scratch, |
|
| 537 | - | Duration::from_secs(claim.timeout_s), |
|
| 538 | - | &mut sink, |
|
| 539 | - | )?; |
|
| 552 | + | let timeout = Duration::from_secs(claim.timeout_s); |
|
| 553 | + | let outcome = if syscall_tier { |
|
| 554 | + | let group = format!("job-{}", claim.job_id); |
|
| 555 | + | crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)? |
|
| 556 | + | } else { |
|
| 557 | + | microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)? |
|
| 558 | + | }; |
|
| 540 | 559 | if outcome.cancelled { |
|
| 541 | 560 | return Ok(cancelled()); |
|
| 542 | 561 | } |
|
| … | 3 unchanged lines |
||
| 546 | 565 | let mut output_error = None; |
|
| 547 | 566 | if outcome.exit_code == Some(0) { |
|
| 548 | 567 | for o in &claim.job.outputs { |
|
| 549 | - | let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
|
| 550 | - | let kept = crate::outputs::read(&image, &inner, &scratch).and_then(|bytes| { |
|
| 568 | + | let read = if syscall_tier { |
|
| 569 | + | let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
|
| 570 | + | crate::outputs::read_dir_file(&dir, &inner) |
|
| 571 | + | } else { |
|
| 572 | + | let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
|
| 573 | + | crate::outputs::read(&image, &inner, &scratch) |
|
| 574 | + | }; |
|
| 575 | + | let kept = read.and_then(|bytes| { |
|
| 551 | 576 | let blob = Repo::blob_ref(&bytes); |
|
| 552 | 577 | let named = hub.artifact(claim.job_id, &o.name, &bytes)?; |
|
| 553 | 578 | if named != blob.to_hex() { |
|
| … | 36 unchanged lines |
||
| 590 | 615 | error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())), |
|
| 591 | 616 | }) |
|
| 592 | 617 | })(); |
|
| 593 | - | let _ = std::fs::remove_dir_all(&scratch); |
|
| 618 | + | let _ = workspace::remove_tree(&scratch); |
|
| 594 | 619 | sink.flush(); |
|
| 595 | 620 | result |
|
| 596 | 621 | } |
|
| … | 308 unchanged lines |
modified crates/flow/src/workspace.rs +27 −0
| … | 132 unchanged lines |
||
| 133 | 133 | } |
|
| 134 | 134 | Ok(()) |
|
| 135 | 135 | } |
|
| 136 | + | ||
| 137 | + | /// Remove `dir` and everything in it, even where a job took away its own |
|
| 138 | + | /// write or search permission on a directory; links are never followed. |
|
| 139 | + | pub fn remove_tree(dir: &Path) -> std::io::Result<()> { |
|
| 140 | + | match std::fs::remove_dir_all(dir) { |
|
| 141 | + | Ok(()) => Ok(()), |
|
| 142 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), |
|
| 143 | + | Err(_) => { |
|
| 144 | + | open_up(dir); |
|
| 145 | + | std::fs::remove_dir_all(dir) |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | fn open_up(dir: &Path) { |
|
| 151 | + | use std::os::unix::fs::PermissionsExt; |
|
| 152 | + | let Ok(meta) = std::fs::symlink_metadata(dir) else { return }; |
|
| 153 | + | if !meta.is_dir() { |
|
| 154 | + | return; |
|
| 155 | + | } |
|
| 156 | + | let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); |
|
| 157 | + | if let Ok(entries) = std::fs::read_dir(dir) { |
|
| 158 | + | for e in entries.flatten() { |
|
| 159 | + | open_up(&e.path()); |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | } |
modified docs/m0.md +16 −2
| … | 167 unchanged lines |
||
| 168 | 168 | `ptrace`, `mount`, `bpf`, `perf_event_open`, `keyctl` and module calls; |
|
| 169 | 169 | `cpu-limit` and `memory-limit` apply through a cgroup v2 scope when the |
|
| 170 | 170 | runner has a delegated cgroup, and the job fails to start otherwise |
|
| 171 | - | rather than run unbounded. Same frames and receipts as tier 2. Done after |
|
| 172 | - | tier 2 works on Earth. |
|
| 171 | + | rather than run unbounded. Same frames and receipts as tier 2. |
|
| 173 | 172 | ||
| 173 | + | **How it runs.** |
|
| 174 | + | - **Helper:** for each job the runner starts `flow __sandbox <spec>` (its own binary, through `/proc/self/exe`). The helper joins the job's cgroup, makes the namespaces with the job's user (uid and gid 1000) mapped to the runner's, and forks the sandbox's PID 1. |
|
| 175 | + | - **Root:** PID 1 binds the unpacked tree read-only as the new root, the workspace at `/src`, tmpfs at `/tmp`, `/run` and `/run/secrets`, a fresh `/proc`, and a `/dev` of `null`, `zero`, `full`, `random` and `urandom` with `/dev/shm`. It brings `lo` up for `network "loopback"`, pivots into the root and detaches the old one, so the host's files, the spec and the scratch directory are out of reach. |
|
| 176 | + | - **Job:** the job gets no new privileges, then Landlock (read and execute everywhere, write beneath `/src`, `/tmp`, `/dev` and its mounts), then the seccomp filter, then `execve` with exactly the job's environment, found the way `flow-init` finds it (the two share that code). The filter also refuses io_uring (which could open sockets past it), the new mount calls, kexec, swap and reboot. |
|
| 177 | + | - **Unpacking:** `debugfs rdump` unpacks the image into `trees/<hash>` in the home once, without mount rights. Set-id bits and device nodes are dropped. |
|
| 178 | + | - **Ephemeral mounts:** a `tmpfs` mount is a tmpfs of its size. An `ephemeral://disk` mount is a scratch directory, so its size is not enforced in this tier. |
|
| 179 | + | - **Artifacts:** they are read from the workspace and mount directories without following links. |
|
| 180 | + | - **Receipts:** the environment's runtime is `syscall`. Its parts are the `flow` binary's BLAKE3, the host kernel's release, memory, CPUs and mounts. |
|
| 181 | + | - **Limits:** the job's cgroup gets `memory.max`, `memory.swap.max 0` and `cpu.max`. A job killed for memory is told so in its log. At the end the group is killed and removed. |
|
| 182 | + | ||
| 183 | + | **Where it is offered.** `flow tiers` prints the tiers this machine can run and why the others cannot; a runner offers only those (spec §11.1). The syscall tier needs: |
|
| 184 | + | - **Namespaces:** unprivileged user namespaces for the `flow` binary. Ubuntu's `kernel.apparmor_restrict_unprivileged_userns=1` can deny them to a program without an AppArmor profile. |
|
| 185 | + | - **Kernel features:** Landlock in the kernel's LSM list, and seccomp filters. |
|
| 186 | + | - **Cgroup:** a cgroup v2 group the runner may manage, with the cpu and memory controllers and no other process in it. That is a systemd service with `Delegate=yes`, or a user service under `systemd --user`. The runner moves itself into a `flow-main` leaf of that group and makes one group per job beside it. For `flow run`, use `systemd-run --user --scope -p Delegate=yes flow run`. |
|
| 187 | + | ||
| 174 | 188 | ## 4. Receipts |
|
| 175 | 189 | ||
| 176 | 190 | Every finished job gets a receipt (spec §6.1): `job_name`, |
|
| … | 63 unchanged lines |