OXYGENHUB
◆ main oxygenflow / crates / flow / src / home.rs
15.1 KiB · Rust · last changed flow runner register keeps the CA in the Flow home by absolute path, and a relative ca in runner.toml is read against the home, so a service finds it from any directory (closes #98) 8 days ago source blame history raw
e0918b88 Andy McCall 1
//! `$FLOW_HOME` (default `~/.local/share/flow`): the pinned parts of each
2
//! isolation tier, rootfs images by hash, scratch space and the runner's
3
//! configuration, all described by `flow.toml`.
4
//!
5
//! ```toml
6
//! [microvm]
7
//! firecracker = { path = "bin/firecracker", blake3 = "…" }
8
//! kernel = { path = "kernel/vmlinux", blake3 = "…" }
9
//! init = { path = "bin/flow-init", blake3 = "…" }
10
//!
11
//! [runner]                       # only for `flow runner`
12
//! hub = "https://hub.example:7343"
13
//! token = "…"                    # a runner token from the hub's [flow] runner_tokens
14
//! ca = "/path/to/hub-ca.pem"     # when the hub's certificate is self-signed
15
//! name = "earth-1"
16
//! ```
47c1d64d Andy McCall ↶ 17
//!
18
//! A runner signs its receipts with a composite key kept beside `flow.toml`
19
//! (`runner.key`, `runner.identity`), made on first start.
e0918b88 Andy McCall 20
21
use anyhow::{bail, Context, Result};
22
use serde::Deserialize;
23
use std::collections::HashMap;
24
use std::path::{Path, PathBuf};
25
use std::sync::Mutex;
26
27
#[derive(Clone, Debug, Deserialize)]
28
pub struct Pinned {
29
    pub path: PathBuf,
30
    pub blake3: String,
31
}
32
33
#[derive(Clone, Debug, Deserialize)]
34
pub struct MicrovmConfig {
35
    pub firecracker: Pinned,
36
    pub kernel: Pinned,
37
    pub init: Pinned,
38
}
39
f98a8f78 Andy McCall ↶ 40
/// Where `flow runner register` keeps the runner's hub and credential.
41
pub const RUNNER_TOML: &str = "runner.toml";
42
0d663e91 Andy McCall ↶ 43
/// The hub's CA certificate as `flow runner register --ca` keeps it (#98).
44
pub const CA_PEM: &str = "hub-ca.pem";
45
e0918b88 Andy McCall 46
#[derive(Clone, Debug, Deserialize)]
47
pub struct RunnerConfig {
48
    pub hub: String,
49
    pub token: String,
50
    pub ca: Option<PathBuf>,
51
    pub name: Option<String>,
bb643c25 Andy McCall ↶ 52
    /// Jobs run at once (default 1).
53
    #[serde(default)]
54
    pub max_jobs: Option<usize>,
f98a8f78 Andy McCall ↶ 55
    /// Labels the runner offers, e.g. `gpu`, `big-disk`.
56
    #[serde(default)]
57
    pub labels: Vec<String>,
bb643c25 Andy McCall ↶ 58
    /// Memory (MiB) and vCPUs the jobs running at once may use together.
59
    #[serde(default)]
60
    pub memory_mib: Option<u32>,
61
    #[serde(default)]
62
    pub cpus: Option<u32>,
b14aeaae Andy McCall ↶ 63
    /// Sharing images with other runners (docs/jetstream.md §8.1): absent,
64
    /// the runner neither serves nor asks peers.
65
    #[serde(default)]
66
    pub swarm: Option<SwarmConfig>,
d60aac6c Andy McCall ↶ 67
    /// The build cache's size limit in GiB (#28; default 64).
68
    #[serde(default)]
69
    pub cache_gb: Option<u64>,
077a6524 Andy McCall ↶ 70
    /// The share of its disk, in percent, a kept build cache may fill
71
    /// (oxygenflow#72; default 60): over it, the files the job did not use
72
    /// are pruned, oldest first, and a cache still over it is not kept.
73
    #[serde(default)]
74
    pub cache_prune_percent: Option<u8>,
ea81796b Andy McCall ↶ 75
    /// Let microVM jobs reach the hosts their sandbox allows, through this
76
    /// runner (oxygenflow#55); off unless set. The runner then offers the
77
    /// `egress` label, which such jobs need.
78
    #[serde(default)]
79
    pub egress: bool,
35f7a849 Andy McCall ↶ 80
    /// Sign each job's receipt with a fresh key the hub certifies for that
81
    /// job, just before signing (keyless, oxygenhub#241), rather than with
82
    /// the runner's own key; off unless set. When the hub cannot certify,
83
    /// the runner's key signs, and the job's log says why.
84
    #[serde(default)]
85
    pub keyless: bool,
4be5e4cd Andy McCall ↶ 86
    /// Serve this runner's metrics at this address, e.g. `127.0.0.1:9464`
87
    /// (oxygenflow#35); off unless set.
88
    #[serde(default)]
89
    pub metrics: Option<String>,
90
    /// A bearer token a scrape of the metrics must send.
91
    #[serde(default)]
92
    pub metrics_token: Option<String>,
85275e1e Andy McCall ↶ 93
    /// `[runner.update]`: whether this runner takes updates (#85).
94
    #[serde(default)]
95
    pub update: Option<UpdateConfig>,
96
}
97
98
/// `[runner.update]` in flow.toml (docs/jetstream.md §8.2): the runner's
99
/// last word on updates, whatever the hub's policy says.
100
#[derive(Clone, Debug, Deserialize)]
101
pub struct UpdateConfig {
102
    /// `false` refuses every update, so a site can hold a machine back.
103
    #[serde(default = "yes")]
104
    pub allow: bool,
105
}
106
107
fn yes() -> bool {
108
    true
ea81796b Andy McCall ↶ 109
}
110
111
impl RunnerConfig {
112
    /// The labels offered to the hub: the configured ones, and `egress`
113
    /// when egress is on.
114
    pub fn offered_labels(&self) -> Vec<String> {
115
        let mut l = self.labels.clone();
116
        if self.egress && !l.iter().any(|x| x == flow_core::EGRESS_LABEL) {
117
            l.push(flow_core::EGRESS_LABEL.to_string());
118
        }
119
        l
120
    }
b14aeaae Andy McCall ↶ 121
}
122
123
/// `[runner.swarm]` in flow.toml, or `[swarm]` in runner.toml.
124
#[derive(Clone, Debug, Deserialize)]
125
pub struct SwarmConfig {
126
    /// Where peers reach this runner, e.g. `0.0.0.0:7401`.
127
    pub listen: String,
128
    /// The address peers are given (default: `listen`).
129
    #[serde(default)]
130
    pub advertise: Option<String>,
131
    /// Peers are only ever in the same site.
132
    #[serde(default)]
133
    pub site: String,
134
    /// The most this runner sends to peers, MiB a second (default 50).
135
    #[serde(default)]
136
    pub upload_mib_s: Option<u32>,
e0918b88 Andy McCall 137
}
138
139
#[derive(Clone, Debug, Default, Deserialize)]
140
pub struct Config {
141
    pub microvm: Option<MicrovmConfig>,
142
    pub runner: Option<RunnerConfig>,
143
}
144
145
pub struct Home {
146
    pub root: PathBuf,
147
    pub config: Config,
148
    /// Files already verified, by path, with the (len, mtime) they had then.
149
    verified: Mutex<HashMap<PathBuf, (u64, std::time::SystemTime)>>,
150
}
151
152
impl Home {
153
    pub fn locate(explicit: Option<&Path>) -> Result<Home> {
154
        let root = match explicit {
155
            Some(p) => p.to_path_buf(),
156
            None => match std::env::var_os("FLOW_HOME") {
157
                Some(p) => PathBuf::from(p),
158
                None => PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?).join(".local/share/flow"),
159
            },
160
        };
161
        let file = root.join("flow.toml");
f98a8f78 Andy McCall ↶ 162
        let mut config: Config = if file.is_file() {
e0918b88 Andy McCall 163
            let text = std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?;
164
            toml::from_str(&text).with_context(|| format!("parse {}", file.display()))?
165
        } else {
166
            Config::default()
167
        };
f98a8f78 Andy McCall ↶ 168
        // `flow runner register` writes the runner's hub and credential to
169
        // runner.toml, which stands in for a [runner] section flow.toml lacks.
170
        let registered = root.join(RUNNER_TOML);
171
        if config.runner.is_none() && registered.is_file() {
172
            let text = std::fs::read_to_string(&registered).with_context(|| format!("read {}", registered.display()))?;
0d663e91 Andy McCall ↶ 173
            let mut runner: RunnerConfig = toml::from_str(&text).with_context(|| format!("parse {}", registered.display()))?;
174
            // A relative CA there is the home's (#98): register keeps its
175
            // copy there, and the service may start in any directory.
176
            runner.ca = runner.ca.map(|ca| if ca.is_absolute() { ca } else { root.join(ca) });
177
            config.runner = Some(runner);
f98a8f78 Andy McCall ↶ 178
        }
e0918b88 Andy McCall 179
        for d in ["cas", "work", "repos"] {
180
            std::fs::create_dir_all(root.join(d)).with_context(|| format!("create {}", root.join(d).display()))?;
181
        }
182
        Ok(Home { root, config, verified: Mutex::new(HashMap::new()) })
183
    }
184
185
    pub fn resolve(&self, p: &Path) -> PathBuf {
186
        if p.is_absolute() {
187
            p.to_path_buf()
188
        } else {
189
            self.root.join(p)
190
        }
191
    }
192
193
    /// Where a rootfs image with this hash lives.
194
    pub fn rootfs(&self, hash: &str) -> PathBuf {
195
        self.root.join("cas").join(format!("{hash}.ext4"))
196
    }
197
1abadd4a Andy McCall ↶ 198
    /// An image's manifest (`cas/<hash>.json`): what `flow rootfs import`
199
    /// found out about it.
200
    pub fn image_manifest(&self, hash: &str) -> PathBuf {
201
        self.root.join("cas").join(format!("{hash}.json"))
202
    }
203
0077c748 Andy McCall ↶ 204
    /// The architecture an image is for: as its manifest records, else
205
    /// read from the image's own shell (spec §11.2) and recorded, so an
206
    /// image fetched or imported without one is read once and a runner
207
    /// declines what it cannot run instead of failing it. `None` when the
208
    /// image is not here or names no architecture.
1abadd4a Andy McCall ↶ 209
    pub fn image_arch(&self, hash: &str) -> Option<String> {
0077c748 Andy McCall ↶ 210
        let path = self.image_manifest(hash);
211
        let mut v: serde_json::Value =
212
            std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| serde_json::json!({}));
213
        if let Some(a) = v["arch"].as_str() {
214
            return Some(a.to_string());
215
        }
216
        let image = self.rootfs(hash);
217
        if v.get("arch_read_ms").is_some() || !image.is_file() {
218
            return None;
219
        }
220
        let arch = jet::rootfs::arch_of_file(&image);
221
        let o = v.as_object_mut()?;
222
        if let Some(a) = arch {
223
            o.insert("arch".into(), a.into());
224
        }
225
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
226
        o.insert("arch_read_ms".into(), now.into());
227
        let tmp = path.with_extension("json.part");
228
        if std::fs::write(&tmp, v.to_string()).is_ok() {
229
            let _ = std::fs::rename(&tmp, &path);
230
        }
231
        arch.map(String::from)
1abadd4a Andy McCall ↶ 232
    }
