OXYGENHUB
# 3e3cc9475ab3 oxygenflow / crates / flow / src / swarm.rs
35.7 KiB · Rust · last changed Jetstream: runners share images peer to peer 23 days ago source blame history raw
1
//! The swarm (oxygenflow#47, docs/jetstream.md §8.1): a runner with a
2
//! `[runner.swarm]` section serves the image chunks it holds to its peers,
3
//! and fetches an image it lacks from its peers and the hub together.
4
//!
5
//! The server is a small HTTP/1.1 endpoint on the swarm address: a peer
6
//! shows the ticket its hub signed and gets a session, then asks which
7
//! chunks of an image this runner has and for chunks by hash. A chunk is
8
//! served from the chunk cache, or read out of a whole image by its
9
//! manifest. Every chunk is checked by the side that asked for it, so a
10
//! peer can only fail to help.
11
12
use crate::home::{Home, RunnerConfig};
13
use crate::images::{cache_path, chunk_cache, manifest_path, save_manifest, write_image, Client, Moved};
14
use anyhow::{bail, Context, Result};
15
use flow_core::api::{SwarmAddr, SwarmPeer};
16
use jet::images::{is_hash, Manifest};
17
use jet::swarm::{Bits, Pick, Picker, Source, Ticket};
18
use oxy_core::identity::Identity;
19
use std::collections::HashMap;
20
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
21
use std::net::{TcpListener, TcpStream};
22
use std::path::{Path, PathBuf};
23
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
24
use std::sync::{Arc, Mutex};
25
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
26
27
/// Connections served at once; more are told 503 and try elsewhere.
28
const MAX_CONNS: usize = 16;
29
/// Requests in flight to one peer, and to the hub.
30
const PEER_WORKERS: usize = 4;
31
const HUB_WORKERS: usize = 8;
32
/// How often a fetcher asks its peers what they have.
33
const REFRESH: Duration = Duration::from_millis(500);
34
/// Hub failures a fetch survives before it gives up.
35
const HUB_FAILURES: usize = 5;
36
/// The largest request body the server reads: a ticket.
37
const MAX_BODY: usize = 64 * 1024;
38
/// Names the part files of chunks two sources fetch at once apart.
39
static PART: AtomicU64 = AtomicU64::new(0);
40
41
fn now_ms() -> u64 {
42
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
43
}
44
45
/// An image this runner offers: which of its needed chunks are here
46
/// (`None`: all of them, the image is whole) and which it is fetching now.
47
struct Offer {
48
    bits: Option<Bits>,
49
    fetching: Bits,
50
}
51
52
struct State {
53
    root: PathBuf,
54
    site: String,
55
    client: Client,
56
    /// The hub's identity, which signs tickets; fetched when first needed.
57
    hub: Mutex<Option<Identity>>,
58
    offers: Mutex<HashMap<String, Offer>>,
59
    /// Where a chunk lies in a whole image: the image, offset and length.
60
    located: Mutex<HashMap<String, (PathBuf, u64, u32)>>,
61
    /// Sessions: token to (peer name, expiry).
62
    sessions: Mutex<HashMap<String, (String, u64)>>,
63
    conns: AtomicUsize,
64
    /// The upload allowance: bytes in hand (negative: owed) and when it
65
    /// was last topped up.
66
    bucket: Mutex<(f64, Instant)>,
67
    rate: f64,
68
}
69
70
/// This runner's place in the swarm.
71
pub struct Swarm {
72
    state: Arc<State>,
73
    pub addr: SwarmAddr,
74
    peer: ureq::Agent,
75
}
76
77
impl Swarm {
78
    /// Start serving peers when the runner is configured for it.
79
    pub fn start(home: &Home, cfg: &RunnerConfig) -> Result<Option<Swarm>> {
80
        let Some(sc) = &cfg.swarm else { return Ok(None) };
81
        let listener = TcpListener::bind(&sc.listen).with_context(|| format!("swarm: listen on {}", sc.listen))?;
82
        let client = Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref())?;
83
        let hub = client.hub_identity().map_err(|e| eprintln!("swarm: the hub's key: {e:#}; trying again when a peer comes")).ok();
84
        let rate = sc.upload_mib_s.unwrap_or(50).max(1) as f64 * (1 << 20) as f64;
85
        let state = Arc::new(State {
86
            root: home.root.clone(),
87
            site: sc.site.clone(),
88
            client,
89
            hub: Mutex::new(hub),
90
            offers: Mutex::new(HashMap::new()),
91
            located: Mutex::new(HashMap::new()),
92
            sessions: Mutex::new(HashMap::new()),
93
            conns: AtomicUsize::new(0),
94
            bucket: Mutex::new((rate, Instant::now())),
95
            rate,
96
        });
97
        // Whole images: offered from their manifests; one without a
98
        // manifest (imported here, not fetched) is scanned for one, in the
99
        // background and one at a time.
100
        let mut unscanned = Vec::new();
101
        for h in home.images() {
102
            match std::fs::read(manifest_path(&home.root, &h)).ok().and_then(|b| serde_json::from_slice::<Manifest>(&b).ok()) {
103
                Some(m) if m.hash == h => offer_whole(&state, &m, &home.rootfs(&h)),
104
                _ => unscanned.push((h.clone(), home.rootfs(&h), home.image_arch(&h))),
105
            }
106
        }
107
        let scanning = state.clone();
108
        std::thread::spawn(move || {
109
            for (h, path, arch) in unscanned {
110
                match jet::images::scan_file(&path, arch, |_, _| Ok(())) {
111
                    Ok(m) if m.hash == h => {
112
                        save_manifest(&scanning.root, &m).ok();
113
                        offer_whole(&scanning, &m, &path);
114
                    }
115
                    Ok(_) => eprintln!("swarm: {} does not hold the image its name says; not offering it", path.display()),
116
                    Err(e) => eprintln!("swarm: scan {}: {e}", path.display()),
117
                }
118
            }
119
        });
120
        let serving = state.clone();
121
        std::thread::spawn(move || accept(listener, serving));
122
        let addr = SwarmAddr { addr: sc.advertise.clone().unwrap_or_else(|| sc.listen.clone()), site: sc.site.clone() };
123
        eprintln!("swarm: serving image chunks on {} (site {:?}, up to {} MiB/s)", sc.listen, sc.site, sc.upload_mib_s.unwrap_or(50));
124
        let peer = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(2)).timeout(Duration::from_secs(20)).build();
