OXYGENHUB

Runners ask the hub for their update and take it cedd12342afa

Once the hub answers a claim, and every 15 minutes, a runner asks GET
/api/v1/flow/runner/update; an offered build is downloaded, refused
unless its size and BLAKE3 are the hub's, and installed as flow runner
update does (leaving within 600 s), or checked for a dry run. Outcomes,
proofs and rollbacks are reported to the hub once each.

refs #84, oxygenhub#322
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-26 01:38 UTC
Signature
◈ sealed by Andy McCall
Hash
cedd12342afac41162c56d51b436c8260d763a4a492f11a8bad6efa04094f25d
Depends on
1b2229dab1a4 3007d8f011fa 732b4de405e6 85275e1ef3b7 a4b3f8cb9aae bb643c252515 c3db750ed96e e0918b887850 ed4aef8b70ca
Depended on by
1a50d4dae89c 21a3ea4aab88 494a1b9a8ac2 72772aa01d6b
In bookmarks
main
In tags
v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
10 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Runners ask the hub for their update (oxygenhub#322):** once the hub has answered a claim, and every 15 minutes, a runner asks `GET /api/v1/flow/runner/update`; when the hub offers a build, it downloads it (kept in the home's `updates/`), refuses it unless its size and BLAKE3 are those the hub attests, and installs it as `flow runner update` does, leaving within 600 s, or for a dry run checks it and says what would happen. What it did goes back to the hub once per outcome, and a proof or a rollback is told at the next start. `[runner.update] allow = false` refuses and says so; a hub without runner updates (404) is simply asked again later.
7 8
- **The runner update protocol's types (oxygenhub#322):** `UpdateAnswer`, `UpdatePolicy`, `UpdateOffer` and `UpdateReport` in flow-core, for `GET`/`POST /api/v1/flow/runner/update`: the policy that applies to a runner once every level is applied, and the release asset it should run.
8 9
- **`flow runner update --from FILE` (#85):** the runner's side of self-updating runners (docs/jetstream.md §8.2). It checks a new build (this machine's target, `--version`, a tier it can run here), keeps the installed one as `flow.prev`, swaps the new one in with a single rename, and tells the running runner (SIGUSR1, its pid in `runner.pid`) to leave within `--notice` seconds (600 by default): jobs that fit finish, the rest are handed back with their checkpoints, and the unit starts the new build. The new build proves itself by getting an answer from the hub; four starts or ten minutes without one put `flow.prev` back. `--dry-run` changes nothing; `[runner.update] allow = false` in `flow.toml` refuses every update; `updates.log` in the home records each install, proof and rollback. Spec §11.5.3.
9 10
- **A local `flow run` of a WASM or syscall job copies the workspace whole:** it copied into a directory it never made, so a file at the top of the workspace (a README) failed with `No such file or directory`.
…
96 unchanged lines
modified crates/flow/src/runner.rs +147 −0
…
160 unchanged lines
161 161
        }
162 162
    }
163 163
164 +
    /// The update policy for this runner and the build it should run
165 +
    /// (oxygenhub#322); `None` from a hub without runner updates.
166 +
    pub fn update_answer(&self) -> Result<Option<flow_core::api::UpdateAnswer>> {
167 +
        let url = format!("{}/api/v1/flow/runner/update", self.base);
168 +
        match self.agent.get(&url).set("Authorization", &format!("Bearer {}", self.token)).call() {
169 +
            Ok(r) => Ok(Some(r.into_json()?)),
170 +
            Err(ureq::Error::Status(404, _)) => Ok(None),
171 +
            Err(e) => Err(anyhow::anyhow!("{url}: {e}")),
172 +
        }
173 +
    }
174 +
175 +
    /// Tell the hub what this runner did about its update.
176 +
    pub fn report_update(&self, note: &str) -> Result<()> {
177 +
        self.post_with("/flow/runner/update", &flow_core::api::UpdateReport { note: note.to_string() }, &[])?;
178 +
        Ok(())
179 +
    }
180 +
181 +
    /// Download an offered build to `to`, refusing it unless its BLAKE3
182 +
    /// is the one the hub attests and it is the size it said.
183 +
    pub fn download_build(&self, offer: &flow_core::api::UpdateOffer, to: &Path) -> Result<()> {
184 +
        use std::io::{Read, Write};
185 +
        let url = format!("{}{}", self.base, offer.url);
186 +
        let r = self.agent.get(&url).timeout(Duration::from_secs(1800)).call().map_err(|e| anyhow::anyhow!("{url}: {e}"))?;
187 +
        let part = to.with_extension("partial");
188 +
        let mut out = std::fs::File::create(&part).with_context(|| format!("writing {}", part.display()))?;
189 +
        let (mut hasher, mut n, mut buf) = (blake3::Hasher::new(), 0u64, vec![0u8; 1 << 16]);
190 +
        let mut body = r.into_reader().take(offer.size + 1);
191 +
        loop {
192 +
            let k = body.read(&mut buf)?;
193 +
            if k == 0 {
194 +
                break;
195 +
            }
196 +
            hasher.update(&buf[..k]);
197 +
            out.write_all(&buf[..k])?;
198 +
            n += k as u64;
199 +
        }
200 +
        drop(out);
201 +
        let got = hasher.finalize().to_hex().to_string();
202 +
        if n != offer.size || got != offer.blake3 {
203 +
            let _ = std::fs::remove_file(&part);
204 +
            bail!("{} does not match what the hub attests ({n} bytes, BLAKE3 {}…)", offer.asset, &got[..12]);
205 +
        }
206 +
        std::fs::rename(&part, to)?;
207 +
        Ok(())
208 +
    }
