OXYGENHUB

Images by hash: flow rootfs push and pull, and runners fetch what they lack 4cf0c99a1486

flow rootfs push HASH sends an image to a hub: only the chunks the hub
lacks, eight at a time, then the manifest the hub checks against the
hash. flow rootfs pull HASH fetches one into a Flow home. A runner whose
job's image is not here fetches it before the preflight, one image at a
time, saying so in the job's log; chunks stay in the home's chunk cache,
so a rebuilt image costs only its changed chunks. Claims say the runner
fetches images (fetches_images), so the hub may offer it jobs whose
image only the hub holds.

refs #39
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 17:23 UTC
Signature
✔ signed by Andy McCall
Hash
4cf0c99a1486e32d3395552e851e5eb55af06d9ed5e909c980cd26b60c43942f
Depends on
1abadd4a2fa6 1daca0b6bb8c 3007d8f011fa a72abeeedd78 b11e30f5031d e0918b887850
Depended on by
8b7034c55402 1215f05ff8d0 32f54b393396 8a4f5174526a 5cf1e8aefbb6 4be5e4cd0af6 077a65242359 01043bfff9fd b14aeaae683a e38bb032e38b 62829d15ef09
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
11 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/flow/src/images.rs +220 −0
1 +
//! Images by hash with a hub (oxygenflow#39, docs/jetstream.md §8):
2 +
//! `flow rootfs push` sends an image's missing chunks and its manifest;
3 +
//! `flow rootfs pull`, and a runner that lacks a job's image, fetch the
4 +
//! manifest and the chunks they lack and write the image back, checked
5 +
//! against its hash. Fetched chunks stay in the home's chunk cache
6 +
//! (`chunks/`), so a rebuilt image costs only the chunks that changed.
7 +
8 +
use crate::home::Home;
9 +
use anyhow::{bail, Context, Result};
10 +
use jet::images::{self, Chunk, Manifest};
11 +
use std::collections::HashSet;
12 +
use std::path::{Path, PathBuf};
13 +
use std::sync::Mutex;
14 +
use std::time::{Duration, Instant};
15 +
16 +
/// Requests in flight at once.
17 +
const PARALLEL: usize = 8;
18 +
19 +
/// A hub's image endpoints, with a token.
20 +
pub struct Client {
21 +
    base: String,
22 +
    token: String,
23 +
    agent: ureq::Agent,
24 +
}
25 +
26 +
/// What a push or a fetch moved.
27 +
pub struct Moved {
28 +
    pub chunks: usize,
29 +
    pub bytes: u64,
30 +
    pub elapsed: Duration,
31 +
}
32 +
33 +
impl Client {
34 +
    pub fn new(hub: &str, token: &str, ca: Option<&Path>) -> Result<Client> {
35 +
        let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?;
36 +
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(120)).build();
37 +
        Ok(Client { base: format!("{}/api/v1", hub.trim_end_matches('/')), token: token.into(), agent })
38 +
    }
39 +
40 +
    fn auth(&self) -> String {
41 +
        format!("Bearer {}", self.token)
42 +
    }
43 +
44 +
    fn fail(url: &str, e: ureq::Error) -> anyhow::Error {
45 +
        match e {
46 +
            ureq::Error::Status(code, r) => {
47 +
                let text = r.into_string().unwrap_or_default();
48 +
                let detail = serde_json::from_str::<serde_json::Value>(&text)
49 +
                    .ok()
50 +
                    .and_then(|v| v["detail"].as_str().map(String::from))
51 +
                    .unwrap_or(text);
52 +
                anyhow::anyhow!("{url}: HTTP {code}: {detail}")
53 +
            }
54 +
            e => anyhow::anyhow!("{url}: {e}"),
55 +
        }
56 +
    }
57 +
58 +
    /// The hub's manifest of image `hash`, `None` when it does not hold it.
59 +
    pub fn manifest(&self, hash: &str) -> Result<Option<Manifest>> {
60 +
        let url = format!("{}/flow/images/{hash}", self.base);
61 +
        match self.agent.get(&url).set("Authorization", &self.auth()).call() {
62 +
            Ok(r) => Ok(Some(r.into_json()?)),
63 +
            Err(ureq::Error::Status(404, _)) => Ok(None),
64 +
            Err(e) => Err(Self::fail(&url, e)),
65 +
        }
66 +
    }
