OXYGENHUB

Jetstream: the swarm's tracker and peer tickets a46d15e72101

Runners in an image swarm report where peers reach them, and their site,
with each claim; Admin › Runners shows it. POST /flow/swarm/announce gives a
fetching runner the online runners in its site that hold the image or are
fetching it, and a ticket signed with the hub key that those peers check
before serving chunks. The OxygenFlow pin moves to the runner side.

closes #144
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-12 15:06 UTC
Signature
✔ signed by Andy McCall
Hash
a46d15e72101a6a8cc994080e4f13821812e2142a84dae49c5e51941684a1404
Depends on
1c8276b90314 1f78655a0466 20b8fe85888b 2a83f5303458 2fb70d019851 3556d08e17f9 380996db84e5 42fa95540468 44a3d2ebaddd 5e6d6484c28e 74be97962ed8 92a797b20912 93be9ce4653e b0612e7dce30 b64e2537d702 b6b7e4cf6b24 cc19697f8871 d23464c0ac60
Depended on by
951aed50c2f5 ba0a55bb5b3c 8dcaeeb848c6 e9b9619cd857 aa0bfbd11f02 c2be148cfe83 4fa0da126b70 750579ecc721 6ca0b971ff07 6adcd68b90b1
In bookmarks
api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
15 atoms, 1 tree operation, 9 files

Browse the tree at this change

added crates/hub/src/swarm.rs +153 −0
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, and a ticket, signed
5 +
//! with the hub key, that those peers accept.
6 +
//!
7 +
//! What the tracker knows lives in memory: runners repeat their address
8 +
//! with every claim and fetchers announce every `INTERVAL_S`, so a
9 +
//! restarted hub knows the swarm again within seconds.
10 +
11 +
use crate::api::ApiError;
12 +
use crate::db;
13 +
use crate::Shared;
14 +
use axum::extract::State;
15 +
use axum::http::{HeaderMap, StatusCode};
16 +
use axum::Json;
17 +
use flow_core::api::{Announce, Announced, SwarmAddr, SwarmPeer};
18 +
use jet::swarm::{Ticket, TICKET_LIFE_MS};
19 +
use std::collections::HashMap;
20 +
use std::sync::Mutex;
21 +
22 +
/// How often a fetcher announces again.
23 +
pub const INTERVAL_S: u64 = 15;
24 +
/// A runner whose address is older than this is not offered as a peer.
25 +
const ADDR_FRESH_MS: i64 = 2 * 60 * 1000;
26 +
/// A fetcher that has not announced for this long is no longer fetching.
27 +
const FETCHING_FRESH_MS: i64 = 3 * INTERVAL_S as i64 * 1000;
28 +
/// A ticket is made again once it has less than this to run.
29 +
const TICKET_RENEW_MS: u64 = 5 * 60 * 1000;
30 +
31 +
#[derive(Default)]
32 +
pub struct Tracker {
33 +
    /// Runner id to its swarm address and when it last said so.
34 +
    addrs: Mutex<HashMap<i64, (SwarmAddr, i64)>>,
35 +
    /// Image to the runners fetching it, with when each last announced.
36 +
    fetching: Mutex<HashMap<String, HashMap<i64, i64>>>,
37 +
    /// Runner id to its current ticket and when that expires.
38 +
    tickets: Mutex<HashMap<i64, (String, u64)>>,
39 +
}
40 +
41 +
impl Tracker {
42 +
    /// What a runner's claim said: where peers reach it, or that it is not
43 +
    /// in a swarm.
44 +
    pub fn claimed(&self, runner: i64, swarm: Option<&SwarmAddr>) {
45 +
        let mut addrs = self.addrs.lock().unwrap_or_else(|p| p.into_inner());
46 +
        match swarm.filter(|s| plausible(&s.addr)) {
47 +
            Some(s) => {
48 +
                addrs.insert(runner, (s.clone(), db::now_ms()));
49 +
            }
50 +
            None => {
51 +
                addrs.remove(&runner);
52 +
            }
53 +
        }
54 +
    }
55 +
56 +
    /// Where peers reach `runner`, if it is in a swarm now.
57 +
    pub fn addr(&self, runner: i64) -> Option<SwarmAddr> {
58 +
        let addrs = self.addrs.lock().unwrap_or_else(|p| p.into_inner());
59 +
        addrs.get(&runner).filter(|(_, seen)| db::now_ms() - seen < ADDR_FRESH_MS).map(|(a, _)| a.clone())
60 +
    }
61 +
62 +
    fn fetch(&self, image: &str, runner: i64, state: &str) {
63 +
        let mut fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner());
64 +
        let now = db::now_ms();
65 +
        for who in fetching.values_mut() {
66 +
            who.retain(|_, seen| now - *seen < FETCHING_FRESH_MS);
67 +
        }
68 +
        fetching.retain(|_, who| !who.is_empty());
69 +
        if state == "fetching" {
70 +
            fetching.entry(image.to_string()).or_default().insert(runner, now);
71 +
        } else if let Some(who) = fetching.get_mut(image) {
72 +
            who.remove(&runner);
73 +
        }
74 +
    }
