OXYGENHUB
# 9296a15494d3 oxygenflow / crates / flow-init / src / main.rs
13.8 KiB · Rust · last changed Prune build caches before they are kept 16 days ago source blame history raw
1
//! PID 1 of a job's microVM (docs/m0.md §2). It mounts the read-only
2
//! rootfs and the workspace, runs the job's argv as an unprivileged user
3
//! with exactly the job's environment, frames the output on the serial
4
//! console for the host, and powers off. It never decides anything the
5
//! host has not: the job comes from `/job.json` in the initramfs the host
6
//! built, and the host enforces the time limit.
7
//!
8
//! Frames, one per line on the console: `\x1eO ` stdout, `\x1eE ` stderr,
9
//! `\x1eX <code>` the exit code (128 + signal when killed), `\x1eF <text>`
10
//! a failure of init itself, `\x1eC <json>` a build cache pruned before
11
//! the guest powers off (oxygenflow#72): `{"target": ..., "pruned":
12
//! flow_init::prune::Pruned}`, sent before the exit code.
13
14
use flow_init::prune;
15
use serde::Deserialize;
16
use std::ffi::CString;
17
use std::io::{Read, Write};
18
use std::os::fd::FromRawFd;
19
use std::sync::{Arc, Mutex};
20
21
mod egress;
22
mod job;
23
use job::{dev_links, job_env, loopback_up, resolve_program, PATH};
24
25
#[derive(Deserialize)]
26
struct JobSpec {
27
    argv: Vec<String>,
28
    env: Vec<(String, String)>,
29
    #[serde(default)]
30
    mounts: Vec<MountSpec>,
31
    /// `network "loopback"`: bring up `lo` (the guest has no other device).
32
    #[serde(default)]
33
    loopback: bool,
34
    /// `network "egress"` (oxygenflow#55): `lo` comes up and a proxy on
35
    /// 127.0.0.1:3128 relays over vsock to the runner.
36
    #[serde(default)]
37
    egress: bool,
38
    /// Secrets to write before the job starts (spec §9): each at `path`
39
    /// under `/run/secrets`, 0400 and the job's user's, on a tmpfs.
40
    #[serde(default)]
41
    secrets: Vec<SecretSpec>,
42
}
43
44
#[derive(serde::Deserialize)]
45
struct SecretSpec {
46
    path: String,
47
    value: String,
48
}
49
50
/// An ephemeral mount from the host: a tmpfs of `size_mib`, or the disk at
51
/// `device`, mounted at `target` under the new root.
52
#[derive(serde::Deserialize)]
53
struct MountSpec {
54
    kind: String,
55
    target: String,
56
    size_mib: u32,
57
    #[serde(default)]
58
    device: Option<String>,
59
    /// A build cache's disk (oxygenflow#72): mounted `strictatime`, so
60
    /// what the job reads is seen, and after a passing job pruned to this
61
    /// percentage of its size before the guest powers off.
62
    #[serde(default)]
63
    prune: Option<u8>,
64
    /// Paths on the disk, relative to it, that pruning leaves: the job's
65
    /// outputs, which the runner reads after the guest stops.
66
    #[serde(default)]
67
    keep: Vec<String>,
68
}
69
70
fn main() {
71
    let console = Arc::new(Mutex::new(std::io::stdout()));
72
    let code = match run(&console) {
73
        Ok(code) => code,
74
        Err(e) => {
75
            frame(&console, b'F', e.as_bytes());
76
            125
77
        }
78
    };
79
    frame(&console, b'X', code.to_string().as_bytes());
80
    unsafe {
81
        libc::sync();
82
        // Firecracker ends the VM when the guest reboots (reboot=k).
83
        libc::reboot(libc::LINUX_REBOOT_CMD_RESTART);
84
    }
85
}
86
87
fn frame(console: &Mutex<std::io::Stdout>, kind: u8, text: &[u8]) {
88
    let mut out = console.lock().unwrap_or_else(|p| p.into_inner());
89
    let mut line = Vec::with_capacity(text.len() + 4);
90
    line.extend_from_slice(&[0x1e, kind, b' ']);
91
    line.extend_from_slice(text);
92
    line.push(b'\n');
93
    let _ = out.write_all(&line);
94
    let _ = out.flush();
95
}
96
97
fn cstr(s: &str) -> Result<CString, String> {
98
    CString::new(s).map_err(|_| format!("{s:?} contains NUL"))
99
}
100
101
fn chown(path: &str, uid: libc::uid_t, gid: libc::gid_t) -> Result<(), String> {
102
    let p = cstr(path)?;
103
    if unsafe { libc::chown(p.as_ptr(), uid, gid) } != 0 {
104
        return Err(format!("chown {path}: {}", std::io::Error::last_os_error()));
105
    }
106
    Ok(())
107
}
108
109
/// Create `path` and its missing parents, each owned by the job's user.
110
fn make_dirs_owned(path: &str, uid: libc::uid_t, gid: libc::gid_t) -> Result<(), String> {
111
    let mut missing = Vec::new();
112
    let mut p = std::path::Path::new(path);
113
    while !p.exists() {
114
        missing.push(p.to_path_buf());
115
        p = match p.parent() {
116
            Some(parent) => parent,
117
            None => break,
118
        };
119
    }
120
    for d in missing.iter().rev() {
121
        std::fs::create_dir(d).map_err(|e| format!("mkdir {}: {e}", d.display()))?;
122
        chown(&d.to_string_lossy(), uid, gid)?;
123
    }
124
    Ok(())
125
}
126
127
fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> Result<(), String> {
128
    let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?);
