OXYGENHUB
# 0d663e91c598 oxygenflow / crates / flow / src / images.rs
13.6 KiB · Rust · last changed Runners offer the manifests of images the hub lacks; a fetch the hub cannot seed asks peers alone (refs #94) 9 days ago source blame history raw
4cf0c99a Andy McCall ↶ 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 {
b14aeaae Andy McCall ↶ 21
    root: String,
4cf0c99a Andy McCall ↶ 22
    base: String,
23
    token: String,
24
    agent: ureq::Agent,
25
}
26
27
/// What a push or a fetch moved.
28
pub struct Moved {
29
    pub chunks: usize,
30
    pub bytes: u64,
b14aeaae Andy McCall ↶ 31
    /// Of `bytes`, those that came from peers rather than the hub.
32
    pub peer_bytes: u64,
4cf0c99a Andy McCall ↶ 33
    pub elapsed: Duration,
34
}
35
36
impl Client {
37
    pub fn new(hub: &str, token: &str, ca: Option<&Path>) -> Result<Client> {
38
        let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?;
39
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(120)).build();
b14aeaae Andy McCall ↶ 40
        let root = hub.trim_end_matches('/').to_string();
41
        Ok(Client { base: format!("{root}/api/v1"), root, token: token.into(), agent })
4cf0c99a Andy McCall ↶ 42
    }
43
44
    fn auth(&self) -> String {
45
        format!("Bearer {}", self.token)
46
    }
47
48
    fn fail(url: &str, e: ureq::Error) -> anyhow::Error {
49
        match e {
50
            ureq::Error::Status(code, r) => {
51
                let text = r.into_string().unwrap_or_default();
52
                let detail = serde_json::from_str::<serde_json::Value>(&text)
53
                    .ok()
54
                    .and_then(|v| v["detail"].as_str().map(String::from))
55
                    .unwrap_or(text);
56
                anyhow::anyhow!("{url}: HTTP {code}: {detail}")
57
            }
58
            e => anyhow::anyhow!("{url}: {e}"),
59
        }
60
    }
61
62
    /// The hub's manifest of image `hash`, `None` when it does not hold it.
63
    pub fn manifest(&self, hash: &str) -> Result<Option<Manifest>> {
64
        let url = format!("{}/flow/images/{hash}", self.base);
65
        match self.agent.get(&url).set("Authorization", &self.auth()).call() {
66
            Ok(r) => Ok(Some(r.into_json()?)),
67
            Err(ureq::Error::Status(404, _)) => Ok(None),
68
            Err(e) => Err(Self::fail(&url, e)),
69
        }
70
    }
71
b14aeaae Andy McCall ↶ 72
    /// The hub's identity (`/.well-known/oxyhub-key`): the key its swarm
73
    /// tickets are signed with.
74
    pub fn hub_identity(&self) -> Result<oxy_core::identity::Identity> {
75
        let url = format!("{}/.well-known/oxyhub-key", self.root);
76
        let r = self.agent.get(&url).call().map_err(|e| Self::fail(&url, e))?;
77
        let mut out = Vec::new();
78
        std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?;
79
        oxy_core::identity::Identity::decode(&out).map_err(|e| anyhow::anyhow!("{url}: not a hub identity: {e}"))
80
    }
81
82
    /// Tell the hub's tracker this runner is fetching image `hash`, or has
83
    /// stopped: the peers to ask and the ticket they admit.
84
    pub fn announce(&self, hash: &str, state: &str) -> Result<flow_core::api::Announced> {
85
        let url = format!("{}/flow/swarm/announce", self.base);
86
        let body = flow_core::api::Announce { image: hash.into(), state: state.into() };
87
        Ok(self.agent.post(&url).set("Authorization", &self.auth()).send_json(&body).map_err(|e| Self::fail(&url, e))?.into_json()?)
88
    }
89
90
    pub(crate) fn chunk(&self, hash: &str) -> Result<Vec<u8>> {
4cf0c99a Andy McCall ↶ 91
        let url = format!("{}/flow/images/chunks/{hash}", self.base);
92
        let r = self.agent.get(&url).set("Authorization", &self.auth()).call().map_err(|e| Self::fail(&url, e))?;
93
        let mut out = Vec::new();
94
        std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?;
95
        Ok(out)
96
    }
97
98
    /// Send image `hash` from `home`: only the chunks the hub lacks, then
99
    /// the manifest, which the hub checks against the hash.
100
    pub fn push(&self, home: &Home, hash: &str) -> Result<Moved> {
101
        let start = Instant::now();
102
        let path = home.rootfs(hash);
103
        if !path.is_file() {
104
            bail!("{} is not in this Flow home ({}); import it first", &hash[..12.min(hash.len())], path.display());
105
        }
106
        let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?;
107
        if m.hash != hash {
108
            bail!("{} holds an image named {}, not {}: the file was changed", path.display(), &m.hash[..12], &hash[..12]);
109
        }
110
        let mut missing: HashSet<String> = HashSet::new();
111
        for ask in m.needed().chunks(50_000) {
112
            let url = format!("{}/flow/images/missing", self.base);
113
            let r: serde_json::Value = self
114
                .agent
115
                .post(&url)
116
                .set("Authorization", &self.auth())
117
                .send_json(serde_json::json!({ "chunks": ask }))
118
                .map_err(|e| Self::fail(&url, e))?
119
                .into_json()?;
120
            missing.extend(r["missing"].as_array().into_iter().flatten().filter_map(|h| h.as_str().map(String::from)));
121
        }
122
        // Read the image again, sending the missing chunks as they come.
123
        let (count, bytes) = (missing.len(), Mutex::new(0u64));
124
        let queue: Mutex<Vec<(String, Vec<u8>)>> = Mutex::new(Vec::new());
125
        let failed: Mutex<Option<anyhow::Error>> = Mutex::new(None);
126
        let sent = Mutex::new(HashSet::new());
127
        let send = |c: &str, b: &[u8]| -> Result<()> {
128
            let url = format!("{}/flow/images/chunks/{c}", self.base);
129
            self.agent.put(&url).set("Authorization", &self.auth()).send_bytes(b).map_err(|e| Self::fail(&url, e))?;
130
            *bytes.lock().unwrap() += b.len() as u64;
131
            Ok(())
132
        };
133
        images::scan_file(&path, None, |c: &Chunk, b: &[u8]| {
134
            if missing.contains(c.hash()) && sent.lock().unwrap().insert(c.hash().to_string()) {
135
                queue.lock().unwrap().push((c.hash().to_string(), b.to_vec()));
136
            }
137
            if queue.lock().unwrap().len() >= PARALLEL * 4 {
138
                let batch: Vec<_> = std::mem::take(&mut *queue.lock().unwrap());
139
                send_batch(&batch, &send, &failed);
140
            }
141
            match failed.lock().unwrap().take() {
142
                Some(e) => Err(images::Error::Bad(format!("{e:#}"))),
143
                None => Ok(()),
144
            }
145
        })?;
146
        let rest: Vec<_> = std::mem::take(&mut *queue.lock().unwrap());
147
        send_batch(&rest, &send, &failed);
148
        if let Some(e) = failed.lock().unwrap().take() {
149
            return Err(e);
150
        }
151
        let url = format!("{}/flow/images/{hash}", self.base);
8a4f5174 Andy McCall ↶ 152
        // The hub reads the whole image before it answers, which on a busy
153
        // disk takes far longer than the agent's 120 s (oxygenhub#341).
154
        self.agent
155
            .put(&url)
156
            .timeout(Duration::from_secs(3600))
157
            .set("Authorization", &self.auth())
158
            .send_json(&m)
159
            .map_err(|e| Self::fail(&url, e))?;
4cf0c99a Andy McCall ↶ 160
        let bytes = *bytes.lock().unwrap();
b14aeaae Andy McCall ↶ 161
        Ok(Moved { chunks: count, bytes, peer_bytes: 0, elapsed: start.elapsed() })
4cf0c99a Andy McCall ↶ 162
    }
163
164
    /// Fetch image `hash` into `home`: the chunks its cache lacks, the
165
    /// image written and checked, its architecture recorded. `Ok(None)`
166
    /// when the hub does not hold it.
167
    pub fn fetch(&self, home: &Home, hash: &str) -> Result<Option<Moved>> {
168
        let start = Instant::now();
169
        let Some(m) = self.manifest(hash)? else { return Ok(None) };
170
        if m.hash != hash {
171
            bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]);
172
        }