67 +
68 +
    fn chunk(&self, hash: &str) -> Result<Vec<u8>> {
69 +
        let url = format!("{}/flow/images/chunks/{hash}", self.base);
70 +
        let r = self.agent.get(&url).set("Authorization", &self.auth()).call().map_err(|e| Self::fail(&url, e))?;
71 +
        let mut out = Vec::new();
72 +
        std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?;
73 +
        Ok(out)
74 +
    }
75 +
76 +
    /// Send image `hash` from `home`: only the chunks the hub lacks, then
77 +
    /// the manifest, which the hub checks against the hash.
78 +
    pub fn push(&self, home: &Home, hash: &str) -> Result<Moved> {
79 +
        let start = Instant::now();
80 +
        let path = home.rootfs(hash);
81 +
        if !path.is_file() {
82 +
            bail!("{} is not in this Flow home ({}); import it first", &hash[..12.min(hash.len())], path.display());
83 +
        }
84 +
        let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?;
85 +
        if m.hash != hash {
86 +
            bail!("{} holds an image named {}, not {}: the file was changed", path.display(), &m.hash[..12], &hash[..12]);
87 +
        }
88 +
        let mut missing: HashSet<String> = HashSet::new();
89 +
        for ask in m.needed().chunks(50_000) {
90 +
            let url = format!("{}/flow/images/missing", self.base);
91 +
            let r: serde_json::Value = self
92 +
                .agent
93 +
                .post(&url)
94 +
                .set("Authorization", &self.auth())
95 +
                .send_json(serde_json::json!({ "chunks": ask }))
96 +
                .map_err(|e| Self::fail(&url, e))?
97 +
                .into_json()?;
98 +
            missing.extend(r["missing"].as_array().into_iter().flatten().filter_map(|h| h.as_str().map(String::from)));
99 +
        }
100 +
        // Read the image again, sending the missing chunks as they come.
101 +
        let (count, bytes) = (missing.len(), Mutex::new(0u64));
102 +
        let queue: Mutex<Vec<(String, Vec<u8>)>> = Mutex::new(Vec::new());
103 +
        let failed: Mutex<Option<anyhow::Error>> = Mutex::new(None);
104 +
        let sent = Mutex::new(HashSet::new());
105 +
        let send = |c: &str, b: &[u8]| -> Result<()> {
106 +
            let url = format!("{}/flow/images/chunks/{c}", self.base);
107 +
            self.agent.put(&url).set("Authorization", &self.auth()).send_bytes(b).map_err(|e| Self::fail(&url, e))?;
108 +
            *bytes.lock().unwrap() += b.len() as u64;
109 +
            Ok(())
110 +
        };
111 +
        images::scan_file(&path, None, |c: &Chunk, b: &[u8]| {
112 +
            if missing.contains(c.hash()) && sent.lock().unwrap().insert(c.hash().to_string()) {
113 +
                queue.lock().unwrap().push((c.hash().to_string(), b.to_vec()));
114 +
            }
115 +
            if queue.lock().unwrap().len() >= PARALLEL * 4 {
116 +
                let batch: Vec<_> = std::mem::take(&mut *queue.lock().unwrap());
117 +
                send_batch(&batch, &send, &failed);
118 +
            }
119 +
            match failed.lock().unwrap().take() {
120 +
                Some(e) => Err(images::Error::Bad(format!("{e:#}"))),
121 +
                None => Ok(()),
122 +
            }
123 +
        })?;
124 +
        let rest: Vec<_> = std::mem::take(&mut *queue.lock().unwrap());
125 +
        send_batch(&rest, &send, &failed);
126 +
        if let Some(e) = failed.lock().unwrap().take() {
127 +
            return Err(e);
128 +
        }
129 +
        let url = format!("{}/flow/images/{hash}", self.base);
130 +
        self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?;
131 +
        let bytes = *bytes.lock().unwrap();
132 +
        Ok(Moved { chunks: count, bytes, elapsed: start.elapsed() })
133 +
    }
