OXYGENHUB
# 01043bfff9fd oxygenflow / crates / flow / src / images.rs
9.6 KiB · Rust · last changed flow rootfs push: ask about missing chunks 20,000 at a time 24 days ago source blame history raw
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
/// Chunk names per `missing` question.
19
const ASK: usize = 20_000;
20
21
/// A hub's image endpoints, with a token.
22
pub struct Client {
23
    base: String,
24
    token: String,
25
    agent: ureq::Agent,
26
}
27
28
/// What a push or a fetch moved.
29
pub struct Moved {
30
    pub chunks: usize,
31
    pub bytes: u64,
32
    pub elapsed: Duration,
33
}
34
35
impl Client {
36
    pub fn new(hub: &str, token: &str, ca: Option<&Path>) -> Result<Client> {
37
        let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?;
38
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(120)).build();
39
        Ok(Client { base: format!("{}/api/v1", hub.trim_end_matches('/')), token: token.into(), agent })
40
    }
41
42
    fn auth(&self) -> String {
43
        format!("Bearer {}", self.token)
44
    }
45
46
    fn fail(url: &str, e: ureq::Error) -> anyhow::Error {
47
        match e {
48
            ureq::Error::Status(code, r) => {
49
                let text = r.into_string().unwrap_or_default();
50
                let detail = serde_json::from_str::<serde_json::Value>(&text)
51
                    .ok()
52
                    .and_then(|v| v["detail"].as_str().map(String::from))
53
                    .unwrap_or(text);
54
                anyhow::anyhow!("{url}: HTTP {code}: {detail}")
55
            }
56
            e => anyhow::anyhow!("{url}: {e}"),
57
        }
58
    }
59
60
    /// The hub's manifest of image `hash`, `None` when it does not hold it.
61
    pub fn manifest(&self, hash: &str) -> Result<Option<Manifest>> {
62
        let url = format!("{}/flow/images/{hash}", self.base);
63
        match self.agent.get(&url).set("Authorization", &self.auth()).call() {
64
            Ok(r) => Ok(Some(r.into_json()?)),
65
            Err(ureq::Error::Status(404, _)) => Ok(None),
66
            Err(e) => Err(Self::fail(&url, e)),
67
        }
68
    }
69
70
    fn chunk(&self, hash: &str) -> Result<Vec<u8>> {
71
        let url = format!("{}/flow/images/chunks/{hash}", self.base);
72
        let r = self.agent.get(&url).set("Authorization", &self.auth()).call().map_err(|e| Self::fail(&url, e))?;
73
        let mut out = Vec::new();
74
        std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?;
75
        Ok(out)
76
    }
77
78
    /// Send image `hash` from `home`: only the chunks the hub lacks, then
79
    /// the manifest, which the hub checks against the hash.
80
    pub fn push(&self, home: &Home, hash: &str) -> Result<Moved> {
81
        let start = Instant::now();
82
        let path = home.rootfs(hash);
83
        if !path.is_file() {
84
            bail!("{} is not in this Flow home ({}); import it first", &hash[..12.min(hash.len())], path.display());
85
        }
86
        let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?;
87
        if m.hash != hash {
88
            bail!("{} holds an image named {}, not {}: the file was changed", path.display(), &m.hash[..12], &hash[..12]);
89
        }
90
        let mut missing: HashSet<String> = HashSet::new();
91
        // 20,000 names is about 1.3 MB of JSON: under a hub's 2 MB default
92
        // body limit, which refused 50,000 at a time (oxygenhub#147).
93
        for ask in m.needed().chunks(ASK) {
94
            let url = format!("{}/flow/images/missing", self.base);
95
            let r: serde_json::Value = self
96
                .agent
97
                .post(&url)
98
                .set("Authorization", &self.auth())
99
                .send_json(serde_json::json!({ "chunks": ask }))
100
                .map_err(|e| Self::fail(&url, e))?
101
                .into_json()?;
102
            missing.extend(r["missing"].as_array().into_iter().flatten().filter_map(|h| h.as_str().map(String::from)));
103
        }
104
        // Read the image again, sending the missing chunks as they come.
105
        let (count, bytes) = (missing.len(), Mutex::new(0u64));
106
        let queue: Mutex<Vec<(String, Vec<u8>)>> = Mutex::new(Vec::new());
107
        let failed: Mutex<Option<anyhow::Error>> = Mutex::new(None);
108
        let sent = Mutex::new(HashSet::new());
109
        let send = |c: &str, b: &[u8]| -> Result<()> {
110
            let url = format!("{}/flow/images/chunks/{c}", self.base);
111
            self.agent.put(&url).set("Authorization", &self.auth()).send_bytes(b).map_err(|e| Self::fail(&url, e))?;
112
            *bytes.lock().unwrap() += b.len() as u64;
113
            Ok(())
114
        };
115
        images::scan_file(&path, None, |c: &Chunk, b: &[u8]| {
116
            if missing.contains(c.hash()) && sent.lock().unwrap().insert(c.hash().to_string()) {
117
                queue.lock().unwrap().push((c.hash().to_string(), b.to_vec()));
118
            }
119
            if queue.lock().unwrap().len() >= PARALLEL * 4 {
120
                let batch: Vec<_> = std::mem::take(&mut *queue.lock().unwrap());
121
                send_batch(&batch, &send, &failed);
122
            }
123
            match failed.lock().unwrap().take() {
124
                Some(e) => Err(images::Error::Bad(format!("{e:#}"))),
125
                None => Ok(()),
126
            }
127
        })?;
128
        let rest: Vec<_> = std::mem::take(&mut *queue.lock().unwrap());
129
        send_batch(&rest, &send, &failed);
130
        if let Some(e) = failed.lock().unwrap().take() {
131
            return Err(e);
132
        }
133
        let url = format!("{}/flow/images/{hash}", self.base);
134
        self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?;
135
        let bytes = *bytes.lock().unwrap();
136
        Ok(Moved { chunks: count, bytes, elapsed: start.elapsed() })
137
    }