233
234
    /// Every image with a known architecture (spec §11.2).
235
    pub fn image_archs(&self) -> std::collections::BTreeMap<String, String> {
236
        self.images().into_iter().filter_map(|h| self.image_arch(&h).map(|a| (h, a))).collect()
237
    }
238
8c0a4e3a Andy McCall ↶ 239
    /// The rootfs images this home holds: the hashes of `cas/*.ext4`.
240
    pub fn images(&self) -> Vec<String> {
241
        let Ok(dir) = std::fs::read_dir(self.root.join("cas")) else {
242
            return Vec::new();
243
        };
244
        let mut out: Vec<String> = dir
245
            .filter_map(|e| {
246
                let name = e.ok()?.file_name().into_string().ok()?;
247
                let hash = name.strip_suffix(".ext4")?;
248
                (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
249
            })
250
            .collect();
251
        out.sort();
252
        out
253
    }
254
e0918b88 Andy McCall 255
    /// A pinned file, checked against its BLAKE3 before use (again only
256
    /// when its size or modification time changed since).
257
    pub fn verified(&self, path: &Path, blake3_hex: &str) -> Result<PathBuf> {
258
        let meta = std::fs::metadata(path).with_context(|| format!("{} is missing", path.display()))?;
259
        let stamp = (meta.len(), meta.modified()?);
260
        if self.verified.lock().unwrap().get(path) == Some(&stamp) {
261
            return Ok(path.to_path_buf());
262
        }
ab3204b9 Andy McCall ↶ 263
        let mut got = hash_file(path)?;
e0918b88 Andy McCall 264
        if got != blake3_hex {
ab3204b9 Andy McCall ↶ 265
            // The read may have come from bad cached pages rather than the
266
            // disk (oxygenflow#22: a RAM fault on Earth failed every job for
267
            // hours while the file was intact): drop the file's pages and
268
            // read it again from the disk before giving up.
269
            drop_cached(path);
270
            let again = hash_file(path)?;
271
            if again == blake3_hex {
272
                eprintln!(
273
                    "warning: {} hashed {got} from the page cache but {again} from the disk: the cached copy was bad (check this machine's memory)",
274
                    path.display()
275
                );
276
            }
277
            got = again;
278
        }
279
        if got != blake3_hex {
e0918b88 Andy McCall 280
            bail!("{} has BLAKE3 {got}, but {blake3_hex} is pinned", path.display());
281
        }
282
        self.verified.lock().unwrap().insert(path.to_path_buf(), stamp);
283
        Ok(path.to_path_buf())
ab3204b9 Andy McCall ↶ 284
    }
285
}
286
287
/// Ask the kernel to forget a file's cached pages, so the next read comes
288
/// from the disk. Best effort: dirty pages and other platforms stay.
289
fn drop_cached(path: &Path) {
290
    #[cfg(target_os = "linux")]
291
    if let Ok(f) = std::fs::File::open(path) {
292
        use std::os::fd::AsRawFd;
293
        // SAFETY: a valid descriptor; the advice only affects caching.
294
        unsafe {
295
            libc::posix_fadvise(f.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED);
296
        }
e0918b88 Andy McCall 297
    }
ab3204b9 Andy McCall ↶ 298
    #[cfg(not(target_os = "linux"))]
299
    let _ = path;
e0918b88 Andy McCall 300
}
301
302
pub fn hash_file(path: &Path) -> Result<String> {
303
    let mut h = blake3::Hasher::new();
304
    h.update_reader(std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?)?;
305
    Ok(h.finalize().to_hex().to_string())
47c1d64d Andy McCall ↶ 306
}
307
308
/// The key a runner signs receipts with (Oxygen spec §14.6).
309
pub struct RunnerKey {
310
    pub identity: oxy_core::identity::Identity,
311
    pub secret: oxy_core::identity::SecretKey,
312
}
313
314
impl Home {
315
    /// The runner's key, made on first use as `runner.key` (owner-only)
316
    /// and `runner.identity`, named `name`.
317
    pub fn runner_key(&self, name: &str) -> Result<RunnerKey> {
318
        use oxy_core::identity::{Identity, SecretKey};
319
        let key_path = self.root.join("runner.key");
320
        let id_path = self.root.join("runner.identity");
321
        if key_path.exists() {
322
            let (key, secret) = SecretKey::decode(&std::fs::read(&key_path)?).context("runner.key")?;
323
            let identity = Identity::decode(&std::fs::read(&id_path)?).context("runner.identity")?;
324
            if identity.hash() != key {
325
                bail!("runner.identity does not belong to runner.key");
326
            }
327
            return Ok(RunnerKey { identity, secret });
328
        }
329
        let secret = SecretKey::generate();
330
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
331
        let identity = secret.identity(&format!("{name} (OxygenFlow runner)"), "", now);
332
        std::fs::write(&id_path, identity.encode())?;
333
        let tmp = self.root.join("runner.key.tmp");
334
        {
335
            use std::io::Write;
3554afef Andy McCall ↶ 336
            let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&tmp)?;
47c1d64d Andy McCall ↶ 337
            f.write_all(&secret.encode(&identity.hash()))?;
338
            f.sync_all()?;
339
        }
340
        std::fs::rename(&tmp, &key_path)?;
341
        Ok(RunnerKey { identity, secret })
342
    }