134 +
135 +
    /// Fetch image `hash` into `home`: the chunks its cache lacks, the
136 +
    /// image written and checked, its architecture recorded. `Ok(None)`
137 +
    /// when the hub does not hold it.
138 +
    pub fn fetch(&self, home: &Home, hash: &str) -> Result<Option<Moved>> {
139 +
        let start = Instant::now();
140 +
        let Some(m) = self.manifest(hash)? else { return Ok(None) };
141 +
        if m.hash != hash {
142 +
            bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]);
143 +
        }
144 +
        let cache = chunk_cache(home);
145 +
        let wanted: Vec<String> = m.needed().into_iter().filter(|h| !cache_path(&cache, h).is_file()).map(String::from).collect();
146 +
        let (bytes, failed) = (Mutex::new(0u64), Mutex::new(None::<anyhow::Error>));
147 +
        let next = Mutex::new(wanted.iter());
148 +
        std::thread::scope(|s| {
149 +
            for _ in 0..PARALLEL {
150 +
                s.spawn(|| loop {
151 +
                    let Some(h) = next.lock().unwrap().next() else { break };
152 +
                    if failed.lock().unwrap().is_some() {
153 +
                        break;
154 +
                    }
155 +
                    let got = self.chunk(h).and_then(|b| {
156 +
                        if blake3::hash(&b).to_hex().as_str() != h {
157 +
                            bail!("chunk {} came back with other bytes", &h[..12]);
158 +
                        }
159 +
                        let p = cache_path(&cache, h);
160 +
                        std::fs::create_dir_all(p.parent().expect("a chunk directory"))?;
161 +
                        let tmp = p.with_extension("part");
162 +
                        std::fs::write(&tmp, &b)?;
163 +
                        std::fs::rename(&tmp, &p)?;
164 +
                        *bytes.lock().unwrap() += b.len() as u64;
165 +
                        Ok(())
166 +
                    });
167 +
                    if let Err(e) = got {
168 +
                        *failed.lock().unwrap() = Some(e);
169 +
                        break;
170 +
                    }
171 +
                });
172 +
            }
173 +
        });
174 +
        if let Some(e) = failed.into_inner().unwrap() {
175 +
            return Err(e);
176 +
        }
177 +
        let out = home.rootfs(hash);
178 +
        std::fs::create_dir_all(out.parent().expect("the cas directory"))?;
179 +
        images::assemble(&m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?))
180 +
            .with_context(|| format!("write image {}", &hash[..12]))?;
181 +
        if let Some(arch) = &m.arch {
182 +
            let manifest = serde_json::json!({ "arch": arch, "imported_ms": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0), "from": "hub" });
183 +
            std::fs::write(home.image_manifest(hash), serde_json::to_vec(&manifest)?)?;
184 +
        }
185 +
        Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), elapsed: start.elapsed() }))
186 +
    }
187 +
}
188 +
189 +
fn send_batch(batch: &[(String, Vec<u8>)], send: &(dyn Fn(&str, &[u8]) -> Result<()> + Sync), failed: &Mutex<Option<anyhow::Error>>) {
190 +
    std::thread::scope(|s| {
191 +
        for part in batch.chunks(batch.len().div_ceil(PARALLEL).max(1)) {
192 +
            s.spawn(move || {
193 +
                for (h, b) in part {
194 +
                    if let Err(e) = send(h, b) {
195 +
                        *failed.lock().unwrap() = Some(e);
196 +
                        return;
197 +
                    }
198 +
                }
199 +
            });
200 +
        }
201 +
    });
202 +
}
203 +
204 +
/// The home's chunk cache.
205 +
pub fn chunk_cache(home: &Home) -> PathBuf {
206 +
    home.root.join("chunks")
207 +
}
208 +
209 +
fn cache_path(cache: &Path, hash: &str) -> PathBuf {
210 +
    cache.join(&hash[..2]).join(hash)
211 +
}
212 +
213 +
/// A size for people: 991 MiB, 1.2 GiB.
214 +
pub fn human(bytes: u64) -> String {
215 +
    if bytes >= 1 << 30 {
216 +
        format!("{:.1} GiB", bytes as f64 / (1u64 << 30) as f64)
217 +
    } else {
218 +
        format!("{} MiB", bytes >> 20)
219 +
    }
220 +
}
modified Cargo.lock +1 −0
…
420 unchanged lines
421 421
 "flow-core",
