| e0918b88 Andy McCall |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 47c1d64d Andy McCall ↶ |
6 |
use crate::home::{Home, RunnerConfig, RunnerKey}; |
| e0918b88 Andy McCall |
7 |
use crate::{microvm, workspace}; |
| 8 |
use anyhow::{bail, Context, Result}; |
| 9 |
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, 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 |
fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> { |
| 30 |
let url = format!("{}/api/v1{path}", self.base); |
| 31 |
match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) { |
| 32 |
Ok(r) if r.status() == 204 => Ok(None), |
| 33 |
Ok(r) => Ok(Some(r)), |
| 34 |
Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()), |
| 35 |
Err(e) => bail!("{url}: {e}"), |
| 36 |
} |
| 37 |
} |
| 38 |
|
| 39 |
pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> { |
| 40 |
match self.post("/flow/claim", req)? { |
| 41 |
Some(r) => Ok(Some(r.into_json()?)), |
| 42 |
None => Ok(None), |
| 43 |
} |
| 44 |
} |
| 45 |
|
| 46 |
pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> { |
| 47 |
self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?; |
| 48 |
Ok(()) |
| 49 |
} |
| 50 |
|
| 51 |
pub fn finish(&self, job: i64, f: &Finish) -> Result<()> { |
| 52 |
self.post(&format!("/flow/jobs/{job}/finish"), f)?; |
| 53 |
Ok(()) |
| 54 |
} |
| 55 |
} |
| 56 |
|
| bb643c25 Andy McCall ↶ |
57 |
|
| 58 |
|
| 59 |
#[derive(Debug, Default, PartialEq, Eq)] |
| 60 |
pub struct Budget { |
| 61 |
pub max_jobs: usize, |
| 62 |
pub memory_mib: Option<u32>, |
| 63 |
pub cpus: Option<u32>, |
| 64 |
} |
| 65 |
|
| 66 |
|
| 67 |
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
| 68 |
pub struct Used { |
| 69 |
pub jobs: usize, |
| 70 |
pub memory_mib: u32, |
| 71 |
pub cpus: u32, |
| 72 |
} |
| 73 |
|
| 74 |
impl Budget { |
| 75 |
pub fn of(cfg: &RunnerConfig) -> Budget { |
| 76 |
Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus } |
| 77 |
} |
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> { |
| 83 |
if used.jobs >= self.max_jobs { |
| 84 |
return None; |
| 85 |
} |
| 86 |
if used.jobs == 0 { |
| 87 |
return Some((None, None)); |
| 88 |
} |
| 89 |
let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib)); |
| 90 |
let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus)); |
| 91 |
if mem == Some(0) || cpus == Some(0) { |
| 92 |
return None; |
| 93 |
} |
| 94 |
Some((mem, cpus)) |
| 95 |
} |
| 96 |
} |
| 97 |
|
| e0918b88 Andy McCall |
98 |
pub fn serve(home: &Home) -> Result<()> { |
| 99 |
let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?; |
| 100 |
let hub = Hub::new(&cfg)?; |
| 101 |
let name = cfg.name.clone().unwrap_or_else(|| "flow".into()); |
| bb643c25 Andy McCall ↶ |
102 |
let budget = Budget::of(&cfg); |
| 47c1d64d Andy McCall ↶ |
103 |
let key = home.runner_key(&name)?; |
| 104 |
eprintln!( |
| 105 |
"flow runner {name}: polling {} (up to {} jobs at once), signing receipts as {}", |
| 106 |
cfg.hub, |
| 107 |
budget.max_jobs, |
| 108 |
key.identity.hash().to_hex() |
| 109 |
); |
| bb643c25 Andy McCall ↶ |
110 |
let used = std::sync::Mutex::new(Used::default()); |
| e0918b88 Andy McCall |
111 |
let mut idle = Duration::from_secs(2); |
| bb643c25 Andy McCall ↶ |
112 |
std::thread::scope(|scope| loop { |
| 113 |
let room = budget.room(*used.lock().unwrap()); |
| 114 |
let Some((free_memory_mib, free_cpus)) = room else { |
| 115 |
std::thread::sleep(Duration::from_millis(500)); |
| 116 |
continue; |
| 117 |
}; |
| 118 |
let req = ClaimRequest { runner: name.clone(), runtimes: vec!["microvm".into()], free_memory_mib, free_cpus }; |
| e0918b88 Andy McCall |
119 |
match hub.claim(&req) { |
| 120 |
Ok(Some(claim)) => { |
| 121 |
idle = Duration::from_secs(2); |
| bb643c25 Andy McCall ↶ |
122 |
let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus); |
| 123 |
{ |
| 124 |
let mut u = used.lock().unwrap(); |
| 125 |
u.jobs += 1; |
| 126 |
u.memory_mib += mem; |
| 127 |
u.cpus += cpus; |
| e0918b88 Andy McCall |
128 |
} |
| 47c1d64d Andy McCall ↶ |
129 |
let (home, hub, cfg, used, key) = (home, &hub, &cfg, &used, &key); |
| bb643c25 Andy McCall ↶ |
130 |
scope.spawn(move || { |
| 131 |
let id = claim.job_id; |
| 132 |
eprintln!("job {id}: {} {} on {}", claim.repo, claim.job.name, claim.heads.first().map(|h| &h[..12]).unwrap_or("-")); |
| 47c1d64d Andy McCall ↶ |
133 |
let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish { |
| bb643c25 Andy McCall ↶ |
134 |
exit_code: None, |
| 135 |
duration_ms: 0, |
| 136 |
receipt: None, |
| 47c1d64d Andy McCall ↶ |
137 |
signed_receipt: None, |
| bb643c25 Andy McCall ↶ |
138 |
error: Some(format!("{e:#}")), |
| 139 |
}); |
| 140 |
eprintln!( |
| 141 |
"job {id}: exit {:?}{}", |
| 142 |
finish.exit_code, |
| 143 |
finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default() |
| 144 |
); |
| 145 |
if let Err(e) = hub.finish(id, &finish) { |
| 146 |
eprintln!("job {id}: reporting failed: {e:#}"); |
| 147 |
} |
| 148 |
let mut u = used.lock().unwrap(); |
| 149 |
u.jobs -= 1; |
| 150 |
u.memory_mib -= mem; |
| 151 |
u.cpus -= cpus; |
| 152 |
}); |
| e0918b88 Andy McCall |
153 |
} |
| 154 |
Ok(None) => std::thread::sleep(idle), |
| 155 |
Err(e) => { |
| 156 |
eprintln!("claim: {e:#}"); |
| 157 |
std::thread::sleep(idle); |
| 158 |
idle = (idle * 2).min(Duration::from_secs(60)); |
| 159 |
} |
| 160 |
} |
| bb643c25 Andy McCall ↶ |
161 |
}) |
| e0918b88 Andy McCall |
162 |
} |
| 163 |
|
| 164 |
|
| 165 |
struct LogSink<'a> { |
| 166 |
hub: &'a Hub, |
| 167 |
job: i64, |
| 168 |
buf: Vec<Record>, |
| 169 |
last: Instant, |
| 170 |
} |
| 171 |
|
| 172 |
impl LogSink<'_> { |
| 173 |
fn push(&mut self, mut r: Record) { |
| 174 |
if r.text.len() > MAX_RECORD_TEXT { |
| 175 |
let mut cut = MAX_RECORD_TEXT; |
| 176 |
while !r.text.is_char_boundary(cut) { |
| 177 |
cut -= 1; |
| 178 |
} |
| 179 |
r.text.truncate(cut); |
| 180 |
} |
| 181 |
self.buf.push(r); |
| 182 |
if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) { |
| 183 |
self.flush(); |
| 184 |
} |
| 185 |
} |
| 186 |
|
| 187 |
fn flush(&mut self) { |
| 188 |
if !self.buf.is_empty() { |
| 189 |
if let Err(e) = self.hub.log(self.job, std::mem::take(&mut self.buf)) { |
| 190 |
eprintln!("job {}: log: {e:#}", self.job); |
| 191 |
} |
| 192 |
} |
| 193 |
self.last = Instant::now(); |
| 194 |
} |
| 195 |
} |
| bb643c25 Andy McCall ↶ |
196 |
|
| 197 |
|
| 198 |
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| e0918b88 Andy McCall |
199 |
|
| 47c1d64d Andy McCall ↶ |
200 |
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> { |
| e0918b88 Andy McCall |
201 |
let started = Instant::now(); |
| 202 |
let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now() }; |
| 203 |
let note = |
| 204 |
|sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text }); |
| 205 |
let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?; |
| 206 |
note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"))); |
| bb643c25 Andy McCall ↶ |
207 |
|
| 208 |
|
| 209 |
|
| 210 |
|
| 211 |
let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner())); |
| e0918b88 Andy McCall |
212 |
let repo = cached_repo(home, cfg, claim)?; |
| 213 |
let scratch = home.root.join("work").join(format!("job-{}", claim.job_id)); |
| 214 |
let _ = std::fs::remove_dir_all(&scratch); |
| 215 |
std::fs::create_dir_all(scratch.join("tree"))?; |
| 216 |
let result = (|| -> Result<Finish> { |
| 217 |
let files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?; |
| 218 |
let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h))); |
| 219 |
workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?; |
| 220 |
std::fs::remove_dir_all(scratch.join("tree"))?; |
| bb643c25 Andy McCall ↶ |
221 |
preparing.take(); |
| e0918b88 Andy McCall |
222 |
note( |
| 223 |
&mut sink, |
| 224 |
format!( |
| 225 |
"booting a microVM: {} vCPU, {} MiB, rootfs {}", |
| 226 |
claim.sandbox.cpus, |
| 227 |
claim.sandbox.memory_mib, |
| 228 |
&claim.sandbox.rootfs[..12] |
| 229 |
), |
| 230 |
); |
| 231 |
let outcome = microvm::run( |
| 232 |
home, |
| 233 |
&claim.sandbox, |
| 234 |
&claim.job, |
| 235 |
&scratch.join("workspace.ext4"), |
| 236 |
&scratch, |
| 237 |
Duration::from_secs(claim.timeout_s), |
| 238 |
&mut |r| sink.push(r), |
| 239 |
)?; |
| 240 |
let receipt = outcome.exit_code.map(|code| Receipt { |
| 241 |
job_name: claim.job.name.clone(), |
| 242 |
pipeline_hash: hex32(&claim.pipeline_hash), |
| 243 |
scope_tree_hash: scope_hash, |
| 244 |
inputs: vec![], |
| 245 |
environment: outcome.environment.hash(), |
| 246 |
outputs: vec![], |
| 247 |
exit_code: code as u32, |
| 248 |
duration_ms: outcome.duration_ms, |
| 249 |
}); |
| 47c1d64d Andy McCall ↶ |
250 |
let signed_receipt = match &receipt { |
| 251 |
Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())), |
| 252 |
None => None, |
| 253 |
}; |
| e0918b88 Andy McCall |
254 |
Ok(Finish { |
| 255 |
exit_code: outcome.exit_code, |
| 256 |
duration_ms: started.elapsed().as_millis() as u64, |
| 257 |
receipt, |
| 47c1d64d Andy McCall ↶ |
258 |
signed_receipt, |
| e0918b88 Andy McCall |
259 |
error: outcome.timed_out.then(|| "stopped at the time limit".to_string()), |
| 260 |
}) |
| 261 |
})(); |
| 262 |
let _ = std::fs::remove_dir_all(&scratch); |
| 263 |
sink.flush(); |
| 264 |
result |
| 47c1d64d Andy McCall ↶ |
265 |
} |
| 266 |
|
| 267 |
|
| 268 |
|
| 269 |
|
| 270 |
fn sign_receipt( |
| 271 |
repo: &Repo, |
| 272 |
heads: &[ChangeHash], |
| 273 |
cfg: &RunnerConfig, |
| 274 |
key: &RunnerKey, |
| 275 |
claim: &Claim, |
| 276 |
receipt: &Receipt, |
| 277 |
) -> Result<oxy_core::receipt::SignedReceipt> { |
| 278 |
let state = state_of(heads); |
| 279 |
let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number); |
| 280 |
Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret)) |
| 281 |
} |
| 282 |
|
| 283 |
|
| 284 |
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef { |
| 285 |
use oxy_core::hash::{hash_object, ObjectType}; |
| 286 |
hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode()) |
| e0918b88 Andy McCall |
287 |
} |
| 288 |
|
| 289 |
fn hex32(s: &str) -> [u8; 32] { |
| 290 |
let mut out = [0; 32]; |
| 291 |
if let Ok(v) = hex::decode(s) { |
| 292 |
if v.len() == 32 { |
| 293 |
out.copy_from_slice(&v); |
| 294 |
} |
| 295 |
} |
| 296 |
out |
| 297 |
} |
| 298 |
|
| 299 |
|
| 300 |
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> { |
| 301 |
let dir: PathBuf = home.root.join("repos").join(claim.repo.replace('/', "__")); |
| 302 |
let set_remote = |repo: &mut Repo| -> Result<()> { |
| 303 |
repo.config.set("remote.origin.url", &claim.clone_url).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 304 |
repo.config.set("remote.origin.token", &claim.token).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 305 |
if let Some(ca) = &cfg.ca { |
| 306 |
repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 307 |
} |
| 308 |
repo.save_config().map_err(|e| anyhow::anyhow!("{e}")) |
| 309 |
}; |
| 310 |
if dir.join(".oxy").is_dir() { |
| 311 |
let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 312 |
set_remote(&mut repo)?; |
| 313 |
repo.fetch("origin").map_err(|e| anyhow::anyhow!("fetch {}: {e}", claim.clone_url))?; |
| 314 |
return Ok(repo); |
| 315 |
} |
| 316 |
|
| 317 |
std::env::set_var("OXY_TOKEN", &claim.token); |
| 318 |
if let Some(ca) = &cfg.ca { |
| 319 |
std::env::set_var("OXY_CA", ca); |
| 320 |
} |
| 321 |
let cloned = Repo::clone(&claim.clone_url, &dir); |
| 322 |
std::env::remove_var("OXY_TOKEN"); |
| 323 |
std::env::remove_var("OXY_CA"); |
| 324 |
let mut repo = cloned.map_err(|e| { |
| 325 |
let _ = std::fs::remove_dir_all(&dir); |
| 326 |
anyhow::anyhow!("clone {}: {e}", claim.clone_url) |
| 327 |
})?; |
| 328 |
set_remote(&mut repo)?; |
| 329 |
Ok(repo) |
| bb643c25 Andy McCall ↶ |
330 |
} |
| 331 |
|
| 332 |
#[cfg(test)] |
| 333 |
mod tests { |
| 334 |
use super::*; |
| 335 |
|
| 336 |
#[test] |
| 337 |
fn the_budget_admits_jobs_that_fit() { |
| 338 |
let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) }; |
| 339 |
let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus }; |
| 340 |
assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything"); |
| 341 |
assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4)))); |
| 342 |
assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left"); |
| 343 |
assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs"); |
| 344 |
let one = Budget { max_jobs: 1, memory_mib: None, cpus: None }; |
| 345 |
assert_eq!(one.room(used(0, 0, 0)), Some((None, None))); |
| 346 |
assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time"); |
| 347 |
let open = Budget { max_jobs: 2, memory_mib: None, cpus: None }; |
| 348 |
assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured"); |
| 349 |
} |
| e0918b88 Andy McCall |
350 |
} |