OXYGENHUB

Coverage / crates/hub/src/swarm.rs 89.8%

114 of 127 lines run, 25 of 65 functions · run #513 on main · run missed

1
//! The swarm's tracker (oxygenhub#144, oxygenflow docs/jetstream.md §8.1):
2
//! runners that share images tell the hub where peers reach them with each
3
//! claim; a runner fetching an image announces it and is given the peers
4
//! in its site that hold or are fetching that image, each with the
5
//! fingerprint of its TLS certificate, and a ticket, signed with the hub
6
//! key, that those peers accept.
7
//!
8
//! What the tracker knows lives in memory: runners repeat their address
9
//! with every claim and fetchers announce every `INTERVAL_S`, so a
10
//! restarted hub knows the swarm again within seconds.
11
12
use crate::api::ApiError;
13
use crate::db;
14
use crate::Shared;
15
use axum::extract::State;
16
use axum::http::{HeaderMap, StatusCode};
17
use axum::Json;
18
use flow_core::api::{Announce, Announced, SwarmAddr, SwarmPeer};
19
use jet::swarm::{Ticket, TICKET_LIFE_MS};
20
use std::collections::HashMap;
21
use std::sync::Mutex;
22
23
/// How often a fetcher announces again.
24
pub const INTERVAL_S: u64 = 15;
25
/// A runner whose address is older than this is not offered as a peer.
26
const ADDR_FRESH_MS: i64 = 2 * 60 * 1000;
27
/// A fetcher that has not announced for this long is no longer fetching.
28
const FETCHING_FRESH_MS: i64 = 3 * INTERVAL_S as i64 * 1000;
29
/// A ticket is made again once it has less than this to run.
30
const TICKET_RENEW_MS: u64 = 5 * 60 * 1000;
31
32
#[derive(Default)]
33
pub struct Tracker {
34
    /// Runner id to its swarm address and when it last said so.
35
    addrs: Mutex<HashMap<i64, (SwarmAddr, i64)>>,
36
    /// Image to the runners fetching it, with when each last announced.
37
    fetching: Mutex<HashMap<String, HashMap<i64, i64>>>,
38
    /// Runner id to its current ticket and when that expires.
39
    tickets: Mutex<HashMap<i64, (String, u64)>>,
40
}
41
42
impl Tracker {
43
    /// What a runner's claim said: where peers reach it, or that it is not
44
    /// in a swarm.
45222×
    pub fn claimed(&self, runner: i64, swarm: Option<&SwarmAddr>) {
46222×
        let mut addrs = self.addrs.lock().unwrap_or_else(|p| p.into_inner());
47
        // A peer is trusted by its certificate, so one without is not in
48
        // the swarm (an older flow).
49222×
        match swarm.filter(|s| plausible(&s.addr) && jet::images::is_hash(&s.cert)) {
505×
            Some(s) => {
515×
                addrs.insert(runner, (s.clone(), db::now_ms()));
525×
            }
53217×
            None => {
54217×
                addrs.remove(&runner);
55217×
            }
56
        }
57222×
    }
58
59
    /// Where peers reach `runner`, if it is in a swarm now.
60332×
    pub fn addr(&self, runner: i64) -> Option<SwarmAddr> {
61332×
        let addrs = self.addrs.lock().unwrap_or_else(|p| p.into_inner());
62332×
        addrs.get(&runner).filter(|(_, seen)| db::now_ms() - seen < ADDR_FRESH_MS).map(|(a, _)| a.clone())
63332×
    }
64
654×
    fn fetch(&self, image: &str, runner: i64, state: &str) {
664×
        let mut fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner());
674×
        let now = db::now_ms();
684×
        for who in fetching.values_mut() {
694×
            who.retain(|_, seen| now - *seen < FETCHING_FRESH_MS);
70
        }
714×
        fetching.retain(|_, who| !who.is_empty());
724×
        if state == "fetching" {
733×
            fetching.entry(image.to_string()).or_default().insert(runner, now);
743×
        } else if let Some(who) = fetching.get_mut(image) {
751×
            who.remove(&runner);
761×
        }
774×
    }
78
79
    /// Who is fetching which image now, by image.
802×
    pub fn fetching_now(&self) -> HashMap<String, Vec<i64>> {
812×
        let fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner());
822×
        let now = db::now_ms();
832×
        fetching
842×
            .iter()
852×
            .map(|(image, who)| {
860×
                (image.clone(), who.iter().filter(|(_, seen)| now - **seen < FETCHING_FRESH_MS).map(|(r, _)| *r).collect::<Vec<_>>())
870×
            })
882×
            .filter(|(_, who)| !who.is_empty())
892×
            .collect()
902×
    }
91
924×
    fn fetchers(&self, image: &str) -> Vec<i64> {
934×
        let fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner());