343
}
344
345
#[cfg(test)]
346
mod tests {
347
    use super::*;
348
349
    #[test]
350
    fn the_runner_key_is_made_once_and_kept_private() {
351
        use std::os::unix::fs::PermissionsExt;
352
        let dir = tempfile::tempdir().unwrap();
353
        let home = Home::locate(Some(dir.path())).unwrap();
354
        let a = home.runner_key("earth-1").unwrap();
355
        assert_eq!(a.identity.name, "earth-1 (OxygenFlow runner)");
356
        let mode = std::fs::metadata(dir.path().join("runner.key")).unwrap().permissions().mode();
357
        assert_eq!(mode & 0o777, 0o600);
358
        let b = home.runner_key("renamed").unwrap();
359
        assert_eq!(a.identity, b.identity, "the same key after a restart");
360
        let sig = b.secret.sign(oxy_core::identity::Domain::Attest, &b.identity.hash(), &oxy_core::hash::ObjectRef([1; 32]));
361
        oxy_core::identity::verify(&a.identity, &sig, oxy_core::identity::Domain::Attest, &oxy_core::hash::ObjectRef([1; 32])).unwrap();
0d663e91 Andy McCall ↶ 362
    }
363
364
    /// #98: runner.toml's relative CA is the home's, wherever flow starts.
365
    #[test]
366
    fn a_registered_runners_relative_ca_is_in_its_home() {
367
        let dir = tempfile::tempdir().unwrap();
368
        std::fs::write(dir.path().join(RUNNER_TOML), "hub = \"https://hub\"\ntoken = \"t\"\nca = \"hub-ca.pem\"\n").unwrap();
369
        let home = Home::locate(Some(dir.path())).unwrap();
370
        assert_eq!(home.config.runner.unwrap().ca, Some(dir.path().join("hub-ca.pem")));
371
        std::fs::write(dir.path().join(RUNNER_TOML), "hub = \"https://hub\"\ntoken = \"t\"\nca = \"/etc/ssl/hub.pem\"\n").unwrap();
372
        let home = Home::locate(Some(dir.path())).unwrap();
373
        assert_eq!(home.config.runner.unwrap().ca, Some(PathBuf::from("/etc/ssl/hub.pem")));
47c1d64d Andy McCall ↶ 374
    }
e0918b88 Andy McCall 375
}