125
        Ok(Some(Swarm { state, addr, peer }))
126
    }
127
128
    /// Fetch image `hash` into `home` from the peers and the hub (§8.1):
129
    /// the rarest chunks from peers first, the hub for what no peer has.
130
    /// `Ok(None)` when the hub does not hold it.
131
    pub fn fetch(&self, home: &Home, hash: &str) -> Result<Option<Moved>> {
132
        let start = Instant::now();
133
        let client = &self.state.client;
134
        let Some(m) = client.manifest(hash)? else { return Ok(None) };
135
        if m.hash != hash {
136
            bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]);
137
        }
138
        save_manifest(&home.root, &m)?;
139
        let needed: Vec<String> = m.needed().into_iter().map(String::from).collect();
140
        let cache = chunk_cache(home);
141
        let mut have = Bits::new(needed.len());
142
        for (i, h) in needed.iter().enumerate() {
143
            if cache_path(&cache, h).is_file() {
144
                have.set(i);
145
            }
146
        }
147
        self.state.offers.lock().unwrap().insert(hash.into(), Offer { bits: Some(have.clone()), fetching: Bits::new(needed.len()) });
148
        let announced = client
149
            .announce(hash, "fetching")
150
            .map_err(|e| eprintln!("swarm: announce {}: {e:#}; fetching from the hub alone", &hash[..12]))
151
            .ok();
152
        let seed = now_ms() ^ (std::process::id() as u64) << 32;
153
        let picker = Mutex::new(Picker::new(needed.len(), have, seed));
154
        let peers: Mutex<Vec<Arc<Peer>>> = Mutex::new(Vec::new());
155
        if let Some(a) = &announced {
156
            self.join(&a.peers, &a.ticket, hash, needed.len(), &peers, &picker);
157
        }
158
        let (bytes, peer_bytes, got) = (AtomicU64::new(0), AtomicU64::new(0), AtomicUsize::new(0));
159
        let (failed, hub_failures) = (Mutex::new(None::<anyhow::Error>), AtomicUsize::new(0));