422 422
 "hex",
423 423
 "ignore",
424 +
 "jet",
424 425
 "libc",
425 426
 "oxy-core",
426 427
 "serde",
…
1253 unchanged lines
modified crates/flow-core/src/api.rs +5 −0
…
32 unchanged lines
33 33
    /// runner whose architecture its image is for.
34 34
    #[serde(default, skip_serializing_if = "Option::is_none")]
35 35
    pub image_archs: Option<std::collections::BTreeMap<String, String>>,
36 +
    /// Whether the runner fetches a job's image from the hub when it lacks
37 +
    /// it (oxygenflow#39): the hub may then offer it jobs whose image only
38 +
    /// the hub holds.
39 +
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
40 +
    pub fetches_images: bool,
36 41
    /// The jobs this runner holds now: running, or finished with a result
37 42
    /// not yet reported. The hub ends any other job it has as running on
38 43
    /// this runner, which the runner lost by restarting or never received
…
198 unchanged lines
modified crates/flow/Cargo.toml +1 −0
…
10 unchanged lines
11 11
12 12
[dependencies]
13 13
flow-core.workspace = true
14 +
jet.workspace = true
14 15
oxy-core.workspace = true
15 16
serde.workspace = true
16 17
serde_json.workspace = true
…
11 unchanged lines
modified crates/flow/src/main.rs +60 −0
…
6 unchanged lines
7 7
#[path = "../../flow-init/src/job.rs"]
8 8
mod guest;
9 9
mod home;
10 +
mod images;
10 11
mod microvm;
11 12
mod outputs;
12 13
mod runner;
…
119 unchanged lines
132 133
    },
133 134
    /// List the images this home holds
134 135
    List,
136 +
    /// Send an image to a hub, so its runners fetch it instead of having it
137 +
    /// copied to them: only the chunks the hub lacks travel. Uses
138 +
    /// $OXY_TOKEN (a token that may write repositories) and $OXY_CA
139 +
    Push {
140 +
        /// The image's hash, as pipelines pin it (cas://blob/HASH)
141 +
        hash: String,
142 +
        /// The hub (default: the runner's, from runner.toml or flow.toml)
143 +
        #[arg(long)]
144 +
        hub: Option<String>,
145 +
    },
146 +
    /// Fetch an image a hub holds into this home, as a runner does for a
147 +
    /// job whose image it lacks. Uses $OXY_TOKEN and $OXY_CA
148 +
    Pull {
149 +
        hash: String,
150 +
        #[arg(long)]
151 +
        hub: Option<String>,
152 +
    },