75 +
76 +
    fn fetchers(&self, image: &str) -> Vec<i64> {
77 +
        let fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner());
78 +
        fetching.get(image).map(|who| who.keys().copied().collect()).unwrap_or_default()
79 +
    }
80 +
}
81 +
82 +
/// An address a peer could dial: `host:port`, nothing else.
83 +
fn plausible(addr: &str) -> bool {
84 +
    addr.len() <= 255 && addr.rsplit_once(':').is_some_and(|(host, port)| !host.is_empty() && port.parse::<u16>().is_ok_and(|p| p > 0))
85 +
}
86 +
87 +
/// `POST /flow/swarm/announce`: a runner starting, continuing or ending a
88 +
/// fetch. The peers it may ask, in its site, and its ticket.
89 +
pub async fn announce(State(app): State<Shared>, headers: HeaderMap, Json(a): Json<Announce>) -> Result<Json<Announced>, ApiError> {
90 +
    let me = crate::flow::runner_auth(&app, &headers).await?;
91 +
    if !jet::images::is_hash(&a.image) || !matches!(a.state.as_str(), "fetching" | "done" | "stopped") {
92 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "announce an image's hash with state fetching, done or stopped".into()));
93 +
    }
94 +
    let image = a.image.to_ascii_lowercase();
95 +
    let Some(mine) = app.swarm.addr(me.id) else {
96 +
        return Err(ApiError(
97 +
            StatusCode::CONFLICT,
98 +
            "this runner has not said where peers reach it: give it a [runner.swarm] section and let it claim".into(),
99 +
        ));
100 +
    };
101 +
    app.swarm.fetch(&image, me.id, &a.state);
102 +
    let fetchers = app.swarm.fetchers(&image);
103 +
    let mut peers = Vec::new();
104 +
    if a.state == "fetching" {
105 +
        for r in db::runners::list(&app.db).await? {
106 +
            if r.id == me.id || r.state == "disabled" || !r.online() {
107 +
                continue;
108 +
            }
109 +
            let Some(addr) = app.swarm.addr(r.id).filter(|s| s.site == mine.site) else { continue };
110 +
            let complete = r.image_list().is_some_and(|held| held.iter().any(|h| h.eq_ignore_ascii_case(&image)));
111 +
            if complete || fetchers.contains(&r.id) {
112 +
                peers.push(SwarmPeer { runner: r.id, name: r.name.clone(), addr: addr.addr, complete });
113 +
            }
114 +
        }
115 +
    }
116 +
    let ticket = ticket(&app, &me, &mine.site)?;
117 +
    Ok(Json(Announced { ticket, peers, interval_s: INTERVAL_S }))