160
        {
161
            let (picker, peers, needed, cache) = (&picker, &peers, &needed, &cache);
162
            let (bytes, peer_bytes, got, failed, hub_failures) = (&bytes, &peer_bytes, &got, &failed, &hub_failures);
163
            let (state, m) = (&self.state, &m);
164
            let worker = move |src: Source| loop {
165
                if failed.lock().unwrap().is_some() {
166
                    break;
167
                }
168
                let peer = match src {
169
                    Source::Peer(p) => Some(peers.lock().unwrap()[p].clone()),
170
                    Source::Hub => None,
171
                };
172
                if peer.as_ref().is_some_and(|p| !p.alive.load(Ordering::Relaxed)) {
173
                    break;
174
                }
175
                // Taken apart from the match, so the lock is not held
176
                // while waiting.
177
                let pick = picker.lock().unwrap().pick(src);
178
                let i = match pick {
179
                    Pick::Done => break,
180
                    Pick::Wait => {
181
                        std::thread::sleep(Duration::from_millis(20));
182
                        continue;
183
                    }
184
                    Pick::Chunk(i) => i,
185
                };
186
                if let Some(o) = state.offers.lock().unwrap().get_mut(&m.hash) {
187
                    o.fetching.set(i);
188
                }
189
                let h = &needed[i];
190
                let fetched = match &peer {
191
                    Some(p) => self.peer_chunk(p, h),
192
                    None => client.chunk(h),
193
                };
194
                let kept = fetched.and_then(|b| {
195
                    if blake3::hash(&b).to_hex().as_str() != h {
196
                        bail!("chunk {} came back with other bytes", &h[..12]);
197
                    }
198
                    let p = cache_path(cache, h);
199
                    std::fs::create_dir_all(p.parent().expect("a chunk directory"))?;
200
                    let tmp = p.with_extension(format!("part{}", PART.fetch_add(1, Ordering::Relaxed)));
201
                    std::fs::write(&tmp, &b)?;
202
                    std::fs::rename(&tmp, &p)?;
203
                    Ok(b.len() as u64)
204
                });
205
                match kept {
206
                    Ok(n) => {
207
                        if let Some(Offer { bits: Some(b), fetching }) = state.offers.lock().unwrap().get_mut(&m.hash) {
208
                            b.set(i);
209
                            fetching.clear(i);
210
                        }
211
                        picker.lock().unwrap().got(i);
212
                        bytes.fetch_add(n, Ordering::Relaxed);
213
                        got.fetch_add(1, Ordering::Relaxed);
214
                        if peer.is_some() {
215
                            peer_bytes.fetch_add(n, Ordering::Relaxed);
216
                        }
217
                    }
218
                    Err(e) => {
219
                        picker.lock().unwrap().failed(i);
220
                        if let Some(o) = state.offers.lock().unwrap().get_mut(&m.hash) {
221
                            o.fetching.clear(i);
222
                        }
223
                        match (&peer, src) {
224
                            (Some(p), Source::Peer(idx)) => {
225
                                eprintln!("swarm: peer {} failed ({e:#}); carrying on without it", p.name);
226
                                p.alive.store(false, Ordering::Relaxed);
227
                                picker.lock().unwrap().drop_peer(idx);
228
                                break;
229
                            }
230
                            _ => {
231
                                if hub_failures.fetch_add(1, Ordering::Relaxed) + 1 >= HUB_FAILURES {
232
                                    *failed.lock().unwrap() = Some(e);
233
                                    break;
234
                                }
235
                                std::thread::sleep(Duration::from_millis(500));
236
                            }
237
                        }
238
                    }
239
                }
240
            };
241
            std::thread::scope(|s| {
242
                for _ in 0..HUB_WORKERS {
243
                    s.spawn(move || worker(Source::Hub));
244
                }
245
                let mut spawned = 0;
246
                let mut spawn_new = || {
247
                    let n = peers.lock().unwrap().len();
248
                    for p in spawned..n {
249
                        for _ in 0..PEER_WORKERS {
250
                            s.spawn(move || worker(Source::Peer(p)));
251
                        }
252
                    }
253
                    spawned = n;
254
                };
255
                spawn_new();
256
                // Keep the peers' maps fresh, and find new peers while
257
                // fetching, until every chunk is here.
258
                let mut asked_hub = Instant::now();
259
                let interval = Duration::from_secs(announced.as_ref().map(|a| a.interval_s).unwrap_or(30).max(5));
260
                let mut last = Instant::now();
261
                while picker.lock().unwrap().missing() > 0 && failed.lock().unwrap().is_none() {
262
                    std::thread::sleep(Duration::from_millis(100));
263
                    if last.elapsed() < REFRESH {
264
                        continue;
265
                    }
266
                    last = Instant::now();
267
                    let now: Vec<(usize, Arc<Peer>)> =
268
                        peers.lock().unwrap().iter().cloned().enumerate().filter(|(_, p)| p.alive.load(Ordering::Relaxed)).collect();
269
                    for (idx, p) in now {
270
                        match self.peer_have(&p, hash, needed.len()) {
271
                            Ok((bits, fetching)) => picker.lock().unwrap().peer_fetching(idx, bits, fetching),
272
                            Err(_) => {
273
                                p.alive.store(false, Ordering::Relaxed);
274
                                picker.lock().unwrap().drop_peer(idx);
275
                            }
276
                        }
277
                    }
278
                    if announced.is_some() && asked_hub.elapsed() >= interval {
279
                        asked_hub = Instant::now();
280
                        if let Ok(a) = client.announce(hash, "fetching") {
281
                            self.join(&a.peers, &a.ticket, hash, needed.len(), peers, picker);
282
                            spawn_new();
283
                        }
284
                    }
285
                }
286
            });
287
        }
288
        if let Some(e) = failed.into_inner().unwrap() {
289
            client.announce(hash, "stopped").ok();
290
            return Err(e);
291
        }
292
        write_image(home, &m)?;
293
        offer_whole(&self.state, &m, &home.rootfs(hash));
294
        client.announce(hash, "done").ok();