135 153
}
136 154
137 155
fn main() {
…
17 unchanged lines
155 173
            let home = Home::locate(cli.home.as_deref())?;
156 174
            match cmd {
157 175
                RootfsCmd::Import { tarball, sha256, env } => rootfs_import(&home, &tarball, sha256.as_deref(), &env),
176 +
                RootfsCmd::Push { hash, hub } => {
177 +
                    let client = image_client(&home, hub)?;
178 +
                    eprintln!("Sending image {} …", &hash[..12.min(hash.len())]);
179 +
                    let m = client.push(&home, &hash)?;
180 +
                    println!(
181 +
                        "Sent image {}: {} chunks the hub lacked, {} in {:.1} s",
182 +
                        &hash[..12],
183 +
                        m.chunks,
184 +
                        images::human(m.bytes),
185 +
                        m.elapsed.as_secs_f64()
186 +
                    );
187 +
                    Ok(())
188 +
                }
189 +
                RootfsCmd::Pull { hash, hub } => {
190 +
                    let client = image_client(&home, hub)?;
191 +
                    match client.fetch(&home, &hash)? {
192 +
                        Some(m) => {
193 +
                            println!(
194 +
                                "Fetched image {}: {} chunks, {} in {:.1} s",
195 +
                                &hash[..12],
196 +
                                m.chunks,
197 +
                                images::human(m.bytes),
198 +
                                m.elapsed.as_secs_f64()
199 +
                            );
200 +
                            Ok(())
201 +
                        }
202 +
                        None => anyhow::bail!("the hub does not hold image {}; send it with flow rootfs push", &hash[..12.min(hash.len())]),
203 +
                    }
204 +
                }
158 205
                RootfsCmd::List => {
159 206
                    for e in std::fs::read_dir(home.root.join("cas"))?.flatten() {
160 207
                        let size = e.metadata().map(|m| m.len()).unwrap_or(0);
…
71 unchanged lines
232 279
            std::process::exit(code);
233 280
        }
234 281
    }
282 +
}
283 +
284 +
/// The hub image commands talk to: `--hub`, else the runner's; with
285 +
/// $OXY_TOKEN, else the runner's own token.
286 +
fn image_client(home: &Home, hub: Option<String>) -> Result<images::Client> {
287 +
    let runner = home.config.runner.clone();
288 +
    let hub = hub.or_else(|| runner.as_ref().map(|r| r.hub.clone())).context("name the hub with --hub https://HUB")?;
289 +
    let token = std::env::var("OXY_TOKEN")
290 +
        .ok()
291 +
        .or_else(|| runner.as_ref().map(|r| r.token.clone()))
292 +
        .context("set OXY_TOKEN to a token of yours on the hub")?;
293 +
    let ca = std::env::var_os("OXY_CA").map(PathBuf::from).or_else(|| runner.as_ref().and_then(|r| r.ca.clone()));
294 +
    images::Client::new(&hub, &token, ca.as_deref())
235 295
}
236 296
237 297
fn load(file: &Path) -> Result<Pipeline> {
…
252 unchanged lines
modified crates/flow/src/runner.rs +38 −0
…
311 unchanged lines
312 312
    Ok((have, missing))
313 313
}
314 314
315 +
/// Fetch `claim`'s image from the hub when this home lacks it
316 +
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
317 +
/// leaves it missing, and the preflight declines the job as before.
318 +
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, claim: &Claim) {
319 +
    let hash = &claim.sandbox.rootfs;
320 +
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
321 +
        return;
322 +
    }
323 +
    let note = |text: String| {
324 +
        eprintln!("job {}: {text}", claim.job_id);
325 +
        hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok();
326 +
    };
327 +
    // One fetch of an image at a time: two jobs wanting it wait for one.
328 +
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
329 +
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
330 +
    if home.rootfs(hash).is_file() {
331 +
        return;
332 +
    }
333 +
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
334 +
        Ok(c) => c,
335 +
        Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])),
336 +
    };
337 +
    note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
338 +
    match client.fetch(home, hash) {
339 +
        Ok(Some(m)) => note(format!(
340 +
            "image {}: fetched {} chunks, {} in {:.1} s",
341 +
            &hash[..12],
342 +
            m.chunks,
343 +
            crate::images::human(m.bytes),
344 +
            m.elapsed.as_secs_f64()
345 +
        )),
346 +
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
347 +
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
348 +
    }
349 +
}
350 +
315 351
/// Why this runner cannot run `claim` now (spec §11.5), checked before
316 352
/// anything is fetched: the tier, the architecture, the image and the
317 353
/// memory. The hub chose it from what the runner last said, which may be
…
104 unchanged lines
422 458
                images: Some(home.images()),
423 459
                image_archs: Some(home.image_archs()),
424 460
                holding: Some(held.lock().unwrap().iter().copied().collect()),
461 +
                fetches_images: true,
425 462
            };
426 463
            match hub.claim(&req) {
427 464
                Ok(Some(claim)) => {
…
9 unchanged lines
437 474
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
438 475
                    scope.spawn(move || {
439 476
                        let id = claim.job_id;
477 +
                        fetch_image(home, hub, cfg, &claim);
440 478
                        if let Some(why) = preflight(home, &claim) {
441 479
                            eprintln!("job {id}: declined: {why}");
442 480
                            if let Err(e) = hub.decline(id, &why) {
…
586 unchanged lines