OXYGENHUB

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

108 of 120 lines run, 25 of 63 functions · run #476 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.
45221×
    pub fn claimed(&self, runner: i64, swarm: Option<&SwarmAddr>) {
46221×
        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).
49221×
        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×
            }
53216×
            None => {
54216×
                addrs.remove(&runner);
55216×
            }
56
        }
57221×
    }
58
59
    /// Where peers reach `runner`, if it is in a swarm now.
6029×
    pub fn addr(&self, runner: i64) -> Option<SwarmAddr> {
6129×
        let addrs = self.addrs.lock().unwrap_or_else(|p| p.into_inner());
6229×
        addrs.get(&runner).filter(|(_, seen)| db::now_ms() - seen < ADDR_FRESH_MS).map(|(a, _)| a.clone())
6329×
    }
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)?;
1334×
    Ok(Json(Announced { ticket, peers, interval_s: INTERVAL_S }))
1348×
}
135
136
/// The runner's ticket, made again when it nears its end or its site
137
/// changed.
1384×
fn ticket(app: &Shared, me: &db::runners::Runner, site: &str) -> Result<String, ApiError> {
1394×
    let now = db::now_ms() as u64;
1404×
    let mut tickets = app.swarm.tickets.lock().unwrap_or_else(|p| p.into_inner());
1414×
    if let Some((t, until)) = tickets.get(&me.id) {
1422×
        if *until > now + TICKET_RENEW_MS && Ticket::open(t, &app.hub_key.identity, now).is_ok_and(|t| t.site == site) {
1431×
            return Ok(t.clone());
1441×
        }
1452×
    }
1463×
    let (key, secret) = crate::keys::load_secret(&app.config.keys_dir())
1473×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("the hub key: {e}")))?;
1483×
    let expires_ms = now + TICKET_LIFE_MS;
1493×
    let t = Ticket { hub: app.hub_key.key.to_hex(), runner: me.id, name: me.name.clone(), site: site.to_string(), expires_ms }
1503×
        .seal(&key, &secret);
1513×
    tickets.insert(me.id, (t.clone(), expires_ms));
1523×
    Ok(t)
1534×
}
154
155
#[cfg(test)]
156
mod tests {
157
    use super::*;
158
159
    #[test]
1601×
    fn only_host_and_port_addresses_are_kept() {
1611×
        assert!(plausible("10.0.0.5:7401"));
1621×
        assert!(plausible("[::1]:7401"));
1631×
        assert!(plausible("runner.lan:7401"));
1641×
        assert!(!plausible("10.0.0.5"));
1651×
        assert!(!plausible(":7401"));
1661×
        assert!(!plausible("10.0.0.5:0"));
1671×
        assert!(!plausible("10.0.0.5:99999"));
1681×
    }
169
}