295
        Ok(Some(Moved {
296
            chunks: got.into_inner(),
297
            bytes: bytes.into_inner(),
298
            peer_bytes: peer_bytes.into_inner(),
299
            elapsed: start.elapsed(),
300
        }))
301
    }
302
303
    /// Admit this runner to the peers it does not know yet: a session from
304
    /// each, and what each has.
305
    fn join(&self, found: &[SwarmPeer], ticket: &str, hash: &str, len: usize, peers: &Mutex<Vec<Arc<Peer>>>, picker: &Mutex<Picker>) {
306
        for sp in found {
307
            if peers.lock().unwrap().iter().any(|p| p.runner == sp.runner) {
308
                continue;
309
            }
310
            let joined = self.hello(&sp.addr, ticket).and_then(|session| {
311
                let p = Peer {
312
                    runner: sp.runner,
313
                    name: sp.name.clone(),
314
                    base: format!("http://{}/swarm/v1", sp.addr),
315
                    session,
316
                    alive: AtomicBool::new(true),
317
                };
318
                let (bits, fetching) = self.peer_have(&p, hash, len)?;
319
                Ok((p, bits, fetching))
320
            });
321
            match joined {
322
                Ok((p, bits, fetching)) => {
323
                    let mut list = peers.lock().unwrap();
324
                    picker.lock().unwrap().peer_fetching(list.len(), bits, fetching);
325
                    list.push(Arc::new(p));
326
                }
327
                Err(e) => eprintln!("swarm: peer {} ({}): {e:#}", sp.name, sp.addr),
328
            }
329
        }
330
    }
331
332
    fn hello(&self, addr: &str, ticket: &str) -> Result<String> {
333
        let url = format!("http://{addr}/swarm/v1/hello");
334
        let r: serde_json::Value = self.peer.post(&url).send_string(ticket).map_err(|e| peer_error(&url, e))?.into_json()?;
335
        r["session"].as_str().map(String::from).context("the peer gave no session")
336
    }
337
338
    /// What a peer has of image `hash`, and what it is fetching now:
339
    /// nothing yet when it has not begun (the tracker may name it a moment
340
    /// before it starts).
341
    fn peer_have(&self, p: &Peer, hash: &str, len: usize) -> Result<(Bits, Bits)> {
342
        let url = format!("{}/images/{hash}/have", p.base);
343
        let r = match self.peer.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call() {
344
            Ok(r) => r,
345
            Err(ureq::Error::Status(404, _)) => return Ok((Bits::new(len), Bits::new(len))),
346
            Err(e) => return Err(peer_error(&url, e)),
347
        };
348
        let mut body = Vec::new();
349
        r.into_reader().take(len as u64 / 4 + 64).read_to_end(&mut body)?;
350
        if body == b"complete" {
351
            return Ok((Bits::full(len), Bits::new(len)));
352
        }
353
        let (have, fetching) = body.split_at(len.div_ceil(8).min(body.len()));
354
        Ok((Bits::from_bytes(len, have), Bits::from_bytes(len, fetching)))
355
    }
356
357
    fn peer_chunk(&self, p: &Peer, hash: &str) -> Result<Vec<u8>> {
358
        let url = format!("{}/chunks/{hash}", p.base);
359
        let r = self.peer.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call().map_err(|e| peer_error(&url, e))?;
360
        let mut out = Vec::new();
361
        r.into_reader().take(oxy_core::chunk::MAX_SIZE as u64 + 1).read_to_end(&mut out)?;
362
        Ok(out)
363
    }
364
}
365
366
/// A peer this fetch asks for chunks.
367
struct Peer {
368
    runner: i64,
369
    name: String,
370
    base: String,
371
    session: String,
372
    alive: AtomicBool,
373
}
374
375
fn peer_error(url: &str, e: ureq::Error) -> anyhow::Error {
376
    match e {
377
        ureq::Error::Status(code, r) => anyhow::anyhow!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default().trim()),
378
        e => anyhow::anyhow!("{url}: {e}"),
379
    }