118 +
}
119 +
120 +
/// The runner's ticket, made again when it nears its end or its site
121 +
/// changed.
122 +
fn ticket(app: &Shared, me: &db::runners::Runner, site: &str) -> Result<String, ApiError> {
123 +
    let now = db::now_ms() as u64;
124 +
    let mut tickets = app.swarm.tickets.lock().unwrap_or_else(|p| p.into_inner());
125 +
    if let Some((t, until)) = tickets.get(&me.id) {
126 +
        if *until > now + TICKET_RENEW_MS && Ticket::open(t, &app.hub_key.identity, now).is_ok_and(|t| t.site == site) {
127 +
            return Ok(t.clone());
128 +
        }
129 +
    }
130 +
    let (key, secret) = crate::keys::load_secret(&app.config.keys_dir())
131 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("the hub key: {e}")))?;
132 +
    let expires_ms = now + TICKET_LIFE_MS;
133 +
    let t = Ticket { hub: app.hub_key.key.to_hex(), runner: me.id, name: me.name.clone(), site: site.to_string(), expires_ms }
134 +
        .seal(&key, &secret);
135 +
    tickets.insert(me.id, (t.clone(), expires_ms));
136 +
    Ok(t)
137 +
}
138 +
139 +
#[cfg(test)]
140 +
mod tests {
141 +
    use super::*;
142 +
143 +
    #[test]
144 +
    fn only_host_and_port_addresses_are_kept() {
145 +
        assert!(plausible("10.0.0.5:7401"));
146 +
        assert!(plausible("[::1]:7401"));
147 +
        assert!(plausible("runner.lan:7401"));
148 +
        assert!(!plausible("10.0.0.5"));
149 +
        assert!(!plausible(":7401"));
150 +
        assert!(!plausible("10.0.0.5:0"));
151 +
        assert!(!plausible("10.0.0.5:99999"));
152 +
    }
153 +
}
modified crates/hub/src/api/mod.rs +1 −0
…
44 unchanged lines
45 45
        .route("/flow/claim", post(crate::flow::claim))
46 46
        .route("/flow/runners/register", post(crate::flow::register))
47 47
        .route("/flow/images/missing", post(images::missing))
48 +
        .route("/flow/swarm/announce", post(crate::swarm::announce))
48 49
        .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk))
49 50
        .route(
50 51
            "/flow/images/{hash}",
…
2356 unchanged lines
modified crates/hub/src/flow.rs +1 −0
…
556 unchanged lines
557 557
    if let Some(f) = &req.facts {
558 558
        db::runners::set_facts(&app.db, me.id, f).await?;
559 559
    }
560 +
    app.swarm.claimed(me.id, req.swarm.as_ref());
560 561
    let room = fdb::Room {
561 562
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
562 563
        memory_mib: req.free_memory_mib,
…
1354 unchanged lines
modified crates/hub/src/lib.rs +4 −0
…
30 unchanged lines
31 31
pub mod signin;
32 32
pub mod social;
33 33
pub mod storage;
34 +
pub mod swarm;
34 35
pub mod variables;
35 36
pub mod web;
36 37
pub mod wiki;
…
27 unchanged lines
64 65
    /// Outgoing mail: hub.toml's `[email]`, overridden by what the admin
65 66
    /// page saved (#101).
66 67
    pub mail: std::sync::RwLock<config::Email>,
68 +
    /// The image swarm's tracker (oxygenhub#144).
69 +
    pub swarm: swarm::Tracker,
67 70
    pub started: std::time::Instant,
68 71
}
69 72
…
48 unchanged lines
118 121
        assistant_gate: std::sync::Mutex::new(assistants::Gate::default()),
119 122
        events: events::Events::default(),
120 123
        rooms: Arc::new(live::Rooms::default()),
124 +
        swarm: Default::default(),
121 125
        started: std::time::Instant::now(),
122 126
    });
123 127
    *slot.lock().unwrap() = Some(Arc::downgrade(&app));
…
53 unchanged lines
modified crates/hub/src/web/runners.rs +9 −1
…
25 unchanged lines
26 26
    pub running: i64,
27 27
    /// The repositories it serves, in words.
28 28
    pub scope: String,
29 +
    /// Where its peers reach it in the image swarm, and its site
30 +
    /// (oxygenhub#144); empty when it is not in one.
31 +
    pub swarm: String,