129
    let rc = unsafe { libc::mount(s.as_ptr(), t.as_ptr(), f.as_ptr(), flags, d.as_ptr() as *const libc::c_void) };
130
    if rc != 0 {
131
        return Err(format!("mount {src} on {target}: {}", std::io::Error::last_os_error()));
132
    }
133
    Ok(())
134
}
135
136
fn run(console: &Arc<Mutex<std::io::Stdout>>) -> Result<i32, String> {
137
    let job: JobSpec = serde_json::from_slice(&std::fs::read("/job.json").map_err(|e| format!("read /job.json: {e}"))?)
138
        .map_err(|e| format!("parse /job.json: {e}"))?;
139
    if job.argv.is_empty() {
140
        return Err("empty argv".into());
141
    }
142
    let nosuid = libc::MS_NOSUID | libc::MS_NODEV;
143
    std::fs::create_dir_all("/dev").ok();
144
    mount("devtmpfs", "/dev", "devtmpfs", libc::MS_NOSUID, "")?;
145
    std::fs::create_dir_all("/newroot").map_err(|e| e.to_string())?;
146
    mount("/dev/vda", "/newroot", "ext4", libc::MS_RDONLY | libc::MS_NODEV, "")?;
147
    mount("/dev/vdb", "/newroot/src", "ext4", nosuid, "")?;
148
    mount("tmpfs", "/newroot/tmp", "tmpfs", nosuid, "mode=1777")?;
149
    mount("proc", "/newroot/proc", "proc", nosuid | libc::MS_NOEXEC, "")?;
150
    mount("devtmpfs", "/newroot/dev", "devtmpfs", libc::MS_NOSUID, "")?;
151
    dev_links("/newroot/dev")?;
152
    if std::path::Path::new("/newroot/run").is_dir() {
153
        mount("tmpfs", "/newroot/run", "tmpfs", nosuid, "mode=755")?;
154
    }
155
    // The job runs as whoever owns the workspace (the host's builder), never root.
156
    let (uid, gid) = {
157
        let mut st: libc::stat = unsafe { std::mem::zeroed() };
158
        let p = cstr("/newroot/src")?;
159
        if unsafe { libc::stat(p.as_ptr(), &mut st) } != 0 {
160
            return Err("stat /src".into());
161
        }
162
        if st.st_uid == 0 {
163
            (1000, 1000)
164
        } else {
165
            (st.st_uid, st.st_gid)
166
        }
167
    };
168
    // Ephemeral mounts: mount points are made (and owned by the job's user)
169
    // under the writable /src or /tmp, then each is mounted and given to it.
170
    for m in &job.mounts {
171
        let target = format!("/newroot{}", m.target);
172
        make_dirs_owned(&target, uid, gid)?;
173
        match (m.kind.as_str(), &m.device) {
174
            ("tmpfs", _) => mount("tmpfs", &target, "tmpfs", nosuid, &format!("size={}m,uid={uid},gid={gid},mode=755", m.size_mib))?,
175
            ("disk", Some(dev)) => {
176
                let atime = if m.prune.is_some() { libc::MS_STRICTATIME | libc::MS_LAZYTIME } else { 0 };
177
                mount(dev, &target, "ext4", nosuid | atime, "")?;
178
                chown(&target, uid, gid)?;
179
            }
180
            (kind, _) => return Err(format!("unknown mount kind {kind}")),
181
        }
182
    }
183
    // Secrets: an in-memory mount of their own, only the job's user's,
184
    // each file 0400; the job file they came from is outside the new root.
185
    if !job.secrets.is_empty() {
186
        if !std::path::Path::new("/newroot/run").is_dir() {
187
            return Err("the image has no /run, where secrets are mounted".into());
188
        }
189
        let dir = "/newroot/run/secrets";
190
        std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {dir}: {e}"))?;
191
        mount("tmpfs", dir, "tmpfs", nosuid | libc::MS_NOEXEC, &format!("size=1m,uid={uid},gid={gid},mode=700"))?;
192
        for s in &job.secrets {
193
            let Some(rel) = s.path.strip_prefix("/run/secrets/") else {
194
                return Err(format!("secret path {:?} is not under /run/secrets/", s.path));
195
            };
196
            let full = format!("{dir}/{rel}");
197
            if let Some(parent) = std::path::Path::new(&full).parent().and_then(|p| p.to_str()) {
198
                make_dirs_owned(parent, uid, gid)?;
199
            }
200
            std::fs::write(&full, s.value.as_bytes()).map_err(|e| format!("write {}: {e}", s.path))?;
201
            chown(&full, uid, gid)?;
202
            std::fs::set_permissions(&full, std::os::unix::fs::PermissionsExt::from_mode(0o400))
203
                .map_err(|e| format!("chmod {}: {e}", s.path))?;
204
        }
205
    }
206
    if job.loopback || job.egress {
207
        loopback_up()?;
208
    }
209
    if job.egress {
210
        egress::start()?;
211
    }
212
    let root = cstr("/newroot")?;
213
    if unsafe { libc::chroot(root.as_ptr()) } != 0 || std::env::set_current_dir("/src").is_err() {
214
        return Err(format!("enter the rootfs: {}", std::io::Error::last_os_error()));
215
    }
216
217
    // What the job reads or writes on a cache disk from now on is what
218
    // this run used.
219
    let started = job.mounts.iter().any(|m| m.prune.is_some()).then(prune::start_clock);
220
    let argv: Vec<CString> = job.argv.iter().map(|a| cstr(a)).collect::<Result<_, _>>()?;
221
    // glibc's execvpe searches the caller's PATH, and PID 1 has none of its
222
    // own, so the program is found here, in the job's PATH, before forking.
223
    let vars = job_env(std::fs::read_to_string("/etc/flow/env").ok().as_deref(), &job.env);
224
    let path = vars.get("PATH").map(String::as_str).unwrap_or(PATH);
225
    let program = cstr(&resolve_program(&job.argv[0], path).ok_or_else(|| format!("cannot run {:?}: not found in {path}", job.argv[0]))?)?;
226
    let env: Vec<CString> = vars.iter().map(|(k, v)| cstr(&format!("{k}={v}"))).collect::<Result<_, _>>()?;
227
    let mut out = [0; 2];
228
    let mut err = [0; 2];
229
    if unsafe { libc::pipe2(out.as_mut_ptr(), libc::O_CLOEXEC) } != 0 || unsafe { libc::pipe2(err.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
230
        return Err("pipe".into());
231
    }
232
    let pid = unsafe { libc::fork() };
233
    if pid < 0 {
234
        return Err("fork".into());
235
    }
236
    if pid == 0 {
237
        // Child: no way back to root, then the program itself (execvpe: no shell).
238
        unsafe {
239
            let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY);
240
            libc::dup2(null, 0);
241
            libc::dup2(out[1], 1);
242
            libc::dup2(err[1], 2);
243
            libc::setsid();
244
            if libc::setgroups(0, std::ptr::null()) != 0 || libc::setgid(gid) != 0 || libc::setuid(uid) != 0 {
245
                libc::_exit(126);
246
            }
247
            let mut a: Vec<*const libc::c_char> = argv.iter().map(|c| c.as_ptr()).collect();
248
            a.push(std::ptr::null());
249
            let mut e: Vec<*const libc::c_char> = env.iter().map(|c| c.as_ptr()).collect();
250
            e.push(std::ptr::null());
251
            libc::execve(program.as_ptr(), a.as_ptr(), e.as_ptr());
252
            let msg = format!("flow: cannot run {:?}: {}\n", job.argv[0], std::io::Error::last_os_error());
253
            libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
254
            libc::_exit(127);
255
        }
256
    }
257
    unsafe {
258
        libc::close(out[1]);
259
        libc::close(err[1]);
260
    }
261
    let readers: Vec<_> = [(out[0], b'O'), (err[0], b'E')]
262
        .into_iter()
263
        .map(|(fd, kind)| {
264
            let console = console.clone();
265
            std::thread::spawn(move || pump(unsafe { std::fs::File::from_raw_fd(fd) }, kind, &console))
266
        })
267
        .collect();
268
    let mut status = 0;
269
    // Reap everything (we are PID 1) until the job itself exits.
270
    loop {
271
        let r = unsafe { libc::waitpid(-1, &mut status, 0) };
272
        if r == pid || r < 0 {
273
            break;
274
        }
275
    }
276
    for r in readers {
277
        let _ = r.join();
278
    }
279
    let code = if libc::WIFEXITED(status) { libc::WEXITSTATUS(status) } else { 128 + libc::WTERMSIG(status) };
280
    // A failing job's disks are thrown away: only a passing one's are
281
    // worth pruning.
282
    if let (Some(started), 0) = (started, code) {
283
        prune_caches(console, &job.mounts, started);
284
    }
285
    Ok(code)
286
}
287
288
/// Prune each cache disk and say so to the host. Whatever the job left
289
/// running stops first, so nothing changes the disks meanwhile.
290
fn prune_caches(console: &Mutex<std::io::Stdout>, mounts: &[MountSpec], started: std::time::SystemTime) {
291
    unsafe {
292
        libc::kill(-1, libc::SIGKILL);
293
        let mut status = 0;
294
        while libc::waitpid(-1, &mut status, 0) > 0 {}
295
    }
296
    for m in mounts {
297
        let Some(percent) = m.prune else { continue };
298
        let root = std::path::Path::new(&m.target);
299
        let pruned = match prune::usage(root) {
300
            Ok((used, size)) => {
301
                let mut p = prune::prune(root, started, &m.keep, size, used, percent).unwrap_or_else(|e| prune::Pruned {
302
                    size,
303
                    percent,
304
                    before: used,
305
                    after: used,
306
                    unpruned: Some(format!("pruning failed: {e}")),
307
                    ..Default::default()
308
                });
309
                // What is left as the file system counts it.
310
                if p.freed > 0 {
311
                    unsafe { libc::sync() };
312
                    if let Ok((now, _)) = prune::usage(root) {
313
                        p.after = now;
314
                    }
315
                }
316
                p
317
            }
318
            Err(e) => prune::Pruned { percent, unpruned: Some(format!("statvfs {}: {e}", m.target)), ..Default::default() },
319
        };
320
        let msg = serde_json::json!({ "target": m.target, "pruned": pruned });
321
        frame(console, b'C', msg.to_string().as_bytes());
322
    }
323
}
324
325
/// Frame a stream line by line; a long line is cut at 16 KiB.
326
fn pump(mut f: std::fs::File, kind: u8, console: &Mutex<std::io::Stdout>) {
327
    let mut buf = [0u8; 8192];
328
    let mut line: Vec<u8> = Vec::new();
329
    loop {
330
        let n = match f.read(&mut buf) {
331
            Ok(0) | Err(_) => break,
332
            Ok(n) => n,
333
        };
334
        for &b in &buf[..n] {
335
            if b == b'\n' || line.len() >= 16 * 1024 {
336
                frame(console, kind, &line);
337
                line.clear();
338
                if b == b'\n' {
339
                    continue;
340
                }
341
            }
342
            line.push(b);
343
        }
344
    }
345
    if !line.is_empty() {
346
        frame(console, kind, &line);
347
    }
348
}