380
}
381
382
/// Offer a whole image: every chunk of it can be read out of `path`.
383
fn offer_whole(state: &State, m: &Manifest, path: &Path) {
384
    let mut located = state.located.lock().unwrap();
385
    let mut at = 0u64;
386
    for c in &m.chunks {
387
        if !c.is_zeros() {
388
            located.entry(c.hash().to_string()).or_insert_with(|| (path.to_path_buf(), at, c.1));
389
        }
390
        at += c.1 as u64;
391
    }
392
    state.offers.lock().unwrap().insert(m.hash.clone(), Offer { bits: None, fetching: Bits::new(0) });
393
}
394
395
fn accept(listener: TcpListener, state: Arc<State>) {
396
    for conn in listener.incoming() {
397
        let Ok(mut conn) = conn else { continue };
398
        if state.conns.load(Ordering::Relaxed) >= MAX_CONNS {
399
            respond(&mut conn, 503, "text/plain", b"busy: ask another peer").ok();
400
            continue;
401
        }
402
        state.conns.fetch_add(1, Ordering::Relaxed);
403
        let state = state.clone();
404
        std::thread::spawn(move || {
405
            serve_conn(conn, &state).ok();
406
            state.conns.fetch_sub(1, Ordering::Relaxed);
407
        });
408
    }
409
}
410
411
fn respond(conn: &mut TcpStream, code: u16, kind: &str, body: &[u8]) -> std::io::Result<()> {
412
    let reason = match code {
413
        200 => "OK",
414
        400 => "Bad Request",
415
        401 => "Unauthorized",
416
        403 => "Forbidden",
417
        404 => "Not Found",
418
        503 => "Service Unavailable",
419
        _ => "Error",
420
    };
421
    write!(conn, "HTTP/1.1 {code} {reason}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\n\r\n", body.len())?;
422
    conn.write_all(body)?;
423
    conn.flush()
424
}
425
426
/// One connection: requests one after another while the peer keeps it.
427
fn serve_conn(conn: TcpStream, state: &State) -> std::io::Result<()> {
428
    conn.set_read_timeout(Some(Duration::from_secs(30)))?;
429
    let mut out = conn.try_clone()?;
430
    let mut r = BufReader::new(conn);
431
    loop {
432
        let mut line = String::new();
433
        if r.read_line(&mut line)? == 0 {
434
            return Ok(());
435
        }
436
        let mut parts = line.split_whitespace();
437
        let (method, path) = (parts.next().unwrap_or("").to_string(), parts.next().unwrap_or("").to_string());
438
        let (mut length, mut auth, mut close) = (0usize, String::new(), false);
439
        loop {
440
            let mut h = String::new();
441
            if r.read_line(&mut h)? == 0 {
442
                return Ok(());
443
            }
444
            let h = h.trim_end();
445
            if h.is_empty() {
446
                break;
447
            }
448
            if let Some((k, v)) = h.split_once(':') {
449
                match k.trim().to_ascii_lowercase().as_str() {
450
                    "content-length" => length = v.trim().parse().unwrap_or(usize::MAX),
451
                    "authorization" => auth = v.trim().to_string(),
452
                    "connection" => close = v.trim().eq_ignore_ascii_case("close"),
453
                    _ => {}
454
                }
455
            }
456
        }
457
        if length > MAX_BODY {
458
            return respond(&mut out, 400, "text/plain", b"too large");
459
        }
460
        let mut body = vec![0; length];
461
        r.read_exact(&mut body)?;
462
        let (code, kind, reply) = route(state, &method, &path, &auth, &body);
463
        if code == 200 && kind == "application/octet-stream" {
464
            throttle(state, reply.len());
465
        }
466
        respond(&mut out, code, kind, &reply)?;
467
        if close {
468
            return Ok(());
469
        }
470
    }
471
}
472
473
fn route(state: &State, method: &str, path: &str, auth: &str, body: &[u8]) -> (u16, &'static str, Vec<u8>) {
474
    let text = |code, s: &str| (code, "text/plain", s.as_bytes().to_vec());
475
    if method == "POST" && path == "/swarm/v1/hello" {
476
        return match hello(state, &String::from_utf8_lossy(body)) {
477
            Ok(session) => (200, "application/json", serde_json::json!({ "session": session }).to_string().into_bytes()),
478
            Err(why) => text(403, &why),
479
        };
480
    }
481
    let session = auth.strip_prefix("Bearer ").unwrap_or("");
482
    let admitted = {
483
        let mut sessions = state.sessions.lock().unwrap();
484
        let now = now_ms();
485
        sessions.retain(|_, (_, until)| *until > now);
486
        sessions.contains_key(session)
487
    };
488
    if !admitted {
489
        return text(401, "show a ticket at /swarm/v1/hello first");
490
    }
491
    let rest = path.strip_prefix("/swarm/v1/").unwrap_or("");
492
    match (method, rest.split('/').collect::<Vec<_>>().as_slice()) {
493
        ("GET", ["images", image, "have"]) => match state.offers.lock().unwrap().get(*image) {
494
            Some(Offer { bits: None, .. }) => (200, "text/plain", b"complete".to_vec()),
495
            Some(Offer { bits: Some(b), fetching }) => (200, "application/x-have", [b.to_bytes(), fetching.to_bytes()].concat()),
496
            None => text(404, "this runner does not hold that image"),
497
        },
498
        ("GET", ["chunks", chunk]) if is_hash(chunk) => match read_chunk(state, chunk) {
499
            Some(b) => (200, "application/octet-stream", b),
500
            None => text(404, "no such chunk here"),
501
        },
502
        _ => text(404, "not a swarm path"),
503
    }
504
}
505
506
/// Admit a peer that shows a ticket from this runner's hub, for this
507
/// site: a session until the ticket expires.
508
fn hello(state: &State, ticket: &str) -> std::result::Result<String, String> {
509
    let hub = {
510
        let mut hub = state.hub.lock().unwrap();
511
        if hub.is_none() {
512
            *hub = state.client.hub_identity().ok();
513
        }
514
        hub.clone().ok_or("this runner cannot reach its hub to check tickets")?
515
    };
516
    let t = Ticket::open(ticket, &hub, now_ms())?;
517
    if t.site != state.site {
518
        return Err(format!("this runner is in site {:?}, the ticket's runner in {:?}", state.site, t.site));
519
    }
520
    let session = random_token().map_err(|e| format!("make a session: {e}"))?;
521
    state.sessions.lock().unwrap().insert(session.clone(), (t.name, t.expires_ms));
522
    Ok(session)
523
}
524
525
fn random_token() -> std::io::Result<String> {
526
    let mut b = [0u8; 32];
527
    std::fs::File::open("/dev/urandom")?.read_exact(&mut b)?;
528
    Ok(hex::encode(b))
529
}
530
531
fn read_chunk(state: &State, hash: &str) -> Option<Vec<u8>> {
532
    if let Ok(b) = std::fs::read(cache_path(&state.root.join("chunks"), hash)) {
533
        return Some(b);
534
    }
535
    let (path, at, len) = state.located.lock().unwrap().get(hash).cloned()?;
536
    let mut f = std::fs::File::open(path).ok()?;
537
    f.seek(SeekFrom::Start(at)).ok()?;
538
    let mut b = vec![0; len as usize];
539
    f.read_exact(&mut b).ok()?;
540
    Some(b)
541
}
542
543
/// Hold back until sending `n` more bytes keeps within the upload rate.
544
fn throttle(state: &State, n: usize) {
545
    let wait = {
546
        let mut b = state.bucket.lock().unwrap();
547
        let now = Instant::now();
548
        b.0 = (b.0 + now.duration_since(b.1).as_secs_f64() * state.rate).min(state.rate);
549
        b.1 = now;
550
        b.0 -= n as f64;
551
        if b.0 < 0.0 {
552
            -b.0 / state.rate
553
        } else {
554
            0.0
555
        }
556
    };
557
    if wait > 0.0 {
558
        std::thread::sleep(Duration::from_secs_f64(wait));
559
    }
560
}
561
562
#[cfg(test)]
563
mod tests {
564
    use super::*;
565
    use oxy_core::identity::SecretKey;
566
567
    /// A hub that holds one image and runs the tracker: it serves the
568
    /// manifest and chunks (counting the chunks), and answers announces
569
    /// with every other runner as a peer and a ticket for the caller,
570
    /// known by its token.
571
    struct FakeHub {
572
        url: String,
573
        chunks_served: Arc<AtomicUsize>,
574
        runners: Arc<Mutex<Vec<(String, SwarmPeer)>>>,
575
    }
576
577
    fn fake_hub(m: Manifest, image: Vec<u8>, site: &str) -> FakeHub {
578
        slow_hub(m, image, site, Duration::ZERO)
579
    }
580
581
    /// A hub that takes `delay` over each chunk.
582
    fn slow_hub(m: Manifest, image: Vec<u8>, site: &str, delay: Duration) -> FakeHub {
583
        let sk = SecretKey::generate();
584
        let id = sk.identity("hub", "hub@example", 1);
585
        let key = id.hash();
586
        let mut chunks = HashMap::new();
587
        let mut at = 0usize;
588
        for c in &m.chunks {
589
            chunks.insert(c.hash().to_string(), image[at..at + c.len()].to_vec());
590
            at += c.len();
591
        }
592
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
593
        let url = format!("http://{}", listener.local_addr().unwrap());
594
        let served = Arc::new(AtomicUsize::new(0));
595
        let runners: Arc<Mutex<Vec<(String, SwarmPeer)>>> = Arc::new(Mutex::new(Vec::new()));
596
        let (count, known, site) = (served.clone(), runners.clone(), site.to_string());
597
        let shared = Arc::new((m, chunks, id, key, sk));
598
        std::thread::spawn(move || {
599
            for conn in listener.incoming() {
600
                let Ok(conn) = conn else { continue };
601
                let (shared, count, known, site) = (shared.clone(), count.clone(), known.clone(), site.clone());
602
                std::thread::spawn(move || {
603
                    let (m, chunks, id, key, sk) = &*shared;
604
                    let mut out = conn.try_clone().unwrap();
605
                    let mut r = BufReader::new(conn);
606
                    loop {
607
                        let mut line = String::new();
608
                        if r.read_line(&mut line).unwrap_or(0) == 0 {
609
                            return;
610
                        }
611
                        let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
612
                        let (mut len, mut token) = (0, String::new());
613
                        loop {
614
                            let mut h = String::new();
615
                            r.read_line(&mut h).unwrap();
616
                            let h = h.trim_end().to_string();
617
                            if h.is_empty() {
618
                                break;
619
                            }
620
                            let lower = h.to_ascii_lowercase();
621
                            if let Some(v) = lower.strip_prefix("content-length:") {
622
                                len = v.trim().parse().unwrap();
623
                            }
624
                            if let Some(v) = h.strip_prefix("Authorization: Bearer ") {
625
                                token = v.to_string();
626
                            }
627
                        }
628
                        let mut body = vec![0; len];
629
                        r.read_exact(&mut body).unwrap();
630
                        let (code, reply): (u16, Vec<u8>) = if path == "/.well-known/oxyhub-key" {
631
                            (200, id.encode())
632
                        } else if path == format!("/api/v1/flow/images/{}", m.hash) {
633
                            (200, serde_json::to_vec(m).unwrap())
634
                        } else if let Some(c) = path.strip_prefix("/api/v1/flow/images/chunks/") {
635
                            count.fetch_add(1, Ordering::Relaxed);
636
                            std::thread::sleep(delay);
637
                            (200, chunks[c].clone())
638
                        } else if path == "/api/v1/flow/swarm/announce" {
639
                            let runners = known.lock().unwrap();
640
                            let me = runners.iter().find(|(t, _)| *t == token).map(|(_, p)| p.clone()).unwrap();
641
                            let ticket = Ticket {
642
                                hub: key.to_hex(),
643
                                runner: me.runner,
644
                                name: me.name.clone(),
645
                                site: site.clone(),
646
                                expires_ms: now_ms() + 60_000,
647
                            };
648
                            let peers: Vec<SwarmPeer> = runners.iter().filter(|(t, _)| *t != token).map(|(_, p)| p.clone()).collect();
649
                            let a = flow_core::api::Announced { ticket: ticket.seal(key, sk), peers, interval_s: 5 };
650
                            (200, serde_json::to_vec(&a).unwrap())
651
                        } else {
652
                            (404, b"no".to_vec())
653
                        };
654
                        let head = format!("HTTP/1.1 {code} X\r\nContent-Length: {}\r\n\r\n", reply.len());
655
                        if out.write_all(head.as_bytes()).and_then(|_| out.write_all(&reply)).is_err() {
656
                            return;
657
                        }
658
                    }
659
                });
660
            }
661
        });
662
        FakeHub { url, chunks_served: served, runners }
663
    }
664
665
    fn free_port() -> u16 {
666
        TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
667
    }
668
669
    /// A runner home in `dir` with a swarm on `port`, known to `hub` by
670
    /// `token` as runner `id`.
671
    fn runner(dir: &Path, hub: &FakeHub, id: i64, token: &str, port: u16, site: &str) -> (Home, Swarm) {
672
        let toml = format!(
673
            "hub = \"{}\"\ntoken = \"{token}\"\nname = \"r{id}\"\n[swarm]\nlisten = \"127.0.0.1:{port}\"\nsite = \"{site}\"\n",
674
            hub.url
675
        );
676
        std::fs::write(dir.join(crate::home::RUNNER_TOML), toml).unwrap();
677
        let home = Home::locate(Some(dir)).unwrap();
678
        let cfg = home.config.runner.clone().unwrap();
679
        let swarm = Swarm::start(&home, &cfg).unwrap().unwrap();
680
        hub.runners
681
            .lock()
682
            .unwrap()
683
            .push((token.into(), SwarmPeer { runner: id, name: format!("r{id}"), addr: format!("127.0.0.1:{port}"), complete: true }));
684
        (home, swarm)
685
    }
686
687
    fn image() -> Vec<u8> {
688
        // 3 MiB of varied bytes, with a run of zeros the swarm never moves.
689
        let mut x = 0x9e3779b97f4a7c15u64;
690
        let mut b: Vec<u8> = (0..3 << 20)
691
            .map(|_| {
692
                x ^= x << 13;
693
                x ^= x >> 7;
694
                x ^= x << 17;
695
                x as u8
696
            })
697
            .collect();
698
        b[1 << 20..(1 << 20) + (300 << 10)].fill(0);
699
        b
700
    }
701
702
    #[test]
703
    fn a_runner_fetches_an_image_from_its_peer_and_not_the_hub() {
704
        let bytes = image();
705
        let m = jet::images::scan(&bytes[..], Some("x86_64".into()), |_, _| Ok(())).unwrap();
706
        let hub = fake_hub(m.clone(), bytes.clone(), "lab");
707
        let (a_dir, b_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
708
        // Runner A holds the image, imported rather than fetched: its swarm
709
        // scans it for a manifest before offering it.
710
        std::fs::create_dir_all(a_dir.path().join("cas")).unwrap();
711
        std::fs::write(a_dir.path().join("cas").join(format!("{}.ext4", m.hash)), &bytes).unwrap();
712
        let (_a_home, a) = runner(a_dir.path(), &hub, 1, "ta", free_port(), "lab");
713
        let deadline = Instant::now() + Duration::from_secs(20);
714
        while !a.state.offers.lock().unwrap().contains_key(&m.hash) {
715
            assert!(Instant::now() < deadline, "runner A never offered its image");
716
            std::thread::sleep(Duration::from_millis(20));
717
        }
718
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
719
        let moved = b.fetch(&b_home, &m.hash).unwrap().unwrap();
720
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes, "the image came back whole");
721
        assert_eq!(moved.peer_bytes, moved.bytes, "every chunk came from the peer");
722
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), 0, "the hub sent no chunks");
723
        assert_eq!(moved.chunks, m.needed().len());