29 32
}
30 33
31 34
/// A scope in words: the whole site, an owner's repositories, or one
…
61 unchanged lines
93 96
    let mut rows = Vec::with_capacity(list.len());
94 97
    for r in list {
95 98
        let scope = scope_words(app, &r.scope()).await;
96 -
        rows.push(RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), scope, r });
99 +
        let swarm = app
100 +
            .swarm
101 +
            .addr(r.id)
102 +
            .map(|s| if s.site.is_empty() { s.addr } else { format!("{} · site {}", s.addr, s.site) })
103 +
            .unwrap_or_default();
104 +
        rows.push(RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), scope, swarm, r });
97 105
    }
98 106
    let queue = db::flow::queue(&app.db).await.map_err(e)?;
99 107
    Ok(Page(RunnersPage { hub: app.config.base_url().to_string(), shell, runners: rows, pending, new_token, notice, error, queue }))
…
309 unchanged lines
modified crates/hub/tests/flow.rs +65 −0
…
2957 unchanged lines
2958 2958
    let check = checks.iter().find(|c| c.name == "pipeline/images").unwrap();
2959 2959
    assert_eq!(check.status, "passed", "{check:?}");
2960 2960
}
2961 +
2962 +
/// The swarm's tracker (#144): a fetching runner is given the peers in its
2963 +
/// site that hold the image or are fetching it, and a ticket signed with
2964 +
/// the hub key; a runner not in a swarm is told how to join one.
2965 +
#[tokio::test(flavor = "multi_thread")]
2966 +
async fn the_tracker_names_peers_in_the_same_site_and_signs_a_ticket() {
2967 +
    let h = hub().await;
2968 +
    let app = &h.app;
2969 +
    let image = "ab".repeat(32);
2970 +
    let claim_as = |token: &'static str, name: &'static str, body: serde_json::Value| async move {
2971 +
        let mut b = serde_json::json!({ "runner": name, "runtimes": ["microvm"] });
2972 +
        b.as_object_mut().unwrap().extend(body.as_object().unwrap().clone());
2973 +
        let (st, out) = call(app, "POST", "/api/v1/flow/claim", Some(token), Some(b.to_string())).await;
2974 +
        assert_eq!(st, StatusCode::NO_CONTENT, "{out}");
2975 +
    };
2976 +
    let announce = |token: &'static str, state: &'static str| {
2977 +
        let body = serde_json::json!({ "image": image, "state": state }).to_string();
2978 +
        async move { call(app, "POST", "/api/v1/flow/swarm/announce", Some(token), Some(body)).await }
2979 +
    };
2980 +
    // A holds the image; B and C hold nothing; all three are in site "lab".
2981 +
    claim_as("runner-secret", "a", serde_json::json!({ "images": [image], "swarm": { "addr": "10.0.0.1:7401", "site": "lab" } })).await;
2982 +
    claim_as("runner-two", "b", serde_json::json!({ "images": [], "swarm": { "addr": "10.0.0.2:7401", "site": "lab" } })).await;
2983 +
    claim_as("runner-three", "c", serde_json::json!({ "images": [], "swarm": { "addr": "10.0.0.3:7401", "site": "lab" } })).await;
2984 +
2985 +
    let (st, body) = announce("runner-two", "fetching").await;
2986 +
    assert_eq!(st, StatusCode::OK, "{body}");
2987 +
    let a: flow_core::api::Announced = serde_json::from_str(&body).unwrap();
2988 +
    let names: Vec<(&str, bool)> = a.peers.iter().map(|p| (p.addr.as_str(), p.complete)).collect();
2989 +
    assert_eq!(names, vec![("10.0.0.1:7401", true)], "only A holds it so far");
2990 +
    let t = jet::swarm::Ticket::open(&a.ticket, &app.hub_key.identity, oxygenhub::db::now_ms() as u64).unwrap();
2991 +
    assert_eq!((t.name.as_str(), t.site.as_str()), ("b", "lab"));
2992 +
2993 +
    // C, fetching too, is told of A (whole) and B (fetching).
2994 +
    let (_, body) = announce("runner-three", "fetching").await;
2995 +
    let a: flow_core::api::Announced = serde_json::from_str(&body).unwrap();
2996 +
    let mut peers: Vec<(&str, bool)> = a.peers.iter().map(|p| (p.addr.as_str(), p.complete)).collect();
2997 +
    peers.sort();
2998 +
    assert_eq!(peers, vec![("10.0.0.1:7401", true), ("10.0.0.2:7401", false)]);
2999 +
3000 +
    // B is done: no longer offered as fetching. C moves site: no peers.
3001 +
    announce("runner-two", "done").await;
3002 +
    claim_as("runner-three", "c", serde_json::json!({ "images": [], "swarm": { "addr": "10.0.0.3:7401", "site": "elsewhere" } })).await;
3003 +
    let (_, body) = announce("runner-three", "fetching").await;
3004 +
    let a: flow_core::api::Announced = serde_json::from_str(&body).unwrap();
3005 +
    assert!(a.peers.is_empty(), "{:?}", a.peers);
3006 +
3007 +
    // A runner that left the swarm is told how to join; nonsense is refused.
3008 +
    claim_as("runner-two", "b", serde_json::json!({ "images": [] })).await;
3009 +
    let (st, body) = announce("runner-two", "fetching").await;
3010 +
    assert_eq!(st, StatusCode::CONFLICT);
3011 +
    assert!(body.contains("[runner.swarm]"), "{body}");
3012 +
    let (st, _) =
3013 +
        call(app, "POST", "/api/v1/flow/swarm/announce", Some("runner-secret"), Some(r#"{"image":"nope","state":"fetching"}"#.into()))
3014 +
            .await;
3015 +
    assert_eq!(st, StatusCode::BAD_REQUEST);
3016 +
    let (st, _) = call(
3017 +
        app,
3018 +
        "POST",
3019 +
        "/api/v1/flow/swarm/announce",
3020 +
        None,
3021 +
        Some(serde_json::json!({ "image": image, "state": "fetching" }).to_string()),
3022 +
    )
3023 +
    .await;
3024 +
    assert_eq!(st, StatusCode::UNAUTHORIZED);
3025 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
514 unchanged lines
515 515
  - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`.
516 516
517 517
  Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`.
518 -
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28).
518 +
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address and site with each claim (`swarm`), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
519 519
- **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same.
520 520
- **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value.
521 521
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
…
231 unchanged lines
modified templates/admin_runners.html +1 −1
…
25 unchanged lines
26 26
    <td class="small">{{ row.scope }}
27 27
      <form class="tiny" method="post" action="/admin/runners/{{ r.id }}/scope" title="Empty for the whole site, an owner, or owner/repository"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="scope" size="14" placeholder="owner or owner/repo"> <button>set</button></form></td>
28 28
    <td>{% if r.key.is_empty() %}<span class="mute" title="Bound on its first signed receipt">not yet</span>{% else %}<code title="{{ r.key }} · {{ r.key_name }}">{{ r.key|short }}</code>{% endif %}</td>
29 -
    <td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}{% let f = r.facts_line() %}{% if !f.is_empty() %}<br><span class="mute" title="Measured by the runner at its last claim{% if !r.kernel.is_empty() %}; kernel {{ r.kernel }}{% endif %}">{{ f }}</span>{% endif %}</td>
29 +
    <td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}{% let f = r.facts_line() %}{% if !f.is_empty() %}<br><span class="mute" title="Measured by the runner at its last claim{% if !r.kernel.is_empty() %}; kernel {{ r.kernel }}{% endif %}">{{ f }}</span>{% endif %}{% if !row.swarm.is_empty() %}<br><span class="mute" title="Where other runners fetch image chunks from it">swarm {{ row.swarm }}</span>{% endif %}</td>
30 30
    <td class="small">{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}</td>
31 31
    <td class="small">{{ r.max_jobs|plural("job", "jobs") }}{% if let Some(m) = r.memory_mib %} · {{ m }} MiB{% endif %}{% if let Some(c) = r.cpus %} · {{ c }} vCPU{% endif %}</td>
32 32
    <td>{{ row.running }}</td>
…
30 unchanged lines