138
139
    /// Fetch image `hash` into `home`: the chunks its cache lacks, the
140
    /// image written and checked, its architecture recorded. `Ok(None)`
141
    /// when the hub does not hold it.
142
    pub fn fetch(&self, home: &Home, hash: &str) -> Result<Option<Moved>> {
143
        let start = Instant::now();
144
        let Some(m) = self.manifest(hash)? else { return Ok(None) };
145
        if m.hash != hash {
146
            bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]);
147
        }
148
        let cache = chunk_cache(home);
149
        let wanted: Vec<String> = m.needed().into_iter().filter(|h| !cache_path(&cache, h).is_file()).map(String::from).collect();
150
        let (bytes, failed) = (Mutex::new(0u64), Mutex::new(None::<anyhow::Error>));
151
        let next = Mutex::new(wanted.iter());
152
        std::thread::scope(|s| {
153
            for _ in 0..PARALLEL {
154
                s.spawn(|| loop {
155
                    let Some(h) = next.lock().unwrap().next() else { break };
156
                    if failed.lock().unwrap().is_some() {
157
                        break;
158
                    }
159
                    let got = self.chunk(h).and_then(|b| {
160
                        if blake3::hash(&b).to_hex().as_str() != h {
161
                            bail!("chunk {} came back with other bytes", &h[..12]);
162
                        }
163
                        let p = cache_path(&cache, h);
164
                        std::fs::create_dir_all(p.parent().expect("a chunk directory"))?;
165
                        let tmp = p.with_extension("part");
166
                        std::fs::write(&tmp, &b)?;
167
                        std::fs::rename(&tmp, &p)?;
168
                        *bytes.lock().unwrap() += b.len() as u64;
169
                        Ok(())
170
                    });
171
                    if let Err(e) = got {
172
                        *failed.lock().unwrap() = Some(e);
173
                        break;
174
                    }
175
                });
176
            }
177
        });
178
        if let Some(e) = failed.into_inner().unwrap() {
179
            return Err(e);
180
        }
181
        let out = home.rootfs(hash);
182
        std::fs::create_dir_all(out.parent().expect("the cas directory"))?;
183
        images::assemble(&m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?))
184
            .with_context(|| format!("write image {}", &hash[..12]))?;
185
        if let Some(arch) = &m.arch {
186
            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" });
187
            std::fs::write(home.image_manifest(hash), serde_json::to_vec(&manifest)?)?;
188
        }
189
        Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), elapsed: start.elapsed() }))
190
    }
191
}
192
193
fn send_batch(batch: &[(String, Vec<u8>)], send: &(dyn Fn(&str, &[u8]) -> Result<()> + Sync), failed: &Mutex<Option<anyhow::Error>>) {
194
    std::thread::scope(|s| {
195
        for part in batch.chunks(batch.len().div_ceil(PARALLEL).max(1)) {
196
            s.spawn(move || {
197
                for (h, b) in part {
198
                    if let Err(e) = send(h, b) {
199
                        *failed.lock().unwrap() = Some(e);
200
                        return;
201
                    }
202
                }
203
            });
204
        }
205
    });
206
}
207
208
/// The home's chunk cache.
209
pub fn chunk_cache(home: &Home) -> PathBuf {
210
    home.root.join("chunks")
211
}
212
213
fn cache_path(cache: &Path, hash: &str) -> PathBuf {
214
    cache.join(&hash[..2]).join(hash)
215
}
216
217
/// A size for people: 991 MiB, 1.2 GiB.
218
pub fn human(bytes: u64) -> String {
219
    if bytes >= 1 << 30 {
220
        format!("{:.1} GiB", bytes as f64 / (1u64 << 30) as f64)
221
    } else {
222
        format!("{} MiB", bytes >> 20)
223
    }
224
}