724
        // B now offers the whole image too, read out of its rootfs.
725
        assert!(matches!(b.state.offers.lock().unwrap().get(&m.hash), Some(Offer { bits: None, .. })));
726
    }
727
728
    #[test]
729
    fn runners_starting_together_share_what_each_has_fetched() {
730
        let bytes = image();
731
        let m = jet::images::scan(&bytes[..], None, |_, _| Ok(())).unwrap();
732
        let needed = m.needed().len();
733
        // A slow hub, as a remote one is: the runners have time to swap.
734
        let hub = slow_hub(m.clone(), bytes.clone(), "lab", Duration::from_millis(400));
735
        let (b_dir, c_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
736
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
737
        let (c_home, c) = runner(c_dir.path(), &hub, 3, "tc", free_port(), "lab");
738
        let (mb, mc) = std::thread::scope(|s| {
739
            let fb = s.spawn(|| b.fetch(&b_home, &m.hash).unwrap().unwrap());
740
            let fc = s.spawn(|| c.fetch(&c_home, &m.hash).unwrap().unwrap());
741
            (fb.join().unwrap(), fc.join().unwrap())
742
        });
743
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes);
744
        assert_eq!(std::fs::read(c_home.rootfs(&m.hash)).unwrap(), bytes);
745
        let served = hub.chunks_served.load(Ordering::Relaxed);
746
        assert!(served < 2 * needed, "the hub sent {served} chunks for two runners needing {needed} each");
747
        assert!(mb.peer_bytes + mc.peer_bytes > 0, "they shared");
748
    }