209 +
164 210
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
165 211
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
166 212
        Ok(())
…
593 unchanged lines
760 806
            }
761 807
        };
762 808
        let mut on_trial = crate::update::pending(&home.root).is_some();
809 +
        // Runner updates (oxygenhub#322): asked of the hub once it has
810 +
        // answered a claim, then every 15 minutes.
811 +
        let mut update_asked: Option<Instant> = None;
812 +
        let mut update_said = String::new();
763 813
        loop {
764 814
            if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) {
765 815
                let p = crate::update::pending(&home.root);
…
53 unchanged lines
819 869
            if on_trial && answer.is_ok() {
820 870
                crate::update::proven(&home.root);
821 871
                on_trial = false;
872 +
            }
873 +
            if answer.is_ok() && update_asked.is_none_or(|t| t.elapsed() >= UPDATE_EVERY) {
874 +
                update_asked = Some(Instant::now());
875 +
                if let Some(note) = crate::update::take_note(&home.root) {
876 +
                    let _ = hub.report_update(&note);
877 +
                }
878 +
                consider_update(home, &hub, &mut update_said);
822 879
            }
823 880
            match answer {
824 881
                Ok(Some(claim)) => {
…
246 unchanged lines
1071 1128
/// How often a runner with no room to claim tells the hub it is there:
1072 1129
/// well inside the hub's 90 s before it shows a runner offline.
1073 1130
const HUB_HEARTBEAT: Duration = Duration::from_secs(30);
1131 +
1132 +
/// How often a runner asks the hub whether it should run another build.
1133 +
const UPDATE_EVERY: Duration = Duration::from_secs(15 * 60);
1134 +
1135 +
/// Notice a runner gives itself to leave for an update the hub offered.
1136 +
const UPDATE_NOTICE: Duration = Duration::from_secs(600);
1074 1137
1138 +
/// Ask the hub for this runner's update, and act on it: download the build
1139 +
/// it offers, check it against the hub's BLAKE3 and as a runner build, and
1140 +
/// install it, or for a dry run say what would happen. What it did goes to
1141 +
/// the hub once per outcome (`said`).
1142 +
fn consider_update(home: &Home, hub: &Hub, said: &mut String) {
1143 +
    let answer = match hub.update_answer() {
1144 +
        Ok(Some(a)) => a,
1145 +
        Ok(None) => return,
1146 +
        Err(e) => {
1147 +
            eprintln!("flow runner: asking for an update: {e:#}");
1148 +
            return;
1149 +
        }
1150 +
    };
1151 +
    let Some(offer) = answer.offer else { return };
1152 +
    let mut say = |note: String| {
1153 +
        if *said != note {
1154 +
            eprintln!("flow runner: {note}");
1155 +
            if let Err(e) = hub.report_update(&note) {
1156 +
                eprintln!("flow runner: telling the hub: {e:#}");
1157 +
            }
1158 +
            *said = note;
1159 +
        }
1160 +
    };
1161 +
    if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) {
1162 +
        return say(format!("refuses flow {}: flow.toml says [runner.update] allow = false", offer.version));
1163 +
    }
1164 +
    let dir = home.root.join("updates");
1165 +
    let file = dir.join(format!("flow-{}", offer.version));
1166 +
    let fresh = std::fs::read(&file).is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == offer.blake3);
1167 +
    if !fresh {
1168 +
        if let Err(e) = std::fs::create_dir_all(&dir).map_err(anyhow::Error::from).and_then(|_| hub.download_build(&offer, &file)) {
1169 +
            return say(format!("flow {}: not fetched: {e:#}", offer.version));
1170 +
        }
1171 +
    }
1172 +
    let exe = match std::env::current_exe() {
1173 +
        Ok(e) => e,
1174 +
        Err(e) => return say(format!("flow {}: cannot find its own binary: {e}", offer.version)),
1175 +
    };
1176 +
    match crate::update::install(home, &exe, &file, UPDATE_NOTICE, answer.policy.dry_run, offer.older) {
1177 +
        Ok(done) if answer.policy.dry_run => say(format!("dry run: flow {} checks out here and would replace flow {}", done.to, done.from)),
1178 +
        Ok(done) => {
1179 +
            let _ = std::fs::remove_file(&file);
1180 +
            say(format!("installed flow {} (was {}); leaving within {} s to start it", done.to, done.from, UPDATE_NOTICE.as_secs()))
1181 +
        }
1182 +
        Err(e) => say(format!("flow {}: not installed: {e:#}", offer.version)),
1183 +
    }
1184 +
}
1185 +
1075 1186
/// Held while a job takes its cache disks and images its workspace (see
1076 1187
/// `run_claim`): one job at a time on the runner's disk and cache pool.
1077 1188
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
…
771 unchanged lines
1849 1960
1850 1961
    fn hub_at(base: String) -> Hub {
1851 1962
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
1963 +
    }
1964 +
1965 +
    /// oxygenhub#322: an offered build is kept only if it is the bytes the
1966 +
    /// hub attests; a peer or a proxy cannot change what is installed.
1967 +
    #[test]
1968 +
    fn a_build_is_kept_only_if_it_matches_the_hubs_blake3() {
1969 +
        let (body, other) = (b"#!flow build 0.3.2".to_vec(), b"#!something else!!".to_vec());
1970 +
        let serve = |bytes: Vec<u8>| {
1971 +
            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1972 +
            let addr = listener.local_addr().unwrap();
1973 +
            std::thread::spawn(move || {
1974 +
                use std::io::{Read, Write};
1975 +
                let (mut s, _) = listener.accept().unwrap();
1976 +
                let mut buf = [0u8; 4096];
1977 +
                let _ = s.read(&mut buf);
1978 +
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len());
1979 +
                let _ = s.write_all(&bytes);
1980 +
            });
1981 +
            format!("http://{addr}")
1982 +
        };
1983 +
        let offer = flow_core::api::UpdateOffer {
1984 +
            version: "0.3.2".into(),
1985 +
            asset: "flow-linux-x86_64".into(),
1986 +
            url: "/andy/oxygenflow/asset/1/flow-linux-x86_64".into(),
1987 +
            blake3: blake3::hash(&body).to_hex().to_string(),
1988 +
            size: body.len() as u64,
1989 +
            ..Default::default()
1990 +
        };
1991 +
        let dir = tempfile::tempdir().unwrap();
1992 +
        let to = dir.path().join("flow-0.3.2");
1993 +
        hub_at(serve(body.clone())).download_build(&offer, &to).unwrap();
1994 +
        assert_eq!(std::fs::read(&to).unwrap(), body);
1995 +
        std::fs::remove_file(&to).unwrap();
1996 +
        let e = hub_at(serve(other)).download_build(&offer, &to).unwrap_err();
1997 +
        assert!(e.to_string().contains("does not match what the hub attests"), "{e:#}");
1998 +
        assert!(!to.exists() && !to.with_extension("partial").exists(), "nothing of it is left");
1852 1999
    }