173
        let cache = chunk_cache(home);
b14aeaae Andy McCall ↶ 174
        save_manifest(&home.root, &m)?;
4cf0c99a Andy McCall ↶ 175
        let wanted: Vec<String> = m.needed().into_iter().filter(|h| !cache_path(&cache, h).is_file()).map(String::from).collect();
176
        let (bytes, failed) = (Mutex::new(0u64), Mutex::new(None::<anyhow::Error>));
177
        let next = Mutex::new(wanted.iter());
178
        std::thread::scope(|s| {
179
            for _ in 0..PARALLEL {
180
                s.spawn(|| loop {
181
                    let Some(h) = next.lock().unwrap().next() else { break };
182
                    if failed.lock().unwrap().is_some() {
183
                        break;
184
                    }
185
                    let got = self.chunk(h).and_then(|b| {
186
                        if blake3::hash(&b).to_hex().as_str() != h {
187
                            bail!("chunk {} came back with other bytes", &h[..12]);
188
                        }
189
                        let p = cache_path(&cache, h);
190
                        std::fs::create_dir_all(p.parent().expect("a chunk directory"))?;
191
                        let tmp = p.with_extension("part");
192
                        std::fs::write(&tmp, &b)?;
193
                        std::fs::rename(&tmp, &p)?;
194
                        *bytes.lock().unwrap() += b.len() as u64;
195
                        Ok(())
196
                    });
197
                    if let Err(e) = got {
198
                        *failed.lock().unwrap() = Some(e);
199
                        break;
200
                    }
201
                });
202
            }
203
        });
204
        if let Some(e) = failed.into_inner().unwrap() {
205
            return Err(e);
206
        }