749
750
    #[test]
751
    fn without_peers_the_hub_sends_everything_and_a_ticket_from_another_site_is_refused() {
752
        let bytes = image();
753
        let m = jet::images::scan(&bytes[..], None, |_, _| Ok(())).unwrap();
754
        let hub = fake_hub(m.clone(), bytes.clone(), "lab");
755
        let dir = tempfile::tempdir().unwrap();
756
        let (home, s) = runner(dir.path(), &hub, 1, "t1", free_port(), "lab");
757
        let moved = s.fetch(&home, &m.hash).unwrap().unwrap();
758
        assert_eq!(moved.peer_bytes, 0);
759
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), m.needed().len());
760
        assert_eq!(std::fs::read(home.rootfs(&m.hash)).unwrap(), bytes);
761
762
        // A runner in another site shows a ticket for "lab": refused.
763
        let other = tempfile::tempdir().unwrap();
764
        let port = free_port();
765
        let (_h, o) = runner(other.path(), &hub, 3, "t3", port, "elsewhere");
766
        let ticket = s.state.client.announce(&m.hash, "fetching").unwrap().ticket;
767
        let err = o.hello(&format!("127.0.0.1:{port}"), &ticket).unwrap_err();
768
        assert!(format!("{err:#}").contains("HTTP 403"), "{err:#}");
769
        // And no session, no chunks.
770
        let url = format!("http://127.0.0.1:{port}/swarm/v1/chunks/{}", m.chunks[0].hash());
771
        assert!(matches!(o.peer.get(&url).call(), Err(ureq::Error::Status(401, _))));
772
    }
773
774
    #[test]
775
    fn the_upload_rate_holds_back_a_burst() {
776
        let state = State {
777
            root: PathBuf::new(),
778
            site: String::new(),
779
            client: Client::new("http://127.0.0.1:1", "t", None).unwrap(),
780
            hub: Mutex::new(None),
781
            offers: Mutex::new(HashMap::new()),
782
            located: Mutex::new(HashMap::new()),
783
            sessions: Mutex::new(HashMap::new()),
784
            conns: AtomicUsize::new(0),
785
            bucket: Mutex::new((1_000_000.0, Instant::now())),
786
            rate: 1_000_000.0,
787
        };
788
        let start = Instant::now();
789
        throttle(&state, 1_000_000);
790
        assert!(start.elapsed() < Duration::from_millis(100), "the first second's worth goes at once");
791
        throttle(&state, 300_000);
792
        assert!(start.elapsed() >= Duration::from_millis(250), "then it waits for the rate");
793
    }
794
}