| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
use crate::home::{Home, RunnerConfig, RunnerKey}; |
| 7 |
use crate::{microvm, workspace}; |
| 8 |
use anyhow::{bail, Context, Result}; |
| 9 |
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, Register, Registered, RunnerInfo, MAX_RECORD_TEXT}; |
| 10 |
use flow_core::{scope_tree_hash, Receipt}; |
| 11 |
use oxy_core::hash::ChangeHash; |
| 12 |
use oxy_core::repo::Repo; |
| 13 |
use std::path::PathBuf; |
| 14 |
use std::time::{Duration, Instant}; |
| 15 |
|
| 16 |
pub struct Hub { |
| 17 |
base: String, |
| 18 |
token: String, |
| 19 |
agent: ureq::Agent, |
| 20 |
} |
| 21 |
|
| 22 |
impl Hub { |
| 23 |
pub fn new(cfg: &RunnerConfig) -> Result<Hub> { |
| 24 |
let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 25 |
let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build(); |
| 26 |
Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent }) |
| 27 |
} |
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> { |
| 33 |
self.post_with(path, body, &RETRY_DELAYS) |
| 34 |
} |
| 35 |
|
| 36 |
fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> { |
| 37 |
let url = format!("{}/api/v1{path}", self.base); |
| 38 |
let mut delays = delays.iter(); |
| 39 |
loop { |
| 40 |
match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) { |
| 41 |
Ok(r) if r.status() == 204 => return Ok(None), |
| 42 |
Ok(r) => return Ok(Some(r)), |
| 43 |
Err(ureq::Error::Status(code, r)) => { |
| 44 |
let text = r.into_string().unwrap_or_default(); |
| 45 |
match delays.next() { |
| 46 |
Some(d) if code >= 500 => std::thread::sleep(*d), |
| 47 |
_ => bail!("{url}: HTTP {code}: {text}"), |
| 48 |
} |
| 49 |
} |
| 50 |
Err(e) => bail!("{url}: {e}"), |
| 51 |
} |
| 52 |
} |
| 53 |
} |
| 54 |
|
| 55 |
pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> { |
| 56 |
match self.post("/flow/claim", req)? { |
| 57 |
Some(r) => Ok(Some(r.into_json()?)), |
| 58 |
None => Ok(None), |
| 59 |
} |
| 60 |
} |
| 61 |
|
| 62 |
pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> { |
| 63 |
self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?; |
| 64 |
Ok(()) |
| 65 |
} |
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> { |
| 70 |
let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base); |
| 71 |
let mut delays = RETRY_DELAYS.iter(); |
| 72 |
loop { |
| 73 |
let req = self |
| 74 |
.agent |
| 75 |
.post(&url) |
| 76 |
.timeout(Duration::from_secs(600)) |
| 77 |
.set("Authorization", &format!("Bearer {}", self.token)) |
| 78 |
.set("Content-Type", "application/octet-stream"); |
| 79 |
match req.send_bytes(bytes) { |
| 80 |
Ok(r) => { |
| 81 |
let v: serde_json::Value = r.into_json()?; |
| 82 |
return v["blob"].as_str().map(String::from).context("the hub named no blob"); |
| 83 |
} |
| 84 |
Err(ureq::Error::Status(code, r)) => { |
| 85 |
let text = r.into_string().unwrap_or_default(); |
| 86 |
match delays.next() { |
| 87 |
Some(d) if code >= 500 => std::thread::sleep(*d), |
| 88 |
_ => bail!("{url}: HTTP {code}: {text}"), |
| 89 |
} |
| 90 |
} |
| 91 |
Err(e) => bail!("{url}: {e}"), |
| 92 |
} |
| 93 |
} |
| 94 |
} |
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
|
| 101 |
pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> { |
| 102 |
let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base); |
| 103 |
let deadline = std::time::Instant::now() + patience; |
| 104 |
let mut wait = first_wait; |
| 105 |
loop { |
| 106 |
let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) { |
| 107 |
Ok(_) => return Ok(()), |
| 108 |
Err(ureq::Error::Status(code, r)) if code < 500 => { |
| 109 |
bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()) |
| 110 |
} |
| 111 |
Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"), |
| 112 |
Err(e) => e.to_string(), |
| 113 |
}; |
| 114 |
if std::time::Instant::now() + wait > deadline { |
| 115 |
bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60); |
| 116 |
} |
| 117 |
eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs()); |
| 118 |
std::thread::sleep(wait); |
| 119 |
wait = (wait * 2).min(Duration::from_secs(60)); |
| 120 |
} |
| 121 |
} |
| 122 |
} |
| 123 |
|
| 124 |
|
| 125 |
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600); |
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
fn pending_path(home: &Home, job: i64) -> PathBuf { |
| 130 |
home.root.join("pending").join(format!("{job}.json")) |
| 131 |
} |
| 132 |
|
| 133 |
|
| 134 |
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) { |
| 135 |
let path = pending_path(home, job); |
| 136 |
let kept = std::fs::create_dir_all(path.parent().unwrap()) |
| 137 |
.and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default())) |
| 138 |
.is_ok(); |
| 139 |
match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) { |
| 140 |
Ok(()) => {} |
| 141 |
Err(e) => eprintln!("job {job}: reporting failed: {e:#}"), |
| 142 |
} |
| 143 |
if kept { |
| 144 |
let _ = std::fs::remove_file(&path); |
| 145 |
} |
| 146 |
} |
| 147 |
|
| 148 |
|
| 149 |
fn report_pending(home: &Home) -> Vec<(i64, Finish)> { |
| 150 |
let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else { |
| 151 |
return Vec::new(); |
| 152 |
}; |
| 153 |
dir.filter_map(|e| { |
| 154 |
let p = e.ok()?.path(); |
| 155 |
let job: i64 = p.file_stem()?.to_str()?.parse().ok()?; |
| 156 |
let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?; |
| 157 |
Some((job, f)) |
| 158 |
}) |
| 159 |
.collect() |
| 160 |
} |
| 161 |
|
| 162 |
|
| 163 |
|
| 164 |
|
| 165 |
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> { |
| 166 |
if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() { |
| 167 |
bail!("flow.toml already has a [runner] section; remove it to register this runner instead"); |
| 168 |
} |
| 169 |
let key = home.runner_key(name)?; |
| 170 |
let hub = hub.trim_end_matches('/').to_string(); |
| 171 |
let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 172 |
let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build(); |
| 173 |
let req = Register { |
| 174 |
token: token.trim().to_string(), |
| 175 |
name: name.to_string(), |
| 176 |
identity: hex::encode(key.identity.encode()), |
| 177 |
info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) }, |
| 178 |
}; |
| 179 |
let url = format!("{hub}/api/v1/flow/runners/register"); |
| 180 |
let got: Registered = match agent.post(&url).send_json(&req) { |
| 181 |
Ok(r) => r.into_json()?, |
| 182 |
Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()), |
| 183 |
Err(e) => bail!("{url}: {e}"), |
| 184 |
}; |
| 185 |
let mut text = format!( |
| 186 |
"# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n", |
| 187 |
got.credential, got.name |
| 188 |
); |
| 189 |
if let Some(ca) = &ca { |
| 190 |
text.push_str(&format!("ca = {:?}\n", ca.display().to_string())); |
| 191 |
} |
| 192 |
if !labels.is_empty() { |
| 193 |
text.push_str(&format!("labels = {labels:?}\n")); |
| 194 |
} |
| 195 |
let path = home.root.join(crate::home::RUNNER_TOML); |
| 196 |
{ |
| 197 |
use std::io::Write; |
| 198 |
use std::os::unix::fs::OpenOptionsExt; |
| 199 |
let tmp = path.with_extension("toml.tmp"); |
| 200 |
let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?; |
| 201 |
f.write_all(text.as_bytes())?; |
| 202 |
f.sync_all()?; |
| 203 |
std::fs::rename(&tmp, &path)?; |
| 204 |
} |
| 205 |
Ok(got) |
| 206 |
} |
| 207 |
|
| 208 |
|
| 209 |
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)]; |
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
#[derive(Debug, Default, PartialEq, Eq)] |
| 214 |
pub struct Budget { |
| 215 |
pub max_jobs: usize, |
| 216 |
pub memory_mib: Option<u32>, |
| 217 |
pub cpus: Option<u32>, |
| 218 |
} |
| 219 |
|
| 220 |
|
| 221 |
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
| 222 |
pub struct Used { |
| 223 |
pub jobs: usize, |
| 224 |
pub memory_mib: u32, |
| 225 |
pub cpus: u32, |
| 226 |
} |
| 227 |
|
| 228 |
impl Budget { |
| 229 |
pub fn of(cfg: &RunnerConfig) -> Budget { |
| 230 |
Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus } |
| 231 |
} |
| 232 |
|
| 233 |
|
| 234 |
|
| 235 |
|
| 236 |
pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> { |
| 237 |
if used.jobs >= self.max_jobs { |
| 238 |
return None; |
| 239 |
} |
| 240 |
if used.jobs == 0 { |
| 241 |
return Some((None, None)); |
| 242 |
} |
| 243 |
let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib)); |
| 244 |
let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus)); |
| 245 |
if mem == Some(0) || cpus == Some(0) { |
| 246 |
return None; |
| 247 |
} |
| 248 |
Some((mem, cpus)) |
| 249 |
} |
| 250 |
} |
| 251 |
|
| 252 |
|
| 253 |
|
| 254 |
|
| 255 |
pub fn tiers(home: &Home) -> std::result::Result<Vec<String>, String> { |
| 256 |
let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm"); |
| 257 |
match (kvm, home.config.microvm.is_some()) { |
| 258 |
(Ok(_), true) => Ok(vec!["microvm".into()]), |
| 259 |
(Ok(_), false) => Err("flow.toml has no [microvm] section, so no tier can run".into()), |
| 260 |
(Err(e), _) => Err(format!( |
| 261 |
"/dev/kvm cannot be opened ({e}): the MicroVM tier needs hardware virtualisation and this user in the kvm group, and no other tier is in this build" |
| 262 |
)), |
| 263 |
} |
| 264 |
} |
| 265 |
|
| 266 |
pub fn serve(home: &Home) -> Result<()> { |
| 267 |
let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?; |
| 268 |
|
| 269 |
|
| 270 |
let runtimes = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?; |
| 271 |
let hub = Hub::new(&cfg)?; |
| 272 |
let name = cfg.name.clone().unwrap_or_else(|| "flow".into()); |
| 273 |
let budget = Budget::of(&cfg); |
| 274 |
let key = home.runner_key(&name)?; |
| 275 |
eprintln!( |
| 276 |
"flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}", |
| 277 |
cfg.hub, |
| 278 |
budget.max_jobs, |
| 279 |
runtimes.join(", "), |
| 280 |
key.identity.hash().to_hex() |
| 281 |
); |
| 282 |
let used = std::sync::Mutex::new(Used::default()); |
| 283 |
let mut idle = Duration::from_secs(2); |
| 284 |
let pending = report_pending(home); |
| 285 |
|
| 286 |
|
| 287 |
|
| 288 |
let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new()); |
| 289 |
held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job)); |
| 290 |
std::thread::scope(|scope| { |
| 291 |
for (job, f) in pending { |
| 292 |
let (hub, held) = (&hub, &held); |
| 293 |
scope.spawn(move || { |
| 294 |
eprintln!("job {job}: reporting the result kept from before the restart"); |
| 295 |
report_finish(home, hub, job, &f); |
| 296 |
held.lock().unwrap().remove(&job); |
| 297 |
}); |
| 298 |
} |
| 299 |
loop { |
| 300 |
let room = budget.room(*used.lock().unwrap()); |
| 301 |
let Some((free_memory_mib, free_cpus)) = room else { |
| 302 |
std::thread::sleep(Duration::from_millis(500)); |
| 303 |
continue; |
| 304 |
}; |
| 305 |
let req = ClaimRequest { |
| 306 |
runner: name.clone(), |
| 307 |
runtimes: runtimes.clone(), |
| 308 |
free_memory_mib, |
| 309 |
free_cpus, |
| 310 |
info: Some(RunnerInfo { |
| 311 |
version: env!("OXY_VERSION_LONG").into(), |
| 312 |
..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus) |
| 313 |
}), |
| 314 |
|
| 315 |
images: Some(home.images()), |
| 316 |
image_archs: Some(home.image_archs()), |
| 317 |
holding: Some(held.lock().unwrap().iter().copied().collect()), |
| 318 |
}; |
| 319 |
match hub.claim(&req) { |
| 320 |
Ok(Some(claim)) => { |
| 321 |
idle = Duration::from_secs(2); |
| 322 |
let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus); |
| 323 |
{ |
| 324 |
let mut u = used.lock().unwrap(); |
| 325 |
u.jobs += 1; |
| 326 |
u.memory_mib += mem; |
| 327 |
u.cpus += cpus; |
| 328 |
} |
| 329 |
held.lock().unwrap().insert(claim.job_id); |
| 330 |
let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held); |
| 331 |
scope.spawn(move || { |
| 332 |
let id = claim.job_id; |
| 333 |
eprintln!( |
| 334 |
"job {id}: {} {} on {}", |
| 335 |
claim.repo, |
| 336 |
claim.job.name, |
| 337 |
claim.heads.first().map(|h| &h[..12]).unwrap_or("-") |
| 338 |
); |
| 339 |
let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish { |
| 340 |
exit_code: None, |
| 341 |
duration_ms: 0, |
| 342 |
receipt: None, |
| 343 |
signed_receipt: None, |
| 344 |
error: Some(format!("{e:#}")), |
| 345 |
}); |
| 346 |
eprintln!( |
| 347 |
"job {id}: exit {:?}{}", |
| 348 |
finish.exit_code, |
| 349 |
finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default() |
| 350 |
); |
| 351 |
report_finish(home, hub, id, &finish); |
| 352 |
held.lock().unwrap().remove(&id); |
| 353 |
let mut u = used.lock().unwrap(); |
| 354 |
u.jobs -= 1; |
| 355 |
u.memory_mib -= mem; |
| 356 |
u.cpus -= cpus; |
| 357 |
}); |
| 358 |
} |
| 359 |
Ok(None) => std::thread::sleep(idle), |
| 360 |
Err(e) => { |
| 361 |
eprintln!("claim: {e:#}"); |
| 362 |
std::thread::sleep(idle); |
| 363 |
idle = (idle * 2).min(Duration::from_secs(60)); |
| 364 |
} |
| 365 |
} |
| 366 |
} |
| 367 |
}) |
| 368 |
} |
| 369 |
|
| 370 |
|
| 371 |
struct LogSink<'a> { |
| 372 |
hub: &'a Hub, |
| 373 |
job: i64, |
| 374 |
buf: Vec<Record>, |
| 375 |
last: Instant, |
| 376 |
} |
| 377 |
|
| 378 |
impl LogSink<'_> { |
| 379 |
fn push(&mut self, mut r: Record) { |
| 380 |
if r.text.len() > MAX_RECORD_TEXT { |
| 381 |
let mut cut = MAX_RECORD_TEXT; |
| 382 |
while !r.text.is_char_boundary(cut) { |
| 383 |
cut -= 1; |
| 384 |
} |
| 385 |
r.text.truncate(cut); |
| 386 |
} |
| 387 |
self.buf.push(r); |
| 388 |
if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) { |
| 389 |
self.flush(); |
| 390 |
} |
| 391 |
} |
| 392 |
|
| 393 |
fn flush(&mut self) { |
| 394 |
if !self.buf.is_empty() { |
| 395 |
if let Err(e) = self.hub.log(self.job, std::mem::take(&mut self.buf)) { |
| 396 |
eprintln!("job {}: log: {e:#}", self.job); |
| 397 |
} |
| 398 |
} |
| 399 |
self.last = Instant::now(); |
| 400 |
} |
| 401 |
} |
| 402 |
|
| 403 |
|
| 404 |
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 405 |
|
| 406 |
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> { |
| 407 |
let started = Instant::now(); |
| 408 |
let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now() }; |
| 409 |
let note = |
| 410 |
|sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text }); |
| 411 |
let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?; |
| 412 |
note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"))); |
| 413 |
|
| 414 |
|
| 415 |
|
| 416 |
|
| 417 |
let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner())); |
| 418 |
let repo = cached_repo(home, cfg, claim)?; |
| 419 |
let scratch = home.root.join("work").join(format!("job-{}", claim.job_id)); |
| 420 |
let _ = std::fs::remove_dir_all(&scratch); |
| 421 |
std::fs::create_dir_all(scratch.join("tree"))?; |
| 422 |
let result = (|| -> Result<Finish> { |
| 423 |
let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?; |
| 424 |
|
| 425 |
|
| 426 |
for sub in &claim.subrepos { |
| 427 |
if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") { |
| 428 |
bail!("subrepo path {:?} is not a clean relative path", sub.path); |
| 429 |
} |
| 430 |
note( |
| 431 |
&mut sink, |
| 432 |
format!( |
| 433 |
"fetching subrepo {} at {} for {}", |
| 434 |
sub.repo, |
| 435 |
sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"), |
| 436 |
sub.path |
| 437 |
), |
| 438 |
); |
| 439 |
let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?; |
| 440 |
let sub_heads: Vec<ChangeHash> = |
| 441 |
sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?; |
| 442 |
let dir = scratch.join("tree").join(&sub.path); |
| 443 |
std::fs::create_dir_all(&dir)?; |
| 444 |
let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?; |
| 445 |
files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h))); |
| 446 |
} |
| 447 |
let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h))); |
| 448 |
workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?; |
| 449 |
std::fs::remove_dir_all(scratch.join("tree"))?; |
| 450 |
preparing.take(); |
| 451 |
note( |
| 452 |
&mut sink, |
| 453 |
format!( |
| 454 |
"booting a microVM: {} vCPU, {} MiB, rootfs {}", |
| 455 |
claim.sandbox.cpus, |
| 456 |
claim.sandbox.memory_mib, |
| 457 |
&claim.sandbox.rootfs[..12] |
| 458 |
), |
| 459 |
); |
| 460 |
let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env()); |
| 461 |
let outcome = microvm::run( |
| 462 |
home, |
| 463 |
&claim.sandbox, |
| 464 |
&job, |
| 465 |
&claim.secrets, |
| 466 |
&scratch.join("workspace.ext4"), |
| 467 |
&scratch, |
| 468 |
Duration::from_secs(claim.timeout_s), |
| 469 |
&mut |r| sink.push(r), |
| 470 |
)?; |
| 471 |
|
| 472 |
|
| 473 |
let mut outputs = vec![]; |
| 474 |
let mut output_error = None; |
| 475 |
if outcome.exit_code == Some(0) { |
| 476 |
for o in &claim.job.outputs { |
| 477 |
let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
| 478 |
let kept = crate::outputs::read(&image, &inner, &scratch).and_then(|bytes| { |
| 479 |
let blob = Repo::blob_ref(&bytes); |
| 480 |
let named = hub.artifact(claim.job_id, &o.name, &bytes)?; |
| 481 |
if named != blob.to_hex() { |
| 482 |
bail!("the hub kept it as {named}, not {}", blob.to_hex()); |
| 483 |
} |
| 484 |
note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12])); |
| 485 |
Ok(blob.0) |
| 486 |
}); |
| 487 |
match kept { |
| 488 |
Ok(b) => outputs.push(b), |
| 489 |
Err(e) => { |
| 490 |
output_error = Some(format!("artifact {}: {e:#}", o.name)); |
| 491 |
break; |
| 492 |
} |
| 493 |
} |
| 494 |
} |
| 495 |
} |
| 496 |
if let Some(e) = &output_error { |
| 497 |
note(&mut sink, e.clone()); |
| 498 |
} |
| 499 |
let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt { |
| 500 |
job_name: claim.job.name.clone(), |
| 501 |
pipeline_hash: hex32(&claim.pipeline_hash), |
| 502 |
scope_tree_hash: scope_hash, |
| 503 |
inputs: vec![], |
| 504 |
environment: outcome.environment.hash(), |
| 505 |
outputs, |
| 506 |
exit_code: code as u32, |
| 507 |
duration_ms: outcome.duration_ms, |
| 508 |
}); |
| 509 |
let signed_receipt = match &receipt { |
| 510 |
Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())), |
| 511 |
None => None, |
| 512 |
}; |
| 513 |
Ok(Finish { |
| 514 |
exit_code: outcome.exit_code, |
| 515 |
duration_ms: started.elapsed().as_millis() as u64, |
| 516 |
receipt, |
| 517 |
signed_receipt, |
| 518 |
error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())), |
| 519 |
}) |
| 520 |
})(); |
| 521 |
let _ = std::fs::remove_dir_all(&scratch); |
| 522 |
sink.flush(); |
| 523 |
result |
| 524 |
} |
| 525 |
|
| 526 |
|
| 527 |
|
| 528 |
|
| 529 |
fn sign_receipt( |
| 530 |
repo: &Repo, |
| 531 |
heads: &[ChangeHash], |
| 532 |
cfg: &RunnerConfig, |
| 533 |
key: &RunnerKey, |
| 534 |
claim: &Claim, |
| 535 |
receipt: &Receipt, |
| 536 |
) -> Result<oxy_core::receipt::SignedReceipt> { |
| 537 |
let state = state_of(heads); |
| 538 |
let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number); |
| 539 |
Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret)) |
| 540 |
} |
| 541 |
|
| 542 |
|
| 543 |
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef { |
| 544 |
use oxy_core::hash::{hash_object, ObjectType}; |
| 545 |
hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode()) |
| 546 |
} |
| 547 |
|
| 548 |
fn hex32(s: &str) -> [u8; 32] { |
| 549 |
let mut out = [0; 32]; |
| 550 |
if let Ok(v) = hex::decode(s) { |
| 551 |
if v.len() == 32 { |
| 552 |
out.copy_from_slice(&v); |
| 553 |
} |
| 554 |
} |
| 555 |
out |
| 556 |
} |
| 557 |
|
| 558 |
|
| 559 |
|
| 560 |
|
| 561 |
fn transient(msg: &str) -> bool { |
| 562 |
let m = msg.to_lowercase(); |
| 563 |
[ |
| 564 |
"dns failed", |
| 565 |
"name resolution", |
| 566 |
"connection refused", |
| 567 |
"connection reset", |
| 568 |
"timed out", |
| 569 |
"timeout", |
| 570 |
"http 502", |
| 571 |
"http 503", |
| 572 |
"http 504", |
| 573 |
"broken pipe", |
| 574 |
"unexpected eof", |
| 575 |
] |
| 576 |
.iter() |
| 577 |
.any(|k| m.contains(k)) |
| 578 |
} |
| 579 |
|
| 580 |
|
| 581 |
|
| 582 |
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> { |
| 583 |
let mut tries = waits.iter(); |
| 584 |
loop { |
| 585 |
match f() { |
| 586 |
Ok(v) => return Ok(v), |
| 587 |
Err(e) => match tries.next() { |
| 588 |
Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w), |
| 589 |
_ => return Err(e), |
| 590 |
}, |
| 591 |
} |
| 592 |
} |
| 593 |
} |
| 594 |
|
| 595 |
|
| 596 |
const FETCH_WAITS: [Duration; 5] = |
| 597 |
[Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)]; |
| 598 |
|
| 599 |
|
| 600 |
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> { |
| 601 |
cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token) |
| 602 |
} |
| 603 |
|
| 604 |
|
| 605 |
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> { |
| 606 |
let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__")); |
| 607 |
let set_remote = |repo: &mut Repo| -> Result<()> { |
| 608 |
repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 609 |
repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 610 |
if let Some(ca) = &cfg.ca { |
| 611 |
repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 612 |
} |
| 613 |
repo.save_config().map_err(|e| anyhow::anyhow!("{e}")) |
| 614 |
}; |
| 615 |
if dir.join(".oxy").is_dir() { |
| 616 |
let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 617 |
set_remote(&mut repo)?; |
| 618 |
with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?; |
| 619 |
return Ok(repo); |
| 620 |
} |
| 621 |
|
| 622 |
std::env::set_var("OXY_TOKEN", token); |
| 623 |
if let Some(ca) = &cfg.ca { |
| 624 |
std::env::set_var("OXY_CA", ca); |
| 625 |
} |
| 626 |
let cloned = with_retries(&FETCH_WAITS, || { |
| 627 |
let _ = std::fs::remove_dir_all(&dir); |
| 628 |
Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}")) |
| 629 |
}); |
| 630 |
std::env::remove_var("OXY_TOKEN"); |
| 631 |
std::env::remove_var("OXY_CA"); |
| 632 |
let mut repo = cloned.map_err(|e| { |
| 633 |
let _ = std::fs::remove_dir_all(&dir); |
| 634 |
anyhow::anyhow!("clone {}: {e}", clone_url) |
| 635 |
})?; |
| 636 |
set_remote(&mut repo)?; |
| 637 |
Ok(repo) |
| 638 |
} |
| 639 |
|
| 640 |
#[cfg(test)] |
| 641 |
mod tests { |
| 642 |
use super::*; |
| 643 |
|
| 644 |
#[test] |
| 645 |
fn transient_network_errors_are_tried_again_and_others_are_not() { |
| 646 |
assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution")); |
| 647 |
assert!(transient("HTTP 503: the hub is restarting")); |
| 648 |
assert!(!transient("HTTP 404: no such repository")); |
| 649 |
let mut n = 0; |
| 650 |
let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || { |
| 651 |
n += 1; |
| 652 |
if n < 3 { |
| 653 |
Err(anyhow::anyhow!("connection refused")) |
| 654 |
} else { |
| 655 |
Ok(7) |
| 656 |
} |
| 657 |
}); |
| 658 |
assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success"); |
| 659 |
let mut m = 0; |
| 660 |
let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || { |
| 661 |
m += 1; |
| 662 |
Err(anyhow::anyhow!("HTTP 403: forbidden")) |
| 663 |
}); |
| 664 |
assert!(got.is_err()); |
| 665 |
assert_eq!(m, 1, "a refusal is not tried again"); |
| 666 |
let mut k = 0; |
| 667 |
let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || { |
| 668 |
k += 1; |
| 669 |
Err(anyhow::anyhow!("timed out")) |
| 670 |
}); |
| 671 |
assert!(got.is_err()); |
| 672 |
assert_eq!(k, 3, "gives up after the last wait"); |
| 673 |
} |
| 674 |
|
| 675 |
|
| 676 |
fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) { |
| 677 |
use std::io::{Read, Write}; |
| 678 |
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 679 |
let addr = listener.local_addr().unwrap(); |
| 680 |
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| 681 |
let count = seen.clone(); |
| 682 |
std::thread::spawn(move || { |
| 683 |
for code in codes { |
| 684 |
let (mut s, _) = listener.accept().unwrap(); |
| 685 |
let mut buf = [0u8; 8192]; |
| 686 |
let _ = s.read(&mut buf); |
| 687 |
count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
| 688 |
let reason = if code == 204 { "No Content" } else { "Error" }; |
| 689 |
let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); |
| 690 |
} |
| 691 |
}); |
| 692 |
(format!("http://{addr}"), seen) |
| 693 |
} |
| 694 |
|
| 695 |
|
| 696 |
|
| 697 |
fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) { |
| 698 |
use std::io::{Read, Write}; |
| 699 |
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 700 |
let addr = listener.local_addr().unwrap(); |
| 701 |
let (tx, rx) = std::sync::mpsc::channel(); |
| 702 |
std::thread::spawn(move || { |
| 703 |
let (mut s, _) = listener.accept().unwrap(); |
| 704 |
let mut req = Vec::new(); |
| 705 |
let mut buf = [0u8; 65536]; |
| 706 |
loop { |
| 707 |
let n = s.read(&mut buf).unwrap_or(0); |
| 708 |
if n == 0 { |
| 709 |
break; |
| 710 |
} |
| 711 |
req.extend_from_slice(&buf[..n]); |
| 712 |
let text = String::from_utf8_lossy(&req); |
| 713 |
if let Some(end) = text.find("\r\n\r\n") { |
| 714 |
let len = text[..end] |
| 715 |
.lines() |
| 716 |
.find_map(|l| { |
| 717 |
l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)) |
| 718 |
}) |
| 719 |
.unwrap_or(0); |
| 720 |
if req.len() >= end + 4 + len { |
| 721 |
break; |
| 722 |
} |
| 723 |
} |
| 724 |
} |
| 725 |
tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap(); |
| 726 |
let _ = write!( |
| 727 |
s, |
| 728 |
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", |
| 729 |
body.len() |
| 730 |
); |
| 731 |
}); |
| 732 |
(format!("http://{addr}"), rx) |
| 733 |
} |
| 734 |
|
| 735 |
#[test] |
| 736 |
fn registering_binds_the_key_and_keeps_the_credential_private() { |
| 737 |
use std::os::unix::fs::PermissionsExt; |
| 738 |
let dir = tempfile::tempdir().unwrap(); |
| 739 |
let home = Home::locate(Some(dir.path())).unwrap(); |
| 740 |
let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#); |
| 741 |
let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap(); |
| 742 |
assert_eq!((got.id, got.name.as_str()), (7, "plex")); |
| 743 |
let req = sent.recv().unwrap(); |
| 744 |
assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}"); |
| 745 |
let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap(); |
| 746 |
assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()])); |
| 747 |
let key = home.runner_key("plex").unwrap(); |
| 748 |
assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity"); |
| 749 |
let path = dir.path().join(crate::home::RUNNER_TOML); |
| 750 |
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); |
| 751 |
|
| 752 |
let again = Home::locate(Some(dir.path())).unwrap(); |
| 753 |
let cfg = again.config.runner.unwrap(); |
| 754 |
assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex"))); |
| 755 |
assert_eq!(cfg.labels, ["gpu"]); |
| 756 |
} |
| 757 |
|
| 758 |
fn hub_at(base: String) -> Hub { |
| 759 |
Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() } |
| 760 |
} |
| 761 |
|
| 762 |
|
| 763 |
|
| 764 |
#[test] |
| 765 |
fn a_result_is_reported_until_the_hub_takes_it() { |
| 766 |
let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None }; |
| 767 |
let quick = Duration::from_millis(10); |
| 768 |
let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]); |
| 769 |
hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap(); |
| 770 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors"); |
| 771 |
let (base, seen) = fake_hub(vec![409]); |
| 772 |
assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err()); |
| 773 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final"); |
| 774 |
} |
| 775 |
|
| 776 |
#[test] |
| 777 |
fn server_errors_are_tried_again_and_client_errors_are_not() { |
| 778 |
let quick = [Duration::from_millis(10); 3]; |
| 779 |
let (base, seen) = fake_hub(vec![500, 503, 204]); |
| 780 |
let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap(); |
| 781 |
assert!(r.is_none()); |
| 782 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted"); |
| 783 |
|
| 784 |
let (base, seen) = fake_hub(vec![500, 500, 500, 500]); |
| 785 |
let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string(); |
| 786 |
assert!(e.contains("HTTP 500"), "{e}"); |
| 787 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay"); |
| 788 |
|
| 789 |
let (base, seen) = fake_hub(vec![401]); |
| 790 |
assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err()); |
| 791 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final"); |
| 792 |
} |
| 793 |
|
| 794 |
#[test] |
| 795 |
fn the_budget_admits_jobs_that_fit() { |
| 796 |
let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) }; |
| 797 |
let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus }; |
| 798 |
assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything"); |
| 799 |
assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4)))); |
| 800 |
assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left"); |
| 801 |
assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs"); |
| 802 |
let one = Budget { max_jobs: 1, memory_mib: None, cpus: None }; |
| 803 |
assert_eq!(one.room(used(0, 0, 0)), Some((None, None))); |
| 804 |
assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time"); |
| 805 |
let open = Budget { max_jobs: 2, memory_mib: None, cpus: None }; |
| 806 |
assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured"); |
| 807 |
} |
| 808 |
} |