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 |