944×
        fetching.get(image).map(|who| who.keys().copied().collect()).unwrap_or_default()
954×
    }
96
}
97
98
/// An address a peer could dial: `host:port`, nothing else.
9913×
fn plausible(addr: &str) -> bool {
10013×
    addr.len() <= 255 && addr.rsplit_once(':').is_some_and(|(host, port)| !host.is_empty() && port.parse::<u16>().is_ok_and(|p| p > 0))
10113×
}
102
103
/// `POST /flow/swarm/announce`: a runner starting, continuing or ending a
104
/// fetch. The peers it may ask, in its site, and its ticket.
1058×
pub async fn announce(State(app): State<Shared>, headers: HeaderMap, Json(a): Json<Announce>) -> Result<Json<Announced>, ApiError> {
1068×
    let me = crate::flow::runner_auth(&app, &headers).await?;
1077×
    if !jet::images::is_hash(&a.image) || !matches!(a.state.as_str(), "fetching" | "done" | "stopped") {
1081×
        return Err(ApiError(StatusCode::BAD_REQUEST, "announce an image's hash with state fetching, done or stopped".into()));
1096×
    }
1106×
    let image = a.image.to_ascii_lowercase();
1116×
    let Some(mine) = app.swarm.addr(me.id) else {
1122×
        return Err(ApiError(
1132×
            StatusCode::CONFLICT,
1142×
            "this runner has not said where peers reach it: give it a [runner.swarm] section and let it claim".into(),
1152×
        ));
116
    };
1174×
    app.swarm.fetch(&image, me.id, &a.state);
1184×
    let fetchers = app.swarm.fetchers(&image);
1194×
    let mut peers = Vec::new();
1204×
    if a.state == "fetching" {
1219×
        for r in db::runners::list(&app.db).await? {
1229×
            if r.id == me.id || r.state == "disabled" || !r.online() {
1233×
                continue;
1246×
            }
1256×
            let Some(addr) = app.swarm.addr(r.id).filter(|s| s.site == mine.site) else { continue };
1264×
            let complete = r.image_list().is_some_and(|held| held.iter().any(|h| h.eq_ignore_ascii_case(&image)));
1274×
            if complete || fetchers.contains(&r.id) {
1283×
                peers.push(SwarmPeer { runner: r.id, name: r.name.clone(), addr: addr.addr, cert: addr.cert, complete });
1293×
            }
130
        }
1311×
    }
1324×
    let ticket = ticket(&app, &me, &mine.site)?;
133
    // An image only runners hold (oxygenflow#94): the hub has its manifest
134
    // and none of its chunks, so the fetcher asks peers alone.
1354×
    let seed = Some(
1364×
        sqlx::query_scalar::<_, String>("SELECT source FROM jet_images WHERE hash = $1")
1374×
            .bind(&image)
1384×
            .fetch_optional(&app.db)
1394×
            .await?
1404×
            .is_none_or(|s| s != "peer"),
141
    );
1424×
    Ok(Json(Announced { ticket, peers, interval_s: INTERVAL_S, seed }))
1438×
}
144
145
/// The runner's ticket, made again when it nears its end or its site
146
/// changed.
1474×
fn ticket(app: &Shared, me: &db::runners::Runner, site: &str) -> Result<String, ApiError> {
1484×
    let now = db::now_ms() as u64;
1494×
    let mut tickets = app.swarm.tickets.lock().unwrap_or_else(|p| p.into_inner());
1504×
    if let Some((t, until)) = tickets.get(&me.id) {
1512×
        if *until > now + TICKET_RENEW_MS && Ticket::open(t, &app.hub_key.identity, now).is_ok_and(|t| t.site == site) {
1521×
            return Ok(t.clone());
1531×
        }
1542×
    }
1553×
    let (key, secret) = crate::keys::load_secret(&app.config.keys_dir())
1563×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("the hub key: {e}")))?;
1573×
    let expires_ms = now + TICKET_LIFE_MS;
1583×
    let t = Ticket { hub: app.hub_key.key.to_hex(), runner: me.id, name: me.name.clone(), site: site.to_string(), expires_ms }
1593×
        .seal(&key, &secret);
1603×
    tickets.insert(me.id, (t.clone(), expires_ms));
1613×
    Ok(t)
1624×
}
163
164
#[cfg(test)]
165
mod tests {
166
    use super::*;
167
168
    #[test]
1691×
    fn only_host_and_port_addresses_are_kept() {
1701×
        assert!(plausible("10.0.0.5:7401"));
1711×
        assert!(plausible("[::1]:7401"));
1721×
        assert!(plausible("runner.lan:7401"));
1731×
        assert!(!plausible("10.0.0.5"));
1741×
        assert!(!plausible(":7401"));
1751×
        assert!(!plausible("10.0.0.5:0"));
1761×
        assert!(!plausible("10.0.0.5:99999"));
1771×
    }
178
}