b14aeaae Andy McCall ↶ 207
        write_image(home, &m)?;
208
        Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), peer_bytes: 0, elapsed: start.elapsed() }))
209
    }
210
}
211
212
/// Write image `m` into `home` from the chunk cache, checked against its
213
/// hash, and record its architecture.
214
pub(crate) fn write_image(home: &Home, m: &Manifest) -> Result<()> {
215
    let cache = chunk_cache(home);
216
    let out = home.rootfs(&m.hash);
217
    std::fs::create_dir_all(out.parent().expect("the cas directory"))?;
218
    images::assemble(m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?))
219
        .with_context(|| format!("write image {}", &m.hash[..12]))?;
220
    if let Some(arch) = &m.arch {
221
        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" });
222
        std::fs::write(home.image_manifest(&m.hash), serde_json::to_vec(&manifest)?)?;
4cf0c99a Andy McCall ↶ 223
    }
b14aeaae Andy McCall ↶ 224
    Ok(())
225
}
226
32f54b39 Andy McCall ↶ 227
impl Client {
228
    /// Tell the hub which images this home holds, and send the manifest of
229
    /// each it keeps none for (oxygenflow#94, jetstream.md §8.1.1): the
230
    /// swarm can then carry an image nobody pushed. A manifest not kept here
231
    /// yet is made by reading the image once, and kept. How many were sent;
232
    /// none from a hub that predates it.
233
    pub fn offer_held(&self, home: &Home) -> Result<usize> {
234
        let images = home.images();
235
        if images.is_empty() {
236
            return Ok(0);
237
        }
238
        let url = format!("{}/flow/images/held", self.base);
239
        let wanted: flow_core::api::WantedManifests =
240
            match self.agent.post(&url).set("Authorization", &self.auth()).send_json(flow_core::api::HeldImages { images }) {
241
                Ok(r) => r.into_json()?,
242
                Err(ureq::Error::Status(404, _)) => return Ok(0),
243
                Err(e) => return Err(Self::fail(&url, e)),
244
            };
245
        let mut sent = 0;
246
        for hash in wanted.wanted.iter().filter(|h| images::is_hash(h)) {
247
            let path = home.rootfs(hash);
248
            if !path.is_file() {
249
                continue;
250
            }
251
            let kept = std::fs::read(manifest_path(&home.root, hash)).ok().and_then(|b| serde_json::from_slice::<Manifest>(&b).ok());
252
            let m = match kept.filter(|m| m.hash == *hash) {
253
                Some(m) => m,
254
                None => {
255
                    let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?;
256
                    if m.hash != *hash {
257
                        continue;
258
                    }
259
                    save_manifest(&home.root, &m)?;
260
                    m
261
                }
262
            };
263
            let url = format!("{}/flow/images/{hash}/held", self.base);
264
            self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?;
265
            sent += 1;
266
        }
267
        Ok(sent)
268
    }
269
}
270
b14aeaae Andy McCall ↶ 271
/// Where an image's chunk manifest is kept (`manifests/<hash>.json`), so
272
/// the runner can serve its chunks to peers out of the image itself.
273
pub(crate) fn manifest_path(root: &Path, hash: &str) -> PathBuf {
274
    root.join("manifests").join(format!("{hash}.json"))
275
}
276
277
pub(crate) fn save_manifest(root: &Path, m: &Manifest) -> Result<()> {
278
    let p = manifest_path(root, &m.hash);
279
    std::fs::create_dir_all(p.parent().expect("the manifests directory"))?;
280
    let tmp = p.with_extension("part");
281
    std::fs::write(&tmp, serde_json::to_vec(m)?)?;
282
    std::fs::rename(&tmp, &p)?;
283
    Ok(())
4cf0c99a Andy McCall ↶ 284
}
285
286
fn send_batch(batch: &[(String, Vec<u8>)], send: &(dyn Fn(&str, &[u8]) -> Result<()> + Sync), failed: &Mutex<Option<anyhow::Error>>) {
287
    std::thread::scope(|s| {
288
        for part in batch.chunks(batch.len().div_ceil(PARALLEL).max(1)) {
289
            s.spawn(move || {
290
                for (h, b) in part {
291
                    if let Err(e) = send(h, b) {
292
                        *failed.lock().unwrap() = Some(e);
293
                        return;
294
                    }
295
                }
296
            });
297
        }
298
    });
299
}
300
301
/// The home's chunk cache.
302
pub fn chunk_cache(home: &Home) -> PathBuf {
303
    home.root.join("chunks")
304
}
305
b14aeaae Andy McCall ↶ 306
pub(crate) fn cache_path(cache: &Path, hash: &str) -> PathBuf {
4cf0c99a Andy McCall ↶ 307
    cache.join(&hash[..2]).join(hash)
308
}
309
310
/// A size for people: 991 MiB, 1.2 GiB.
311
pub fn human(bytes: u64) -> String {
312
    if bytes >= 1 << 30 {
313
        format!("{:.1} GiB", bytes as f64 / (1u64 << 30) as f64)
314
    } else {
315
        format!("{} MiB", bytes >> 20)
316
    }
317
}