|
|
… |
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(¬e); |
|
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(¬e) { |
|
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 |