1853 2000
1854 2001
    /// oxygenhub#71: a job's result is offered until the hub takes it,
…
241 unchanged lines
modified crates/flow/src/update.rs +17 −0
…
278 unchanged lines
279 279
    Ok(Start::Trial(p))
280 280
}
281 281
282 +
/// In the runner's home: what to tell the hub once it answers.
283 +
const NOTE: &str = "update.note";
284 +
285 +
/// Keep `note` to tell the hub (a proof, a rollback) once it answers.
286 +
fn note(home: &Path, note: &str) {
287 +
    let _ = std::fs::write(home.join(NOTE), note);
288 +
}
289 +
290 +
/// What there is to tell the hub, once.
291 +
pub fn take_note(home: &Path) -> Option<String> {
292 +
    let n = std::fs::read_to_string(home.join(NOTE)).ok()?;
293 +
    let _ = std::fs::remove_file(home.join(NOTE));
294 +
    Some(n).filter(|n| !n.trim().is_empty())
295 +
}
296 +
282 297
/// The new build reached the hub: it stays.
283 298
pub fn proven(home: &Path) {
284 299
    if let Some(p) = pending(home) {
285 300
        let _ = std::fs::remove_file(home.join(PENDING));
286 301
        log(home, "proven", &p.from, &p.to, "reached the hub");
302 +
        note(home, &format!("runs flow {} (was {}); proven", p.to, p.from));
287 303
        eprintln!("flow runner: flow {} proven (it reached the hub); flow {} stays as {PREV}", p.to, p.from);
288 304
    }
289 305
}
…
3 unchanged lines
293 309
    restore(&p.exe)?;
294 310
    let _ = std::fs::remove_file(home.join(PENDING));
295 311
    log(home, "rolled back", &p.to, &p.from, why);
312 +
    note(home, &format!("rolled back from flow {} to {}: {why}", p.to, p.from));
296 313
    eprintln!("flow runner: rolled back from flow {} to {} ({why})", p.to, p.from);
297 314
    Ok(())
298 315
}
…
78 unchanged lines