| 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, Decline, 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::{Path, PathBuf}; |
| 14 | | use std::time::{Duration, Instant}; |
| 15 | |
|
| 16 | |
|
| 17 | |
|
| 18 | | #[derive(Debug)] |
| 19 | | pub struct Refused { |
| 20 | | pub code: u16, |
| 21 | | pub text: String, |
| 22 | | } |
| 23 | |
|
| 24 | | impl std::fmt::Display for Refused { |
| 25 | 0× | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 26 | 0× | write!(f, "HTTP {}: {}", self.code, self.text) |
| 27 | 0× | } |
| 28 | | } |
| 29 | |
|
| 30 | | impl std::error::Error for Refused {} |
| 31 | |
|
| 32 | |
|
| 33 | 3× | fn job_gone(e: &anyhow::Error) -> bool { |
| 34 | 3× | e.downcast_ref::<Refused>().is_some_and(|r| r.code == 409) |
| 35 | 3× | } |
| 36 | |
|
| 37 | |
|
| 38 | |
|
| 39 | | const CANCELLED: &str = "the run was cancelled"; |
| 40 | |
|
| 41 | 0× | fn cancelled() -> Finish { |
| 42 | 0× | Finish { |
| 43 | 0× | exit_code: None, |
| 44 | 0× | duration_ms: 0, |
| 45 | 0× | receipt: None, |
| 46 | 0× | signed_receipt: None, |
| 47 | 0× | error: Some(CANCELLED.into()), |
| 48 | 0× | peak_disk_mib: None, |
| 49 | 0× | peak_memory_mib: None, |
| 50 | 0× | } |
| 51 | 0× | } |
| 52 | |
|
| 53 | | pub struct Hub { |
| 54 | | base: String, |
| 55 | | token: String, |
| 56 | | agent: ureq::Agent, |
| 57 | | } |
| 58 | |
|
| 59 | | impl Hub { |
| 60 | 0× | pub fn new(cfg: &RunnerConfig) -> Result<Hub> { |
| 61 | 0× | let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 62 | 0× | let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build(); |
| 63 | 0× | Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent }) |
| 64 | 0× | } |
| 65 | |
|
| 66 | | |
| 67 | | |
| 68 | | |
| 69 | 8× | fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> { |
| 70 | 8× | self.post_with(path, body, &RETRY_DELAYS) |
| 71 | 8× | } |
| 72 | |
|
| 73 | 11× | fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> { |
| 74 | 11× | let url = format!("{}/api/v1{path}", self.base); |
| 75 | 16× | let r = retrying(&url, delays, false, || { |
| 76 | 16× | self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new) |
| 77 | 16× | })?; |
| 78 | 6× | Ok(if r.status() == 204 { None } else { Some(r) }) |
| 79 | 11× | } |
| 80 | |
|
| 81 | | |
| 82 | | |
| 83 | | |
| 84 | 1× | fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> { |
| 85 | 1× | let url = format!("{}/api/v1{path}", self.base); |
| 86 | 1× | let r = retrying(&url, &PATIENT_DELAYS, true, || { |
| 87 | 1× | self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new) |
| 88 | 1× | })?; |
| 89 | 1× | Ok(if r.status() == 204 { None } else { Some(r) }) |
| 90 | 1× | } |
| 91 | |
|
| 92 | 0× | pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> { |
| 93 | 0× | match self.post("/flow/claim", req)? { |
| 94 | 0× | Some(r) => Ok(Some(r.into_json()?)), |
| 95 | 0× | None => Ok(None), |
| 96 | | } |
| 97 | 0× | } |
| 98 | |
|
| 99 | | |
| 100 | 0× | pub fn decline(&self, job: i64, reason: &str) -> Result<()> { |
| 101 | 0× | self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?; |
| 102 | 0× | Ok(()) |
| 103 | 0× | } |
| 104 | |
|
| 105 | | |
| 106 | | |
| 107 | 1× | pub fn release(&self, job: i64, reason: &str) -> Result<()> { |
| 108 | 1× | self.post_patiently(&format!("/flow/jobs/{job}/release"), &Decline { reason: reason.into() })?; |
| 109 | 1× | Ok(()) |
| 110 | 1× | } |
| 111 | |
|
| 112 | | |
| 113 | | |
| 114 | | |
| 115 | | |
| 116 | | |
| 117 | | |
| 118 | 3× | pub fn certify( |
| 119 | 3× | &self, |
| 120 | 3× | job_token: &str, |
| 121 | 3× | identity: &oxy_core::identity::Identity, |
| 122 | 3× | proof: &oxy_core::identity::Signature, |
| 123 | 3× | ) -> Result<(oxy_core::cert::SignedCertificate, oxy_core::identity::Identity)> { |
| 124 | | use oxy_core::dsse::{base64, base64_decode}; |
| 125 | 3× | let body = serde_json::json!({ |
| 126 | 3× | "identity": base64(&identity.encode()), |
| 127 | 3× | "proof": base64(&oxy_core::cbor::encode(&proof.to_value())), |
| 128 | 3× | "scope": "seal", |
| 129 | | }); |
| 130 | 3× | let url = format!("{}/api/v1/keyless/certify", self.base); |
| 131 | 3× | let r = self |
| 132 | 3× | .agent |
| 133 | 3× | .post(&url) |
| 134 | 3× | .set("Authorization", &format!("Bearer {job_token}")) |
| 135 | 3× | .send_json(body) |
| 136 | 3× | .map_err(|e| anyhow::anyhow!("asking the hub to certify the key: {e}"))?; |
| 137 | 3× | let v: serde_json::Value = r.into_json()?; |
| 138 | 3× | let cert = base64_decode(v["certificate"].as_str().unwrap_or("")) |
| 139 | 3× | .context("the hub's answer has no certificate") |
| 140 | 3× | .and_then(|b| oxy_core::cert::SignedCertificate::decode(&b).map_err(|e| anyhow::anyhow!("{e}")))?; |
| 141 | 3× | let mut bytes = Vec::new(); |
| 142 | 3× | std::io::Read::read_to_end( |
| 143 | 3× | &mut self.agent.get(&format!("{}/.well-known/oxyhub-key", self.base)).call()?.into_reader(), |
| 144 | 3× | &mut bytes, |
| 145 | 0× | )?; |
| 146 | 3× | let issuer = oxy_core::identity::Identity::decode(&bytes)?; |
| 147 | 3× | if issuer.hash() != cert.cert.issuer || cert.verify(&issuer).is_err() { |
| 148 | 1× | bail!("the certificate is not signed by the hub's published key"); |
| 149 | 2× | } |
| 150 | 2× | if cert.cert.key_id() != identity.hash() { |
| 151 | 1× | bail!("the hub certified another key"); |
| 152 | 1× | } |
| 153 | 1× | Ok((cert, issuer)) |
| 154 | 3× | } |
| 155 | |
|
| 156 | 0× | pub fn heartbeat(&self) -> Result<()> { |
| 157 | 0× | match self.post_with("/flow/runners/heartbeat", &serde_json::json!({}), &[]) { |
| 158 | 0× | Ok(_) => Ok(()), |
| 159 | 0× | Err(e) if format!("{e:#}").contains("404") => Ok(()), |
| 160 | 0× | Err(e) => Err(e), |
| 161 | | } |
| 162 | 0× | } |
| 163 | |
|
| 164 | | |
| 165 | | |
| 166 | 0× | pub fn update_answer(&self) -> Result<Option<flow_core::api::UpdateAnswer>> { |
| 167 | 0× | let url = format!("{}/api/v1/flow/runner/update", self.base); |
| 168 | 0× | match self.agent.get(&url).set("Authorization", &format!("Bearer {}", self.token)).call() { |
| 169 | 0× | Ok(r) => Ok(Some(r.into_json()?)), |
| 170 | 0× | Err(ureq::Error::Status(404, _)) => Ok(None), |
| 171 | 0× | Err(e) => Err(anyhow::anyhow!("{url}: {e}")), |
| 172 | | } |
| 173 | 0× | } |
| 174 | |
|
| 175 | | |
| 176 | 0× | pub fn report_update(&self, note: &str) -> Result<()> { |
| 177 | 0× | self.post_with("/flow/runner/update", &flow_core::api::UpdateReport { note: note.to_string() }, &[])?; |
| 178 | 0× | Ok(()) |
| 179 | 0× | } |
| 180 | |
|
| 181 | | |
| 182 | | |
| 183 | 2× | pub fn download_build(&self, offer: &flow_core::api::UpdateOffer, to: &Path) -> Result<()> { |
| 184 | | use std::io::{Read, Write}; |
| 185 | 2× | let url = format!("{}{}", self.base, offer.url); |
| 186 | 2× | let r = self.agent.get(&url).timeout(Duration::from_secs(1800)).call().map_err(|e| anyhow::anyhow!("{url}: {e}"))?; |
| 187 | 2× | let part = to.with_extension("partial"); |
| 188 | 2× | let mut out = std::fs::File::create(&part).with_context(|| format!("writing {}", part.display()))?; |
| 189 | 2× | let (mut hasher, mut n, mut buf) = (blake3::Hasher::new(), 0u64, vec![0u8; 1 << 16]); |
| 190 | 2× | let mut body = r.into_reader().take(offer.size + 1); |
| 191 | | loop { |
| 192 | 4× | let k = body.read(&mut buf)?; |
| 193 | 4× | if k == 0 { |
| 194 | 2× | break; |
| 195 | 2× | } |
| 196 | 2× | hasher.update(&buf[..k]); |
| 197 | 2× | out.write_all(&buf[..k])?; |
| 198 | 2× | n += k as u64; |
| 199 | | } |
| 200 | 2× | drop(out); |
| 201 | 2× | let got = hasher.finalize().to_hex().to_string(); |
| 202 | 2× | if n != offer.size || got != offer.blake3 { |
| 203 | 1× | let _ = std::fs::remove_file(&part); |
| 204 | 1× | bail!("{} does not match what the hub attests ({n} bytes, BLAKE3 {}…)", offer.asset, &got[..12]); |
| 205 | 1× | } |
| 206 | 1× | std::fs::rename(&part, to)?; |
| 207 | 1× | Ok(()) |
| 208 | 2× | } |
| 209 | |
|
| 210 | 8× | pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> { |
| 211 | 8× | self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?; |
| 212 | 5× | Ok(()) |
| 213 | 8× | } |
| 214 | |
|
| 215 | | |
| 216 | | |
| 217 | 0× | pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> { |
| 218 | 0× | let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base); |
| 219 | 0× | let r = retrying(&url, &PATIENT_DELAYS, true, || { |
| 220 | 0× | self.agent |
| 221 | 0× | .post(&url) |
| 222 | 0× | .timeout(Duration::from_secs(600)) |
| 223 | 0× | .set("Authorization", &format!("Bearer {}", self.token)) |
| 224 | 0× | .set("Content-Type", "application/octet-stream") |
| 225 | 0× | .send_bytes(bytes) |
| 226 | 0× | .map_err(Box::new) |
| 227 | 0× | })?; |
| 228 | 0× | let v: serde_json::Value = r.into_json()?; |
| 229 | 0× | v["blob"].as_str().map(String::from).context("the hub named no blob") |
| 230 | 0× | } |
| 231 | |
|
| 232 | | |
| 233 | | |
| 234 | | |
| 235 | 0× | pub fn save_checkpoint(&self, job: i64, bytes: &[u8]) -> Result<String> { |
| 236 | 0× | let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base); |
| 237 | 0× | let r = retrying(&url, &PATIENT_DELAYS, true, || { |
| 238 | 0× | self.agent |
| 239 | 0× | .post(&url) |
| 240 | 0× | .timeout(Duration::from_secs(600)) |
| 241 | 0× | .set("Authorization", &format!("Bearer {}", self.token)) |
| 242 | 0× | .set("Content-Type", "application/octet-stream") |
| 243 | 0× | .send_bytes(bytes) |
| 244 | 0× | .map_err(Box::new) |
| 245 | 0× | })?; |
| 246 | 0× | let v: serde_json::Value = r.into_json()?; |
| 247 | 0× | v["blob"].as_str().map(String::from).context("the hub named no blob") |
| 248 | 0× | } |
| 249 | |
|
| 250 | | |
| 251 | | |
| 252 | 0× | pub fn checkpoint(&self, job: i64) -> Result<Vec<u8>> { |
| 253 | 0× | let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base); |
| 254 | 0× | let r = retrying(&url, &PATIENT_DELAYS, false, || { |
| 255 | 0× | self.agent |
| 256 | 0× | .get(&url) |
| 257 | 0× | .timeout(Duration::from_secs(600)) |
| 258 | 0× | .set("Authorization", &format!("Bearer {}", self.token)) |
| 259 | 0× | .call() |
| 260 | 0× | .map_err(Box::new) |
| 261 | 0× | })?; |
| 262 | 0× | let mut bytes = Vec::new(); |
| 263 | 0× | std::io::Read::read_to_end(&mut std::io::Read::take(r.into_reader(), crate::checkpoint::MAX_BYTES as u64 + 1), &mut bytes)?; |
| 264 | 0× | Ok(bytes) |
| 265 | 0× | } |
| 266 | |
|
| 267 | | |
| 268 | | |
| 269 | | |
| 270 | 0× | pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> { |
| 271 | 0× | let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base); |
| 272 | 0× | let r = retrying(&url, &PATIENT_DELAYS, false, || { |
| 273 | 0× | self.agent |
| 274 | 0× | .get(&url) |
| 275 | 0× | .timeout(Duration::from_secs(600)) |
| 276 | 0× | .set("Authorization", &format!("Bearer {}", self.token)) |
| 277 | 0× | .call() |
| 278 | 0× | .map_err(Box::new) |
| 279 | 0× | })?; |
| 280 | 0× | let mut bytes = Vec::new(); |
| 281 | 0× | let mut reader = r.into_reader(); |
| 282 | 0× | std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?; |
| 283 | 0× | if bytes.len() > MAX_INPUT_BYTES { |
| 284 | 0× | bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20); |
| 285 | 0× | } |
| 286 | 0× | Ok(bytes) |
| 287 | 0× | } |
| 288 | |
|
| 289 | | |
| 290 | | |
| 291 | | |
| 292 | 0× | pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> { |
| 293 | 0× | let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base); |
| 294 | 0× | let r = retrying(&url, &PATIENT_DELAYS, true, || { |
| 295 | 0× | self.agent |
| 296 | 0× | .post(&url) |
| 297 | 0× | .timeout(Duration::from_secs(600)) |
| 298 | 0× | .set("Authorization", &format!("Bearer {}", self.token)) |
| 299 | 0× | .set("Content-Type", "application/octet-stream") |
| 300 | 0× | .set("X-Flow-Report-Path", path) |
| 301 | 0× | .send_bytes(bytes) |
| 302 | 0× | .map_err(Box::new) |
| 303 | 0× | })?; |
| 304 | 0× | let v: serde_json::Value = r.into_json()?; |
| 305 | 0× | v["blob"].as_str().map(String::from).context("the hub named no blob") |
| 306 | 0× | } |
| 307 | |
|
| 308 | | |
| 309 | | |
| 310 | | |
| 311 | | |
| 312 | | |
| 313 | 2× | pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> { |
| 314 | 2× | let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base); |
| 315 | 2× | let deadline = std::time::Instant::now() + patience; |
| 316 | 2× | let mut wait = first_wait; |
| 317 | | loop { |
| 318 | 6× | let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) { |
| 319 | 1× | Ok(_) => return Ok(()), |
| 320 | 5× | Err(ureq::Error::Status(code, r)) if code < 500 => { |
| 321 | 1× | bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()) |
| 322 | | } |
| 323 | 4× | Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"), |
| 324 | 0× | Err(e) => e.to_string(), |
| 325 | | }; |
| 326 | 4× | if std::time::Instant::now() + wait > deadline { |
| 327 | 0× | bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60); |
| 328 | 4× | } |
| 329 | 4× | eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs()); |
| 330 | 4× | std::thread::sleep(wait); |
| 331 | 4× | wait = (wait * 2).min(Duration::from_secs(60)); |
| 332 | | } |
| 333 | 2× | } |
| 334 | | } |
| 335 | |
|
| 336 | |
|
| 337 | | const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600); |
| 338 | |
|
| 339 | |
|
| 340 | |
|
| 341 | 0× | fn pending_path(home: &Home, job: i64) -> PathBuf { |
| 342 | 0× | home.root.join("pending").join(format!("{job}.json")) |
| 343 | 0× | } |
| 344 | |
|
| 345 | |
|
| 346 | 0× | fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) { |
| 347 | 0× | let path = pending_path(home, job); |
| 348 | 0× | let kept = std::fs::create_dir_all(path.parent().unwrap()) |
| 349 | 0× | .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default())) |
| 350 | 0× | .is_ok(); |
| 351 | 0× | match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) { |
| 352 | 0× | Ok(()) => {} |
| 353 | 0× | Err(e) => eprintln!("job {job}: reporting failed: {e:#}"), |
| 354 | | } |
| 355 | 0× | if kept { |
| 356 | 0× | let _ = std::fs::remove_file(&path); |
| 357 | 0× | } |
| 358 | 0× | } |
| 359 | |
|
| 360 | |
|
| 361 | 0× | fn report_pending(home: &Home) -> Vec<(i64, Finish)> { |
| 362 | 0× | let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else { |
| 363 | 0× | return Vec::new(); |
| 364 | | }; |
| 365 | 0× | dir.filter_map(|e| { |
| 366 | 0× | let p = e.ok()?.path(); |
| 367 | 0× | let job: i64 = p.file_stem()?.to_str()?.parse().ok()?; |
| 368 | 0× | let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?; |
| 369 | 0× | Some((job, f)) |
| 370 | 0× | }) |
| 371 | 0× | .collect() |
| 372 | 0× | } |
| 373 | |
|
| 374 | |
|
| 375 | |
|
| 376 | |
|
| 377 | 1× | pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> { |
| 378 | 1× | if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() { |
| 379 | 0× | bail!("flow.toml already has a [runner] section; remove it to register this runner instead"); |
| 380 | 1× | } |
| 381 | 1× | let key = home.runner_key(name)?; |
| 382 | 1× | let hub = hub.trim_end_matches('/').to_string(); |
| 383 | 1× | let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 384 | 1× | let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build(); |
| 385 | 1× | let req = Register { |
| 386 | 1× | token: token.trim().to_string(), |
| 387 | 1× | name: name.to_string(), |
| 388 | 1× | identity: hex::encode(key.identity.encode()), |
| 389 | 1× | info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) }, |
| 390 | 1× | }; |
| 391 | 1× | let url = format!("{hub}/api/v1/flow/runners/register"); |
| 392 | 1× | let got: Registered = match agent.post(&url).send_json(&req) { |
| 393 | 1× | Ok(r) => r.into_json()?, |
| 394 | 0× | Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()), |
| 395 | 0× | Err(e) => bail!("{url}: {e}"), |
| 396 | | }; |
| 397 | 1× | let mut text = format!( |
| 398 | | "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n", |
| 399 | | got.credential, got.name |
| 400 | | ); |
| 401 | 1× | if let Some(ca) = &ca { |
| 402 | 0× | text.push_str(&format!("ca = {:?}\n", ca.display().to_string())); |
| 403 | 1× | } |
| 404 | 1× | if !labels.is_empty() { |
| 405 | 1× | text.push_str(&format!("labels = {labels:?}\n")); |
| 406 | 1× | } |
| 407 | 1× | let path = home.root.join(crate::home::RUNNER_TOML); |
| 408 | | { |
| 409 | | use std::io::Write; |
| 410 | 1× | let tmp = path.with_extension("toml.tmp"); |
| 411 | 1× | let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&tmp)?; |
| 412 | 1× | f.write_all(text.as_bytes())?; |
| 413 | 1× | f.sync_all()?; |
| 414 | 1× | std::fs::rename(&tmp, &path)?; |
| 415 | | } |
| 416 | 1× | Ok(got) |
| 417 | 1× | } |
| 418 | |
|
| 419 | |
|
| 420 | | const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)]; |
| 421 | |
|
| 422 | |
|
| 423 | |
|
| 424 | |
|
| 425 | |
|
| 426 | 6× | fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> { |
| 427 | 6× | let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy")) |
| 428 | | else { |
| 429 | 4× | bail!("path {:?} is not a clean path under /src", i.path); |
| 430 | | }; |
| 431 | 2× | let blob = Repo::blob_ref(bytes); |
| 432 | 2× | if blob.to_hex() != i.blob { |
| 433 | 1× | bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]); |
| 434 | 1× | } |
| 435 | 1× | let dest = tree.join(rel); |
| 436 | 1× | if let Some(dir) = dest.parent() { |
| 437 | 1× | std::fs::create_dir_all(dir)?; |
| 438 | 0× | } |
| 439 | 1× | std::fs::write(&dest, bytes)?; |
| 440 | 1× | Ok(blob.0) |
| 441 | 6× | } |
| 442 | |
|
| 443 | |
|
| 444 | | pub const MAX_INPUT_BYTES: usize = 512 << 20; |
| 445 | |
|
| 446 | | const PATIENT_DELAYS: [Duration; 15] = { |
| 447 | | let mut d = [Duration::from_secs(15); 15]; |
| 448 | | d[0] = Duration::from_secs(1); |
| 449 | | d[1] = Duration::from_secs(2); |
| 450 | | d[2] = Duration::from_secs(4); |
| 451 | | d[3] = Duration::from_secs(8); |
| 452 | | d |
| 453 | | }; |
| 454 | |
|
| 455 | |
|
| 456 | |
|
| 457 | |
|
| 458 | |
|
| 459 | 14× | fn retrying( |
| 460 | 14× | url: &str, |
| 461 | 14× | delays: &[Duration], |
| 462 | 14× | connection: bool, |
| 463 | 14× | mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>, |
| 464 | 14× | ) -> Result<ureq::Response> { |
| 465 | 14× | let mut delays = delays.iter(); |
| 466 | | loop { |
| 467 | 23× | let why = match send().map_err(|e| *e) { |
| 468 | 8× | Ok(r) => return Ok(r), |
| 469 | 9× | Err(ureq::Error::Status(code, r)) if code < 500 => { |
| 470 | 2× | let text = r.into_string().unwrap_or_default(); |
| 471 | 2× | return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string())); |
| 472 | | } |
| 473 | 7× | Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()), |
| 474 | 3× | Err(e) if connection => e.to_string(), |
| 475 | 3× | Err(e) => bail!("{url}: {e}"), |
| 476 | | }; |
| 477 | 10× | match delays.next() { |
| 478 | 9× | Some(d) => std::thread::sleep(*d), |
| 479 | 1× | None => bail!("{url}: {why}"), |
| 480 | | } |
| 481 | | } |
| 482 | 14× | } |
| 483 | |
|
| 484 | |
|
| 485 | |
|
| 486 | | #[derive(Debug, Default, PartialEq, Eq)] |
| 487 | | pub struct Budget { |
| 488 | | pub max_jobs: usize, |
| 489 | | pub memory_mib: Option<u32>, |
| 490 | | pub cpus: Option<u32>, |
| 491 | | } |
| 492 | |
|
| 493 | |
|
| 494 | | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
| 495 | | pub struct Used { |
| 496 | | pub jobs: usize, |
| 497 | | pub memory_mib: u32, |
| 498 | | pub cpus: u32, |
| 499 | | } |
| 500 | |
|
| 501 | | impl Budget { |
| 502 | 0× | pub fn of(cfg: &RunnerConfig) -> Budget { |
| 503 | 0× | Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus } |
| 504 | 0× | } |
| 505 | |
|
| 506 | | |
| 507 | | |
| 508 | | |
| 509 | 7× | pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> { |
| 510 | 7× | if used.jobs >= self.max_jobs { |
| 511 | 2× | return None; |
| 512 | 5× | } |
| 513 | 5× | if used.jobs == 0 { |
| 514 | 2× | return Some((None, None)); |
| 515 | 3× | } |
| 516 | 3× | let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib)); |
| 517 | 3× | let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus)); |
| 518 | 3× | if mem == Some(0) || cpus == Some(0) { |
| 519 | 1× | return None; |
| 520 | 2× | } |
| 521 | 2× | Some((mem, cpus)) |
| 522 | 7× | } |
| 523 | | } |
| 524 | |
|
| 525 | |
|
| 526 | |
|
| 527 | |
|
| 528 | |
|
| 529 | |
|
| 530 | |
|
| 531 | 3× | pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> { |
| 532 | 3× | let mut have = Vec::new(); |
| 533 | 3× | let mut missing = Vec::new(); |
| 534 | 3× | let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm"); |
| 535 | 3× | match (kvm, home.config.microvm.is_some()) { |
| 536 | 3× | _ if !cfg!(target_os = "linux") => missing.push("microvm: the tier runs only on Linux (Firecracker and KVM)".to_string()), |
| 537 | 0× | (Ok(_), true) => have.push("microvm".to_string()), |
| 538 | 0× | (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()), |
| 539 | 3× | (Err(e), _) => missing.push(format!( |
| 540 | | "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group" |
| 541 | | )), |
| 542 | | } |
| 543 | 3× | match crate::syscall::available() { |
| 544 | 0× | Ok(()) => have.push("syscall".to_string()), |
| 545 | 3× | Err(e) => missing.push(format!("syscall: {e}")), |
| 546 | | } |
| 547 | | |
| 548 | 3× | have.push("wasm".to_string()); |
| 549 | 3× | if have.is_empty() { |
| 550 | 0× | return Err(format!("no isolation tier can run here: {}", missing.join("; "))); |
| 551 | 3× | } |
| 552 | 3× | Ok((have, missing)) |
| 553 | 3× | } |
| 554 | |
|
| 555 | |
|
| 556 | |
|
| 557 | |
|
| 558 | 0× | fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) { |
| 559 | 0× | let hash = &claim.sandbox.rootfs; |
| 560 | 0× | if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) { |
| 561 | 0× | return; |
| 562 | 0× | } |
| 563 | 0× | let note = |text: String| { |
| 564 | 0× | eprintln!("job {}: {text}", claim.job_id); |
| 565 | 0× | hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok(); |
| 566 | 0× | }; |
| 567 | | |
| 568 | | static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 569 | 0× | let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner()); |
| 570 | 0× | if home.rootfs(hash).is_file() { |
| 571 | 0× | return; |
| 572 | 0× | } |
| 573 | 0× | let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) { |
| 574 | 0× | Ok(c) => c, |
| 575 | 0× | Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])), |
| 576 | | }; |
| 577 | 0× | let fetched = match swarm { |
| 578 | 0× | Some(sw) => { |
| 579 | 0× | note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12])); |
| 580 | 0× | sw.fetch(home, hash) |
| 581 | | } |
| 582 | | None => { |
| 583 | 0× | note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12])); |
| 584 | 0× | client.fetch(home, hash) |
| 585 | | } |
| 586 | | }; |
| 587 | 0× | match fetched { |
| 588 | 0× | Ok(Some(m)) => note(format!( |
| 589 | | "image {}: fetched {} chunks, {}{} in {:.1} s", |
| 590 | 0× | &hash[..12], |
| 591 | | m.chunks, |
| 592 | 0× | crate::images::human(m.bytes), |
| 593 | 0× | if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() }, |
| 594 | 0× | m.elapsed.as_secs_f64() |
| 595 | | )), |
| 596 | 0× | Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])), |
| 597 | 0× | Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])), |
| 598 | | } |
| 599 | 0× | } |
| 600 | |
|
| 601 | |
|
| 602 | |
|
| 603 | |
|
| 604 | |
|
| 605 | 0× | fn preflight(home: &Home, claim: &Claim) -> Option<String> { |
| 606 | 0× | let sb = &claim.sandbox; |
| 607 | 0× | match sb.runtime { |
| 608 | | flow_core::Runtime::Microvm => { |
| 609 | 0× | if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") { |
| 610 | 0× | return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})")); |
| 611 | 0× | } |
| 612 | 0× | if home.config.microvm.is_none() { |
| 613 | 0× | return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into()); |
| 614 | 0× | } |
| 615 | | } |
| 616 | | flow_core::Runtime::Syscall => { |
| 617 | 0× | if let Err(e) = crate::syscall::available() { |
| 618 | 0× | return Some(format!("the syscall tier cannot run here: {e}")); |
| 619 | 0× | } |
| 620 | | } |
| 621 | | |
| 622 | | flow_core::Runtime::Wasm => { |
| 623 | 0× | return available_memory_mib() |
| 624 | 0× | .filter(|free| *free < u64::from(sb.memory_mib)) |
| 625 | 0× | .map(|free| format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib)); |
| 626 | | } |
| 627 | | } |
| 628 | 0× | sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib()) |
| 629 | 0× | } |
| 630 | |
|
| 631 | |
|
| 632 | |
|
| 633 | 8× | fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> { |
| 634 | 8× | if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) { |
| 635 | 1× | return Some(format!("the sandbox needs {want}; this runner is {here}")); |
| 636 | 7× | } |
| 637 | 7× | let short = &sb.rootfs[..12.min(sb.rootfs.len())]; |
| 638 | 7× | let image = home.rootfs(&sb.rootfs); |
| 639 | 7× | if !image.is_file() { |
| 640 | 1× | return Some(format!("rootfs {short} is not on this runner")); |
| 641 | 6× | } |
| 642 | 6× | if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) { |
| 643 | 2× | return Some(format!("rootfs {short} is for {arch}; this runner is {here}")); |
| 644 | 4× | } |
| 645 | 4× | if let Err(e) = home.verified(&image, &sb.rootfs) { |
| 646 | 1× | return Some(format!("rootfs {short} does not verify here: {e:#}")); |
| 647 | 3× | } |
| 648 | 3× | if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) { |
| 649 | 1× | return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib)); |
| 650 | 2× | } |
| 651 | 2× | None |
| 652 | 8× | } |
| 653 | |
|
| 654 | |
|
| 655 | 0× | fn available_memory_mib() -> Option<u64> { |
| 656 | 0× | let info = std::fs::read_to_string("/proc/meminfo").ok()?; |
| 657 | 0× | let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; |
| 658 | 0× | Some(kib / 1024) |
| 659 | 0× | } |
| 660 | |
|
| 661 | 0× | pub fn serve(home: &Home) -> Result<()> { |
| 662 | 0× | drain_on_stop(); |
| 663 | 0× | crate::update::note_runner(&home.root); |
| 664 | | |
| 665 | | |
| 666 | | #[cfg(unix)] |
| 667 | 0× | { |
| 668 | 0× | let handler = on_update as extern "C" fn(libc::c_int) as libc::sighandler_t; |
| 669 | 0× | unsafe { libc::signal(libc::SIGUSR1, handler) }; |
| 670 | 0× | } |
| 671 | 0× | if let Some(p) = crate::update::pending(&home.root) { |
| 672 | | |
| 673 | 0× | let root = home.root.clone(); |
| 674 | 0× | std::thread::spawn(move || { |
| 675 | 0× | std::thread::sleep(crate::update::PROVE_WITHIN); |
| 676 | 0× | if crate::update::pending(&root).is_some_and(|now| now.to == p.to && now.at_ms == p.at_ms) { |
| 677 | 0× | let why = format!("did not reach the hub within {} minutes", crate::update::PROVE_WITHIN.as_secs() / 60); |
| 678 | 0× | if crate::update::roll_back(&root, &p, &why).is_ok() { |
| 679 | 0× | std::process::exit(1); |
| 680 | 0× | } |
| 681 | 0× | } |
| 682 | 0× | }); |
| 683 | 0× | } |
| 684 | 0× | let served = serve_until_drained(home); |
| 685 | 0× | crate::update::forget_runner(&home.root); |
| 686 | | |
| 687 | | |
| 688 | | |
| 689 | 0× | if cfg!(windows) && served.is_ok() && LEFT_FOR_UPDATE.load(std::sync::atomic::Ordering::SeqCst) { |
| 690 | 0× | eprintln!("flow runner: left for the new build; exiting 75 so it is started"); |
| 691 | 0× | std::process::exit(75); |
| 692 | 0× | } |
| 693 | 0× | served |
| 694 | 0× | } |
| 695 | |
|
| 696 | |
|
| 697 | | static LEFT_FOR_UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); |
| 698 | |
|
| 699 | |
|
| 700 | | static UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); |
| 701 | |
|
| 702 | | #[cfg(unix)] |
| 703 | 0× | extern "C" fn on_update(_: libc::c_int) { |
| 704 | 0× | UPDATE.store(true, std::sync::atomic::Ordering::SeqCst); |
| 705 | 0× | } |
| 706 | |
|
| 707 | |
|
| 708 | |
|
| 709 | |
|
| 710 | 0× | pub fn serve_embedded(home: &Home) -> Result<()> { |
| 711 | 0× | STOPS.store(0, std::sync::atomic::Ordering::SeqCst); |
| 712 | 0× | *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = None; |
| 713 | 0× | serve_until_drained(home) |
| 714 | 0× | } |
| 715 | |
|
| 716 | |
|
| 717 | | #[derive(Clone, Debug)] |
| 718 | | struct Leave { |
| 719 | | reason: String, |
| 720 | | by: Instant, |
| 721 | | } |
| 722 | |
|
| 723 | |
|
| 724 | |
|
| 725 | |
|
| 726 | | static LEAVING: std::sync::Mutex<Option<Leave>> = std::sync::Mutex::new(None); |
| 727 | |
|
| 728 | 0× | fn leaving() -> Option<String> { |
| 729 | 0× | LEAVING.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(|l| l.reason.clone()) |
| 730 | 0× | } |
| 731 | |
|
| 732 | |
|
| 733 | |
|
| 734 | |
|
| 735 | |
|
| 736 | 0× | pub fn leave(reason: &str) { |
| 737 | 0× | leave_within(reason, Duration::ZERO); |
| 738 | 0× | } |
| 739 | |
|
| 740 | |
|
| 741 | |
|
| 742 | |
|
| 743 | |
|
| 744 | 0× | pub fn leave_within(reason: &str, notice: Duration) { |
| 745 | 0× | *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = Some(Leave { reason: reason.to_string(), by: Instant::now() + notice }); |
| 746 | 0× | stop(); |
| 747 | 0× | } |
| 748 | |
|
| 749 | |
|
| 750 | |
|
| 751 | |
|
| 752 | 1× | pub fn stop() { |
| 753 | 1× | STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
| 754 | 1× | } |
| 755 | |
|
| 756 | 0× | fn serve_until_drained(home: &Home) -> Result<()> { |
| 757 | 0× | let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?; |
| 758 | | |
| 759 | | |
| 760 | 0× | let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?; |
| 761 | 0× | for why in &missing { |
| 762 | 0× | eprintln!("flow runner: not offering {why}"); |
| 763 | 0× | } |
| 764 | 0× | let hub = Hub::new(&cfg)?; |
| 765 | 0× | let name = cfg.name.clone().unwrap_or_else(|| "flow".into()); |
| 766 | 0× | let budget = Budget::of(&cfg); |
| 767 | 0× | let key = home.runner_key(&name)?; |
| 768 | | |
| 769 | | |
| 770 | 0× | let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| { |
| 771 | 0× | eprintln!("flow runner: not joining the swarm: {e:#}"); |
| 772 | 0× | None |
| 773 | 0× | }); |
| 774 | 0× | eprintln!( |
| 775 | | "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}", |
| 776 | | cfg.hub, |
| 777 | | budget.max_jobs, |
| 778 | 0× | runtimes.join(", "), |
| 779 | 0× | key.identity.hash().to_hex() |
| 780 | | ); |
| 781 | 0× | if let Some(addr) = &cfg.metrics { |
| 782 | 0× | crate::metrics::serve(addr, cfg.metrics_token.clone(), name.clone()); |
| 783 | 0× | } |
| 784 | 0× | let used = std::sync::Mutex::new(Used::default()); |
| 785 | 0× | let mut idle = Duration::from_secs(2); |
| 786 | 0× | let pending = report_pending(home); |
| 787 | | |
| 788 | | |
| 789 | | |
| 790 | 0× | let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new()); |
| 791 | 0× | held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job)); |
| 792 | 0× | std::thread::scope(|scope| { |
| 793 | 0× | for (job, f) in pending { |
| 794 | 0× | let (hub, held) = (&hub, &held); |
| 795 | 0× | scope.spawn(move || { |
| 796 | 0× | eprintln!("job {job}: reporting the result kept from before the restart"); |
| 797 | 0× | report_finish(home, hub, job, &f); |
| 798 | 0× | held.lock().unwrap().remove(&job); |
| 799 | 0× | }); |
| 800 | | } |
| 801 | | |
| 802 | | |
| 803 | 0× | let measure = || { |
| 804 | 0× | let (speed, ms) = crate::facts::bench(); |
| 805 | 0× | eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed)); |
| 806 | 0× | speed |
| 807 | 0× | }; |
| 808 | 0× | let mut speed = measure(); |
| 809 | 0× | let mut benched = Instant::now(); |
| 810 | 0× | let mut said_draining = false; |
| 811 | | |
| 812 | | |
| 813 | 0× | let mut heard = Instant::now(); |
| 814 | 0× | let beat = |heard: &mut Instant| { |
| 815 | 0× | if heard.elapsed() >= HUB_HEARTBEAT { |
| 816 | 0× | if let Err(e) = hub.heartbeat() { |
| 817 | 0× | eprintln!("flow runner {name}: heartbeat: {e:#}"); |
| 818 | 0× | } |
| 819 | 0× | *heard = Instant::now(); |
| 820 | 0× | } |
| 821 | 0× | }; |
| 822 | 0× | let mut on_trial = crate::update::pending(&home.root).is_some(); |
| 823 | | |
| 824 | | |
| 825 | 0× | let mut update_asked: Option<Instant> = None; |
| 826 | 0× | let mut update_said = String::new(); |
| 827 | | loop { |
| 828 | 0× | if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) || crate::update::requested(&home.root) { |
| 829 | 0× | let p = crate::update::pending(&home.root); |
| 830 | 0× | let notice = Duration::from_secs(p.as_ref().map_or(0, |p| p.notice_secs)); |
| 831 | 0× | let to = p.map_or_else(|| "a new build".to_string(), |p| format!("flow {}", p.to)); |
| 832 | 0× | eprintln!("flow runner {name}: updating to {to}: leaving within {} s", notice.as_secs()); |
| 833 | 0× | LEFT_FOR_UPDATE.store(true, std::sync::atomic::Ordering::SeqCst); |
| 834 | 0× | leave_within(&format!("updating to {to}"), notice); |
| 835 | 0× | } |
| 836 | | |
| 837 | | |
| 838 | 0× | if stopping() { |
| 839 | 0× | let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len()); |
| 840 | 0× | if jobs == 0 && holding == 0 { |
| 841 | 0× | eprintln!("flow runner {name}: drained, stopping"); |
| 842 | 0× | break; |
| 843 | 0× | } |
| 844 | 0× | if !said_draining { |
| 845 | 0× | eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)"); |
| 846 | 0× | said_draining = true; |
| 847 | 0× | } |
| 848 | 0× | beat(&mut heard); |
| 849 | 0× | std::thread::sleep(Duration::from_millis(500)); |
| 850 | 0× | continue; |
| 851 | 0× | } |
| 852 | 0× | if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) { |
| 853 | 0× | speed = measure(); |
| 854 | 0× | benched = Instant::now(); |
| 855 | 0× | } |
| 856 | 0× | let room = budget.room(*used.lock().unwrap()); |
| 857 | 0× | let Some((free_memory_mib, free_cpus)) = room else { |
| 858 | 0× | beat(&mut heard); |
| 859 | 0× | std::thread::sleep(Duration::from_millis(500)); |
| 860 | 0× | continue; |
| 861 | | }; |
| 862 | 0× | heard = Instant::now(); |
| 863 | 0× | let cache_times = crate::cache::Pool::new(&home.root, cfg.cache_gb).slot_times(); |
| 864 | 0× | let req = ClaimRequest { |
| 865 | 0× | runner: name.clone(), |
| 866 | 0× | runtimes: runtimes.clone(), |
| 867 | 0× | free_memory_mib, |
| 868 | 0× | free_cpus, |
| 869 | 0× | info: Some(RunnerInfo { |
| 870 | 0× | version: env!("OXY_VERSION_LONG").into(), |
| 871 | 0× | ..RunnerInfo::here(cfg.offered_labels(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus) |
| 872 | 0× | }), |
| 873 | | |
| 874 | 0× | images: Some(home.images()), |
| 875 | 0× | image_archs: Some(home.image_archs()), |
| 876 | 0× | holding: Some(held.lock().unwrap().iter().copied().collect()), |
| 877 | | fetches_images: true, |
| 878 | 0× | facts: Some(crate::facts::now(home, Some(speed))), |
| 879 | 0× | swarm: swarm.as_ref().map(|s| s.addr.clone()), |
| 880 | 0× | caches: Some(cache_times.keys().cloned().collect()), |
| 881 | 0× | cache_times: Some(cache_times), |
| 882 | | }; |
| 883 | 0× | let answer = hub.claim(&req); |
| 884 | 0× | if on_trial && answer.is_ok() { |
| 885 | 0× | crate::update::proven(&home.root); |
| 886 | 0× | on_trial = false; |
| 887 | 0× | } |
| 888 | 0× | if answer.is_ok() && update_asked.is_none_or(|t| t.elapsed() >= UPDATE_EVERY) { |
| 889 | 0× | update_asked = Some(Instant::now()); |
| 890 | 0× | if let Some(note) = crate::update::take_note(&home.root) { |
| 891 | 0× | let _ = hub.report_update(¬e); |
| 892 | 0× | } |
| 893 | 0× | consider_update(home, &hub, &mut update_said); |
| 894 | 0× | } |
| 895 | 0× | match answer { |
| 896 | 0× | Ok(Some(claim)) => { |
| 897 | 0× | idle = Duration::from_secs(2); |
| 898 | 0× | crate::metrics::CLAIMS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| 899 | 0× | let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus); |
| 900 | 0× | { |
| 901 | 0× | let mut u = used.lock().unwrap(); |
| 902 | 0× | u.jobs += 1; |
| 903 | 0× | u.memory_mib += mem; |
| 904 | 0× | u.cpus += cpus; |
| 905 | 0× | } |
| 906 | 0× | held.lock().unwrap().insert(claim.job_id); |
| 907 | 0× | let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref()); |
| 908 | 0× | scope.spawn(move || { |
| 909 | 0× | let id = claim.job_id; |
| 910 | 0× | fetch_image(home, hub, cfg, swarm, &claim); |
| 911 | 0× | if let Some(why) = preflight(home, &claim) { |
| 912 | 0× | eprintln!("job {id}: declined: {why}"); |
| 913 | 0× | crate::metrics::DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| 914 | 0× | if let Err(e) = hub.decline(id, &why) { |
| 915 | 0× | eprintln!("job {id}: decline: {e:#}"); |
| 916 | 0× | } |
| 917 | 0× | held.lock().unwrap().remove(&id); |
| 918 | 0× | let mut u = used.lock().unwrap(); |
| 919 | 0× | u.jobs -= 1; |
| 920 | 0× | u.memory_mib -= mem; |
| 921 | 0× | u.cpus -= cpus; |
| 922 | 0× | return; |
| 923 | 0× | } |
| 924 | 0× | eprintln!( |
| 925 | | "job {id}: {} {} on {}", |
| 926 | | claim.repo, |
| 927 | | claim.job.name, |
| 928 | 0× | claim.heads.first().map(|h| &h[..12]).unwrap_or("-") |
| 929 | | ); |
| 930 | 0× | crate::metrics::RUNNING.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| 931 | 0× | let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish { |
| 932 | 0× | exit_code: None, |
| 933 | | duration_ms: 0, |
| 934 | 0× | receipt: None, |
| 935 | 0× | signed_receipt: None, |
| 936 | 0× | error: Some(format!("{e:#}")), |
| 937 | 0× | peak_disk_mib: None, |
| 938 | 0× | peak_memory_mib: None, |
| 939 | 0× | }); |
| 940 | 0× | eprintln!( |
| 941 | | "job {id}: exit {:?}{}", |
| 942 | | finish.exit_code, |
| 943 | 0× | finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default() |
| 944 | | ); |
| 945 | 0× | crate::metrics::RUNNING.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); |
| 946 | 0× | let cancelled = finish.error.as_deref() == Some(CANCELLED); |
| 947 | 0× | crate::metrics::finished(finish.exit_code, finish.error.as_deref(), finish.duration_ms, cancelled); |
| 948 | 0× | match leaving() { |
| 949 | | |
| 950 | 0× | Some(why) if cancelled => { |
| 951 | 0× | eprintln!("job {id}: handed back: {why}"); |
| 952 | 0× | if let Err(e) = hub.release(id, &why) { |
| 953 | 0× | eprintln!("job {id}: handing back: {e:#}; the hub requeues it once this runner's lease lapses"); |
| 954 | 0× | } |
| 955 | | } |
| 956 | 0× | _ if !cancelled => report_finish(home, hub, id, &finish), |
| 957 | 0× | _ => {} |
| 958 | | } |
| 959 | 0× | held.lock().unwrap().remove(&id); |
| 960 | 0× | let mut u = used.lock().unwrap(); |
| 961 | 0× | u.jobs -= 1; |
| 962 | 0× | u.memory_mib -= mem; |
| 963 | 0× | u.cpus -= cpus; |
| 964 | 0× | }); |
| 965 | | } |
| 966 | 0× | Ok(None) => std::thread::sleep(idle), |
| 967 | 0× | Err(e) => { |
| 968 | 0× | eprintln!("claim: {e:#}"); |
| 969 | 0× | std::thread::sleep(idle); |
| 970 | 0× | idle = (idle * 2).min(Duration::from_secs(60)); |
| 971 | 0× | } |
| 972 | | } |
| 973 | | } |
| 974 | 0× | Ok(()) |
| 975 | 0× | }) |
| 976 | 0× | } |
| 977 | |
|
| 978 | |
|
| 979 | |
|
| 980 | |
|
| 981 | | #[allow(clippy::too_many_arguments)] |
| 982 | 0× | fn save_checkpoint(hub: &Hub, id: i64, job: &flow_core::Job, scratch: &Path, dir_tier: bool, wasm: bool, mut say: impl FnMut(String)) { |
| 983 | 0× | if !dir_tier { |
| 984 | 0× | say("checkpoint: only the WASM and syscall tiers save one yet; the next attempt starts again".into()); |
| 985 | 0× | return; |
| 986 | 0× | } |
| 987 | 0× | let dirs: Vec<(String, PathBuf)> = job |
| 988 | 0× | .checkpoints |
| 989 | 0× | .iter() |
| 990 | 0× | .filter_map(|c| crate::checkpoint::host_dir(c, job, &scratch.join("tree"), scratch, wasm).map(|d| (c.clone(), d))) |
| 991 | 0× | .collect(); |
| 992 | 0× | match crate::checkpoint::pack(&dirs).and_then(|(bytes, n)| Ok((hub.save_checkpoint(id, &bytes)?, n))) { |
| 993 | 0× | Ok((blob, n)) => { |
| 994 | 0× | say(format!("checkpoint {}: {} files, {} bytes, kept for the next attempt", &blob[..12.min(blob.len())], n.files, n.bytes)) |
| 995 | | } |
| 996 | 0× | Err(e) => say(format!("checkpoint: not kept ({e:#}); the next attempt starts again")), |
| 997 | | } |
| 998 | 0× | } |
| 999 | |
|
| 1000 | |
|
| 1001 | | static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); |
| 1002 | |
|
| 1003 | 1× | extern "C" fn on_stop(_: libc::c_int) { |
| 1004 | | |
| 1005 | 1× | if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 { |
| 1006 | 0× | unsafe { libc::_exit(143) }; |
| 1007 | 1× | } |
| 1008 | 1× | } |
| 1009 | |
|
| 1010 | |
|
| 1011 | |
|
| 1012 | |
|
| 1013 | 0× | fn drain_on_stop() { |
| 1014 | 0× | let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t; |
| 1015 | 0× | unsafe { |
| 1016 | 0× | libc::signal(libc::SIGTERM, handler); |
| 1017 | 0× | libc::signal(libc::SIGINT, handler); |
| 1018 | 0× | } |
| 1019 | 0× | } |
| 1020 | |
|
| 1021 | 3× | fn stopping() -> bool { |
| 1022 | 3× | STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0 |
| 1023 | 3× | } |
| 1024 | |
|
| 1025 | |
|
| 1026 | |
|
| 1027 | |
|
| 1028 | | struct LogSink<'a> { |
| 1029 | | hub: &'a Hub, |
| 1030 | | job: i64, |
| 1031 | | buf: Vec<Record>, |
| 1032 | | last: Instant, |
| 1033 | | gone: bool, |
| 1034 | | |
| 1035 | | |
| 1036 | | leaving: &'a std::sync::Mutex<Option<Leave>>, |
| 1037 | | |
| 1038 | | started: Instant, |
| 1039 | | expected: Option<Duration>, |
| 1040 | | } |
| 1041 | |
|
| 1042 | |
|
| 1043 | | const BATCH: usize = 200; |
| 1044 | |
|
| 1045 | |
|
| 1046 | | const MAX_UNSENT: usize = 50_000; |
| 1047 | |
|
| 1048 | |
|
| 1049 | | const HEARTBEAT: Duration = Duration::from_secs(5); |
| 1050 | |
|
| 1051 | | impl microvm::Sink for LogSink<'_> { |
| 1052 | 1× | fn record(&mut self, r: Record) { |
| 1053 | 1× | self.push(r) |
| 1054 | 1× | } |
| 1055 | |
|
| 1056 | 10× | fn cancelled(&mut self) -> bool { |
| 1057 | 10× | let leave = self.leaving.lock().unwrap_or_else(|p| p.into_inner()).clone(); |
| 1058 | 10× | if let Some(l) = leave { |
| 1059 | | |
| 1060 | | |
| 1061 | 5× | let now = Instant::now(); |
| 1062 | 5× | let fits = self.expected.is_some_and(|e| now + e.saturating_sub(self.started.elapsed()) <= l.by); |
| 1063 | 5× | if !fits || now >= l.by { |
| 1064 | 4× | self.flush(); |
| 1065 | 4× | return true; |
| 1066 | 1× | } |
| 1067 | 5× | } |
| 1068 | 6× | if !self.gone && self.last.elapsed() >= HEARTBEAT { |
| 1069 | 2× | self.send(); |
| 1070 | 4× | } |
| 1071 | 6× | self.gone |
| 1072 | 10× | } |
| 1073 | | } |
| 1074 | |
|
| 1075 | | impl LogSink<'_> { |
| 1076 | 1× | fn push(&mut self, mut r: Record) { |
| 1077 | 1× | if self.gone { |
| 1078 | 1× | return; |
| 1079 | 0× | } |
| 1080 | 0× | if r.text.len() > MAX_RECORD_TEXT { |
| 1081 | 0× | let mut cut = MAX_RECORD_TEXT; |
| 1082 | 0× | while !r.text.is_char_boundary(cut) { |
| 1083 | 0× | cut -= 1; |
| 1084 | 0× | } |
| 1085 | 0× | r.text.truncate(cut); |
| 1086 | 0× | } |
| 1087 | 0× | self.buf.push(r); |
| 1088 | 0× | if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) { |
| 1089 | 0× | self.flush(); |
| 1090 | 0× | } |
| 1091 | 1× | } |
| 1092 | |
|
| 1093 | 5× | fn flush(&mut self) { |
| 1094 | 5× | if !self.buf.is_empty() { |
| 1095 | 1× | self.send(); |
| 1096 | 4× | } |
| 1097 | 5× | self.last = Instant::now(); |
| 1098 | 5× | } |
| 1099 | |
|
| 1100 | | |
| 1101 | | |
| 1102 | 6× | fn send(&mut self) { |
| 1103 | | loop { |
| 1104 | 8× | if self.gone { |
| 1105 | 0× | return; |
| 1106 | 8× | } |
| 1107 | 8× | let n = self.buf.len().min(BATCH); |
| 1108 | 8× | let batch: Vec<Record> = self.buf.drain(..n).collect(); |
| 1109 | 8× | match self.hub.log(self.job, batch.clone()) { |
| 1110 | 5× | Ok(()) => {} |
| 1111 | 3× | Err(e) if job_gone(&e) => self.gone = true, |
| 1112 | 2× | Err(e) => { |
| 1113 | | |
| 1114 | | |
| 1115 | | |
| 1116 | 2× | eprintln!("job {}: log: {e:#}; kept for the next batch", self.job); |
| 1117 | 2× | let mut kept = batch; |
| 1118 | 2× | kept.append(&mut self.buf); |
| 1119 | 2× | let over = kept.len().saturating_sub(MAX_UNSENT); |
| 1120 | 2× | if over > 0 { |
| 1121 | 1× | kept.drain(..over); |
| 1122 | 1× | kept.insert( |
| 1123 | 1× | 0, |
| 1124 | 1× | Record { |
| 1125 | 1× | t: 0, |
| 1126 | 1× | stream: "flow".into(), |
| 1127 | 1× | text: format!("({over} log lines lost while the hub could not be reached)"), |
| 1128 | 1× | }, |
| 1129 | 1× | ); |
| 1130 | 1× | } |
| 1131 | 2× | self.buf = kept; |
| 1132 | 2× | break; |
| 1133 | | } |
| 1134 | | } |
| 1135 | 6× | if self.buf.is_empty() { |
| 1136 | 4× | break; |
| 1137 | 2× | } |
| 1138 | | } |
| 1139 | 6× | self.last = Instant::now(); |
| 1140 | 6× | } |
| 1141 | | } |
| 1142 | |
|
| 1143 | |
|
| 1144 | |
|
| 1145 | | const HUB_HEARTBEAT: Duration = Duration::from_secs(30); |
| 1146 | |
|
| 1147 | |
|
| 1148 | | const UPDATE_EVERY: Duration = Duration::from_secs(15 * 60); |
| 1149 | |
|
| 1150 | |
|
| 1151 | | const UPDATE_NOTICE: Duration = Duration::from_secs(600); |
| 1152 | |
|
| 1153 | |
|
| 1154 | |
|
| 1155 | |
|
| 1156 | |
|
| 1157 | 0× | fn consider_update(home: &Home, hub: &Hub, said: &mut String) { |
| 1158 | 0× | let answer = match hub.update_answer() { |
| 1159 | 0× | Ok(Some(a)) => a, |
| 1160 | 0× | Ok(None) => return, |
| 1161 | 0× | Err(e) => { |
| 1162 | 0× | eprintln!("flow runner: asking for an update: {e:#}"); |
| 1163 | 0× | return; |
| 1164 | | } |
| 1165 | | }; |
| 1166 | 0× | let Some(offer) = answer.offer else { return }; |
| 1167 | 0× | let mut say = |note: String| { |
| 1168 | 0× | if *said != note { |
| 1169 | 0× | eprintln!("flow runner: {note}"); |
| 1170 | 0× | if let Err(e) = hub.report_update(¬e) { |
| 1171 | 0× | eprintln!("flow runner: telling the hub: {e:#}"); |
| 1172 | 0× | } |
| 1173 | 0× | *said = note; |
| 1174 | 0× | } |
| 1175 | 0× | }; |
| 1176 | 0× | if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) { |
| 1177 | 0× | return say(format!("refuses flow {}: flow.toml says [runner.update] allow = false", offer.version)); |
| 1178 | 0× | } |
| 1179 | 0× | let dir = home.root.join("updates"); |
| 1180 | 0× | let file = dir.join(format!("flow-{}", offer.version)); |
| 1181 | 0× | let fresh = std::fs::read(&file).is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == offer.blake3); |
| 1182 | 0× | if !fresh { |
| 1183 | 0× | if let Err(e) = std::fs::create_dir_all(&dir).map_err(anyhow::Error::from).and_then(|_| hub.download_build(&offer, &file)) { |
| 1184 | 0× | return say(format!("flow {}: not fetched: {e:#}", offer.version)); |
| 1185 | 0× | } |
| 1186 | 0× | } |
| 1187 | 0× | let exe = match std::env::current_exe() { |
| 1188 | 0× | Ok(e) => e, |
| 1189 | 0× | Err(e) => return say(format!("flow {}: cannot find its own binary: {e}", offer.version)), |
| 1190 | | }; |
| 1191 | 0× | match crate::update::install(home, &exe, &file, UPDATE_NOTICE, answer.policy.dry_run, offer.older) { |
| 1192 | 0× | Ok(done) if answer.policy.dry_run => say(format!("dry run: flow {} checks out here and would replace flow {}", done.to, done.from)), |
| 1193 | 0× | Ok(done) => { |
| 1194 | 0× | let _ = std::fs::remove_file(&file); |
| 1195 | 0× | say(format!("installed flow {} (was {}); leaving within {} s to start it", done.to, done.from, UPDATE_NOTICE.as_secs())) |
| 1196 | | } |
| 1197 | 0× | Err(e) => say(format!("flow {}: not installed: {e:#}", offer.version)), |
| 1198 | | } |
| 1199 | 0× | } |
| 1200 | |
|
| 1201 | |
|
| 1202 | |
|
| 1203 | | static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 1204 | |
|
| 1205 | |
|
| 1206 | |
|
| 1207 | | static CLONE: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 1208 | |
|
| 1209 | |
|
| 1210 | |
|
| 1211 | |
|
| 1212 | |
|
| 1213 | 0× | fn repo_lock(name: &str) -> std::sync::Arc<std::sync::Mutex<()>> { |
| 1214 | | static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>> = |
| 1215 | | std::sync::OnceLock::new(); |
| 1216 | 0× | let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner()); |
| 1217 | 0× | locks.entry(name.to_string()).or_default().clone() |
| 1218 | 0× | } |
| 1219 | |
|
| 1220 | |
|
| 1221 | 0× | fn secs(d: std::time::Duration) -> String { |
| 1222 | 0× | format!("{:.1} s", d.as_secs_f64()) |
| 1223 | 0× | } |
| 1224 | |
|
| 1225 | 0× | fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> { |
| 1226 | 0× | let started = Instant::now(); |
| 1227 | 0× | let mut sink = LogSink { |
| 1228 | 0× | hub, |
| 1229 | 0× | job: claim.job_id, |
| 1230 | 0× | buf: Vec::new(), |
| 1231 | 0× | last: Instant::now(), |
| 1232 | 0× | gone: false, |
| 1233 | 0× | leaving: &LEAVING, |
| 1234 | 0× | started: Instant::now(), |
| 1235 | 0× | expected: claim.expected_ms.map(Duration::from_millis), |
| 1236 | 0× | }; |
| 1237 | 0× | let note = |
| 1238 | 0× | |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text }); |
| 1239 | 0× | let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?; |
| 1240 | 0× | note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"))); |
| 1241 | | |
| 1242 | | |
| 1243 | | |
| 1244 | | |
| 1245 | | |
| 1246 | 0× | let main_lock = repo_lock(&claim.repo); |
| 1247 | 0× | let asked = Instant::now(); |
| 1248 | 0× | let mut main_guard = Some(main_lock.lock().unwrap_or_else(|p| p.into_inner())); |
| 1249 | 0× | if asked.elapsed().as_secs() >= 2 { |
| 1250 | 0× | note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), claim.repo)); |
| 1251 | 0× | } |
| 1252 | 0× | let fetching = Instant::now(); |
| 1253 | 0× | let repo = cached_repo(home, cfg, claim)?; |
| 1254 | 0× | let fetched = fetching.elapsed(); |
| 1255 | 0× | let mut preparing = None; |
| 1256 | 0× | let scratch = home.root.join("work").join(format!("job-{}", claim.job_id)); |
| 1257 | 0× | let _ = std::fs::remove_dir_all(&scratch); |
| 1258 | 0× | std::fs::create_dir_all(scratch.join("tree"))?; |
| 1259 | 0× | let result = (|| -> Result<Finish> { |
| 1260 | 0× | let exporting = Instant::now(); |
| 1261 | 0× | let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?; |
| 1262 | 0× | main_guard.take(); |
| 1263 | 0× | note(&mut sink, format!("fetched in {}, {} files exported in {}", secs(fetched), files.len(), secs(exporting.elapsed()))); |
| 1264 | | |
| 1265 | | |
| 1266 | 0× | for sub in &claim.subrepos { |
| 1267 | 0× | if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") { |
| 1268 | 0× | bail!("subrepo path {:?} is not a clean relative path", sub.path); |
| 1269 | 0× | } |
| 1270 | 0× | note( |
| 1271 | 0× | &mut sink, |
| 1272 | 0× | format!( |
| 1273 | | "fetching subrepo {} at {} for {}", |
| 1274 | | sub.repo, |
| 1275 | 0× | sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"), |
| 1276 | | sub.path |
| 1277 | | ), |
| 1278 | | ); |
| 1279 | 0× | let sub_lock = repo_lock(&sub.repo); |
| 1280 | 0× | let asked = Instant::now(); |
| 1281 | 0× | let sub_guard = sub_lock.lock().unwrap_or_else(|p| p.into_inner()); |
| 1282 | 0× | if asked.elapsed().as_secs() >= 2 { |
| 1283 | 0× | note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), sub.repo)); |
| 1284 | 0× | } |
| 1285 | 0× | let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?; |
| 1286 | 0× | let sub_heads: Vec<ChangeHash> = |
| 1287 | 0× | sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?; |
| 1288 | 0× | let dir = scratch.join("tree").join(&sub.path); |
| 1289 | 0× | std::fs::create_dir_all(&dir)?; |
| 1290 | 0× | let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?; |
| 1291 | 0× | drop(sub_guard); |
| 1292 | 0× | files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h))); |
| 1293 | | } |
| 1294 | 0× | let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h))); |
| 1295 | | |
| 1296 | | |
| 1297 | | |
| 1298 | 0× | let mut input_blobs = Vec::new(); |
| 1299 | 0× | for (index, i) in claim.inputs.iter().enumerate() { |
| 1300 | 0× | let what = format!("input {} from {}", i.artifact, i.from); |
| 1301 | 0× | let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?; |
| 1302 | 0× | let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?; |
| 1303 | 0× | note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12])); |
| 1304 | 0× | input_blobs.push(blob); |
| 1305 | | } |
| 1306 | | |
| 1307 | 0× | let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall; |
| 1308 | 0× | let wasm_tier = claim.sandbox.runtime == flow_core::Runtime::Wasm; |
| 1309 | | |
| 1310 | | |
| 1311 | 0× | let dir_tier = syscall_tier || wasm_tier; |
| 1312 | 0× | let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env()); |
| 1313 | | |
| 1314 | | |
| 1315 | | |
| 1316 | | |
| 1317 | | |
| 1318 | 0× | let asked = Instant::now(); |
| 1319 | 0× | preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner())); |
| 1320 | 0× | if asked.elapsed().as_secs() >= 2 { |
| 1321 | 0× | note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed()))); |
| 1322 | 0× | } |
| 1323 | 0× | let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb); |
| 1324 | 0× | let form = if dir_tier { crate::cache::Form::Dir } else { crate::cache::Form::Image }; |
| 1325 | 0× | let cache_ctx = |
| 1326 | 0× | crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs, form }; |
| 1327 | 0× | let mut cached: Vec<(usize, String, String)> = Vec::new(); |
| 1328 | 0× | let mut built_from: Option<crate::cache::Sources> = None; |
| 1329 | 0× | for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() { |
| 1330 | 0× | let Some(name) = &m.cache else { continue }; |
| 1331 | 0× | let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h))); |
| 1332 | 0× | let how = match pool.take(&cache_ctx, m, &key, &crate::cache::disk_path(form, &scratch, i)) { |
| 1333 | 0× | Some(t) => { |
| 1334 | 0× | if built_from.is_none() { |
| 1335 | 0× | built_from = t.sources; |
| 1336 | 0× | } |
| 1337 | 0× | t.how |
| 1338 | | } |
| 1339 | 0× | None => format!("cache {name}: none kept yet, an empty disk"), |
| 1340 | | }; |
| 1341 | 0× | note(&mut sink, format!("{} ({})", how, m.target)); |
| 1342 | | |
| 1343 | 0× | if let Err(e) = crate::cache::forget_reads(form, &crate::cache::disk_path(form, &scratch, i)) { |
| 1344 | 0× | note(&mut sink, format!("cache {name}: {e:#}")); |
| 1345 | 0× | } |
| 1346 | 0× | cached.push((i, key, how)); |
| 1347 | | } |
| 1348 | | |
| 1349 | | |
| 1350 | 0× | let cache_started = if cached.is_empty() { std::time::SystemTime::now() } else { flow_init::prune::start_clock() }; |
| 1351 | 0× | let sources = if cached.is_empty() { |
| 1352 | 0× | crate::cache::Sources::new() |
| 1353 | | } else { |
| 1354 | 0× | let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref()); |
| 1355 | 0× | if built_from.is_some() { |
| 1356 | 0× | note( |
| 1357 | 0× | &mut sink, |
| 1358 | 0× | format!( |
| 1359 | 0× | "cache: {restored} of {} files and directories unchanged since the cached build, their times kept", |
| 1360 | 0× | sources.len() |
| 1361 | 0× | ), |
| 1362 | 0× | ); |
| 1363 | 0× | } |
| 1364 | 0× | sources |
| 1365 | | }; |
| 1366 | | |
| 1367 | | |
| 1368 | | |
| 1369 | 0× | if let Some(blob) = &claim.checkpoint { |
| 1370 | 0× | if dir_tier { |
| 1371 | 0× | let bytes = hub.checkpoint(claim.job_id).context("fetching the checkpoint")?; |
| 1372 | 0× | if Repo::blob_ref(&bytes).to_hex() != *blob { |
| 1373 | 0× | bail!("the checkpoint fetched is not blob {}, as the claim names", &blob[..12.min(blob.len())]); |
| 1374 | 0× | } |
| 1375 | 0× | let place = |root: &str| crate::checkpoint::host_dir(root, &job, &scratch.join("tree"), &scratch, wasm_tier); |
| 1376 | 0× | let n = crate::checkpoint::unpack(&bytes, &job.checkpoints, place)?; |
| 1377 | 0× | note( |
| 1378 | 0× | &mut sink, |
| 1379 | 0× | format!("restored checkpoint {}: {} files, {} bytes, from the attempt handed back", &blob[..12], n.files, n.bytes), |
| 1380 | 0× | ); |
| 1381 | 0× | input_blobs.push(Repo::blob_ref(&bytes).0); |
| 1382 | 0× | } else { |
| 1383 | 0× | note(&mut sink, "a checkpoint was saved, but only the WASM and syscall tiers restore one yet: starting again".into()); |
| 1384 | 0× | } |
| 1385 | 0× | } |
| 1386 | 0× | if !dir_tier { |
| 1387 | 0× | workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?; |
| 1388 | 0× | std::fs::remove_dir_all(scratch.join("tree"))?; |
| 1389 | 0× | } |
| 1390 | 0× | preparing.take(); |
| 1391 | | |
| 1392 | 0× | if microvm::Sink::cancelled(&mut sink) { |
| 1393 | 0× | return Ok(cancelled()); |
| 1394 | 0× | } |
| 1395 | 0× | if wasm_tier { |
| 1396 | 0× | note(&mut sink, format!("starting WASM (wasmtime {}): {} MiB", crate::wasm::WASMTIME, claim.sandbox.memory_mib)); |
| 1397 | 0× | } else { |
| 1398 | 0× | note( |
| 1399 | 0× | &mut sink, |
| 1400 | 0× | format!( |
| 1401 | | "{}: {} {}, {} MiB, rootfs {}", |
| 1402 | 0× | if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" }, |
| 1403 | | claim.sandbox.cpus, |
| 1404 | 0× | if syscall_tier { "CPU" } else { "vCPU" }, |
| 1405 | | claim.sandbox.memory_mib, |
| 1406 | 0× | &claim.sandbox.rootfs[..12] |
| 1407 | | ), |
| 1408 | | ); |
| 1409 | | } |
| 1410 | 0× | let timeout = Duration::from_secs(claim.timeout_s); |
| 1411 | 0× | let mut outcome = if wasm_tier { |
| 1412 | 0× | crate::wasm::run(&claim.sandbox, &job, &scratch.join("tree"), &scratch, timeout, &mut sink)? |
| 1413 | 0× | } else if syscall_tier { |
| 1414 | 0× | let group = format!("job-{}", claim.job_id); |
| 1415 | 0× | crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)? |
| 1416 | | } else { |
| 1417 | 0× | microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)? |
| 1418 | | }; |
| 1419 | 0× | if outcome.cancelled { |
| 1420 | | |
| 1421 | | |
| 1422 | 0× | if leaving().is_some() && !job.checkpoints.is_empty() { |
| 1423 | 0× | save_checkpoint(hub, claim.job_id, &job, &scratch, dir_tier, wasm_tier, |t| note(&mut sink, t)); |
| 1424 | 0× | } |
| 1425 | 0× | return Ok(cancelled()); |
| 1426 | 0× | } |
| 1427 | | |
| 1428 | 0× | let disk_mounts: Vec<&flow_core::pipeline::Mount> = |
| 1429 | 0× | job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect(); |
| 1430 | 0× | for (i, key, how) in &cached { |
| 1431 | 0× | outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}"))); |
| 1432 | 0× | } |
| 1433 | | |
| 1434 | | |
| 1435 | | |
| 1436 | | |
| 1437 | 0× | let mut outputs = vec![]; |
| 1438 | 0× | let mut output_error = None; |
| 1439 | 0× | let passed = outcome.exit_code == Some(0); |
| 1440 | 0× | let wanted = claim.job.outputs.iter().filter(|o| passed || o.always); |
| 1441 | 0× | for o in wanted { |
| 1442 | 0× | if o.kind == flow_core::OutputKind::Report { |
| 1443 | 0× | let read = if dir_tier { |
| 1444 | 0× | let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
| 1445 | 0× | crate::outputs::read_report_dir(&dir, &inner) |
| 1446 | | } else { |
| 1447 | 0× | let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
| 1448 | 0× | crate::outputs::read_report(&image, &inner, &scratch) |
| 1449 | | }; |
| 1450 | 0× | let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files)); |
| 1451 | 0× | match kept { |
| 1452 | 0× | Ok((blob, files, bytes)) => { |
| 1453 | 0× | let unattested = if passed { "" } else { " (the job failed: not in its receipt)" }; |
| 1454 | 0× | let hex = blob.to_hex(); |
| 1455 | 0× | note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12])); |
| 1456 | 0× | if passed { |
| 1457 | 0× | outputs.push(blob.0); |
| 1458 | 0× | } |
| 1459 | | } |
| 1460 | 0× | Err(e) if passed => { |
| 1461 | 0× | output_error = Some(format!("report {}: {e:#}", o.name)); |
| 1462 | 0× | break; |
| 1463 | | } |
| 1464 | 0× | Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)), |
| 1465 | | } |
| 1466 | 0× | continue; |
| 1467 | 0× | } |
| 1468 | 0× | let read = if dir_tier { |
| 1469 | 0× | let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
| 1470 | 0× | crate::outputs::read_dir_file(&dir, &inner) |
| 1471 | | } else { |
| 1472 | 0× | let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
| 1473 | 0× | crate::outputs::read(&image, &inner, &scratch) |
| 1474 | | }; |
| 1475 | 0× | let kept = read.and_then(|bytes| { |
| 1476 | 0× | let blob = Repo::blob_ref(&bytes); |
| 1477 | 0× | let named = hub.artifact(claim.job_id, &o.name, &bytes)?; |
| 1478 | 0× | if named != blob.to_hex() { |
| 1479 | 0× | bail!("the hub kept it as {named}, not {}", blob.to_hex()); |
| 1480 | 0× | } |
| 1481 | 0× | let unattested = if passed { "" } else { " (the job failed: not in its receipt)" }; |
| 1482 | 0× | note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12])); |
| 1483 | 0× | Ok(blob.0) |
| 1484 | 0× | }); |
| 1485 | 0× | match kept { |
| 1486 | 0× | Ok(b) if passed => outputs.push(b), |
| 1487 | 0× | Ok(_) => {} |
| 1488 | 0× | Err(e) if passed => { |
| 1489 | 0× | output_error = Some(format!("artifact {}: {e:#}", o.name)); |
| 1490 | 0× | break; |
| 1491 | | } |
| 1492 | 0× | Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)), |
| 1493 | | } |
| 1494 | | } |
| 1495 | 0× | if let Some(e) = &output_error { |
| 1496 | 0× | note(&mut sink, e.clone()); |
| 1497 | 0× | } |
| 1498 | 0× | let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt { |
| 1499 | 0× | job_name: claim.job.name.clone(), |
| 1500 | 0× | pipeline_hash: hex32(&claim.pipeline_hash), |
| 1501 | 0× | scope_tree_hash: scope_hash, |
| 1502 | 0× | inputs: input_blobs.clone(), |
| 1503 | 0× | environment: outcome.environment.hash(), |
| 1504 | 0× | outputs, |
| 1505 | 0× | exit_code: code as u32, |
| 1506 | 0× | duration_ms: outcome.duration_ms, |
| 1507 | 0× | }); |
| 1508 | 0× | let signed_receipt = match &receipt { |
| 1509 | 0× | Some(r) => { |
| 1510 | 0× | let mut signed = sign_receipt(&repo, &heads, cfg, key, claim, r)?; |
| 1511 | 0× | if cfg.keyless { |
| 1512 | 0× | match sign_keyless(&repo, &heads, cfg, hub, claim, r) { |
| 1513 | 0× | Ok(k) => signed = k, |
| 1514 | 0× | Err(e) => { |
| 1515 | 0× | note(&mut sink, format!("receipt: signed with the runner's key; a short-lived key was not certified: {e:#}")) |
| 1516 | | } |
| 1517 | | } |
| 1518 | 0× | } |
| 1519 | 0× | Some(hex::encode(signed.encode())) |
| 1520 | | } |
| 1521 | 0× | None => None, |
| 1522 | | }; |
| 1523 | | |
| 1524 | 0× | let peak_disk_mib = crate::facts::allocated_mib(&scratch); |
| 1525 | | |
| 1526 | | |
| 1527 | | |
| 1528 | 0× | let kept = passed && output_error.is_none(); |
| 1529 | 0× | for (i, key, _) in &cached { |
| 1530 | 0× | let disk = crate::cache::disk_path(form, &scratch, *i); |
| 1531 | 0× | let m = disk_mounts[*i]; |
| 1532 | 0× | let prune = crate::cache::Prune { |
| 1533 | 0× | started: cache_started, |
| 1534 | 0× | percent: cfg.cache_prune_percent.unwrap_or(flow_init::prune::DEFAULT_PERCENT), |
| 1535 | 0× | guest: outcome.pruned.iter().find(|(t, _)| *t == m.target).map(|(_, p)| p), |
| 1536 | | }; |
| 1537 | 0× | match pool.settle(&cache_ctx, m, key, &disk, &sources, kept, &prune) { |
| 1538 | 0× | Ok(Some(line)) => note(&mut sink, line), |
| 1539 | 0× | Ok(None) => {} |
| 1540 | 0× | Err(e) => note(&mut sink, format!("cache: not {}: {e:#}", if kept { "kept" } else { "discarded" })), |
| 1541 | | } |
| 1542 | | } |
| 1543 | | Ok(Finish { |
| 1544 | 0× | exit_code: outcome.exit_code, |
| 1545 | 0× | duration_ms: started.elapsed().as_millis() as u64, |
| 1546 | 0× | receipt, |
| 1547 | 0× | signed_receipt, |
| 1548 | 0× | error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())), |
| 1549 | | |
| 1550 | | |
| 1551 | 0× | peak_disk_mib, |
| 1552 | 0× | peak_memory_mib: outcome.peak_memory_mib, |
| 1553 | | }) |
| 1554 | | })(); |
| 1555 | 0× | let _ = workspace::remove_tree(&scratch); |
| 1556 | 0× | sink.flush(); |
| 1557 | 0× | result |
| 1558 | 0× | } |
| 1559 | |
|
| 1560 | |
|
| 1561 | |
|
| 1562 | |
|
| 1563 | |
|
| 1564 | |
|
| 1565 | |
|
| 1566 | 0× | fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> { |
| 1567 | 0× | let mut listed = Vec::with_capacity(files.len()); |
| 1568 | 0× | let mut total = 0u64; |
| 1569 | 0× | for (path, bytes) in files { |
| 1570 | 0× | let blob = Repo::blob_ref(bytes).to_hex(); |
| 1571 | 0× | let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?; |
| 1572 | 0× | if named != blob { |
| 1573 | 0× | bail!("the hub kept {path} as {named}, not {blob}"); |
| 1574 | 0× | } |
| 1575 | 0× | total += bytes.len() as u64; |
| 1576 | 0× | listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() })); |
| 1577 | | } |
| 1578 | 0× | let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?; |
| 1579 | 0× | let blob = Repo::blob_ref(&manifest); |
| 1580 | 0× | let named = hub.artifact(job, name, &manifest)?; |
| 1581 | 0× | if named != blob.to_hex() { |
| 1582 | 0× | bail!("the hub kept the manifest as {named}, not {}", blob.to_hex()); |
| 1583 | 0× | } |
| 1584 | 0× | Ok((blob, files.len(), total)) |
| 1585 | 0× | } |
| 1586 | |
|
| 1587 | 0× | fn sign_receipt( |
| 1588 | 0× | repo: &Repo, |
| 1589 | 0× | heads: &[ChangeHash], |
| 1590 | 0× | cfg: &RunnerConfig, |
| 1591 | 0× | key: &RunnerKey, |
| 1592 | 0× | claim: &Claim, |
| 1593 | 0× | receipt: &Receipt, |
| 1594 | 0× | ) -> Result<oxy_core::receipt::SignedReceipt> { |
| 1595 | 0× | let state = state_of(heads); |
| 1596 | 0× | let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number); |
| 1597 | 0× | Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret)) |
| 1598 | 0× | } |
| 1599 | |
|
| 1600 | |
|
| 1601 | |
|
| 1602 | |
|
| 1603 | |
|
| 1604 | 0× | fn sign_keyless( |
| 1605 | 0× | repo: &Repo, |
| 1606 | 0× | heads: &[ChangeHash], |
| 1607 | 0× | cfg: &RunnerConfig, |
| 1608 | 0× | hub: &Hub, |
| 1609 | 0× | claim: &Claim, |
| 1610 | 0× | receipt: &Receipt, |
| 1611 | 0× | ) -> Result<oxy_core::receipt::SignedReceipt> { |
| 1612 | 0× | let sk = oxy_core::identity::SecretKey::generate(); |
| 1613 | 0× | let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); |
| 1614 | 0× | let name = format!("{} job {}", cfg.name.as_deref().unwrap_or("runner"), claim.job_id); |
| 1615 | 0× | let id = sk.identity(&name, "", now); |
| 1616 | 0× | let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge(&claim.token)).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1617 | 0× | let (cert, issuer) = hub.certify(&claim.token, &id, &proof)?; |
| 1618 | 0× | let state = state_of(heads); |
| 1619 | 0× | let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number); |
| 1620 | 0× | Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &id, &sk).with_certificate(cert, issuer)) |
| 1621 | 0× | } |
| 1622 | |
|
| 1623 | |
|
| 1624 | 0× | pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef { |
| 1625 | | use oxy_core::hash::{hash_object, ObjectType}; |
| 1626 | 0× | hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode()) |
| 1627 | 0× | } |
| 1628 | |
|
| 1629 | 0× | fn hex32(s: &str) -> [u8; 32] { |
| 1630 | 0× | let mut out = [0; 32]; |
| 1631 | 0× | if let Ok(v) = hex::decode(s) { |
| 1632 | 0× | if v.len() == 32 { |
| 1633 | 0× | out.copy_from_slice(&v); |
| 1634 | 0× | } |
| 1635 | 0× | } |
| 1636 | 0× | out |
| 1637 | 0× | } |
| 1638 | |
|
| 1639 | |
|
| 1640 | |
|
| 1641 | |
|
| 1642 | 8× | fn transient(msg: &str) -> bool { |
| 1643 | 8× | let m = msg.to_lowercase(); |
| 1644 | 8× | [ |
| 1645 | 8× | "dns failed", |
| 1646 | 8× | "name resolution", |
| 1647 | 8× | "connection refused", |
| 1648 | 8× | "connection reset", |
| 1649 | 8× | "timed out", |
| 1650 | 8× | "timeout", |
| 1651 | 8× | "http 502", |
| 1652 | 8× | "http 503", |
| 1653 | 8× | "http 504", |
| 1654 | 8× | "broken pipe", |
| 1655 | 8× | "unexpected eof", |
| 1656 | 8× | ] |
| 1657 | 8× | .iter() |
| 1658 | 47× | .any(|k| m.contains(k)) |
| 1659 | 8× | } |
| 1660 | |
|
| 1661 | |
|
| 1662 | |
|
| 1663 | 3× | fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> { |
| 1664 | 3× | let mut tries = waits.iter(); |
| 1665 | | loop { |
| 1666 | 7× | match f() { |
| 1667 | 1× | Ok(v) => return Ok(v), |
| 1668 | 6× | Err(e) => match tries.next() { |
| 1669 | 5× | Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w), |
| 1670 | 2× | _ => return Err(e), |
| 1671 | | }, |
| 1672 | | } |
| 1673 | | } |
| 1674 | 3× | } |
| 1675 | |
|
| 1676 | |
|
| 1677 | | const FETCH_WAITS: [Duration; 5] = |
| 1678 | | [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)]; |
| 1679 | |
|
| 1680 | |
|
| 1681 | 0× | fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> { |
| 1682 | 0× | cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token) |
| 1683 | 0× | } |
| 1684 | |
|
| 1685 | |
|
| 1686 | 0× | fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> { |
| 1687 | 0× | let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__")); |
| 1688 | 0× | let set_remote = |repo: &mut Repo| -> Result<()> { |
| 1689 | 0× | repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1690 | 0× | repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1691 | 0× | if let Some(ca) = &cfg.ca { |
| 1692 | 0× | repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1693 | 0× | } |
| 1694 | 0× | repo.save_config().map_err(|e| anyhow::anyhow!("{e}")) |
| 1695 | 0× | }; |
| 1696 | 0× | if dir.join(".oxy").is_dir() { |
| 1697 | 0× | let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1698 | 0× | set_remote(&mut repo)?; |
| 1699 | 0× | with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?; |
| 1700 | 0× | return Ok(repo); |
| 1701 | 0× | } |
| 1702 | | |
| 1703 | | |
| 1704 | 0× | let _clone = CLONE.lock().unwrap_or_else(|p| p.into_inner()); |
| 1705 | 0× | std::env::set_var("OXY_TOKEN", token); |
| 1706 | 0× | if let Some(ca) = &cfg.ca { |
| 1707 | 0× | std::env::set_var("OXY_CA", ca); |
| 1708 | 0× | } |
| 1709 | 0× | let cloned = with_retries(&FETCH_WAITS, || { |
| 1710 | 0× | let _ = std::fs::remove_dir_all(&dir); |
| 1711 | 0× | Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}")) |
| 1712 | 0× | }); |
| 1713 | 0× | std::env::remove_var("OXY_TOKEN"); |
| 1714 | 0× | std::env::remove_var("OXY_CA"); |
| 1715 | 0× | let mut repo = cloned.map_err(|e| { |
| 1716 | 0× | let _ = std::fs::remove_dir_all(&dir); |
| 1717 | 0× | anyhow::anyhow!("clone {}: {e}", clone_url) |
| 1718 | 0× | })?; |
| 1719 | 0× | set_remote(&mut repo)?; |
| 1720 | 0× | Ok(repo) |
| 1721 | 0× | } |
| 1722 | |
|
| 1723 | | #[cfg(test)] |
| 1724 | | mod tests { |
| 1725 | |
|
| 1726 | | #[test] |
| 1727 | 1× | fn an_input_is_placed_only_when_it_is_what_the_receipt_names() { |
| 1728 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 1729 | 1× | let bytes = b"the reviewed plan"; |
| 1730 | 1× | let blob = Repo::blob_ref(bytes).to_hex(); |
| 1731 | 1× | let input = |path: &str, blob: &str| flow_core::api::ArtifactInput { |
| 1732 | 6× | artifact: "tfplan".into(), |
| 1733 | 6× | from: "plan".into(), |
| 1734 | 6× | path: path.into(), |
| 1735 | 6× | blob: blob.into(), |
| 1736 | 6× | }; |
| 1737 | 1× | let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap(); |
| 1738 | 1× | assert_eq!(hex::encode(got), blob); |
| 1739 | 1× | assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes); |
| 1740 | 1× | let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err(); |
| 1741 | 1× | assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}"); |
| 1742 | 1× | assert!(!dir.path().join("other").exists(), "nothing written when it does not match"); |
| 1743 | 4× | for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] { |
| 1744 | 4× | assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}"); |
| 1745 | | } |
| 1746 | 1× | } |
| 1747 | |
|
| 1748 | | use super::*; |
| 1749 | |
|
| 1750 | | #[test] |
| 1751 | 1× | fn a_runner_names_what_stops_a_sandbox() { |
| 1752 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 1753 | 1× | let home = Home::locate(Some(dir.path())).unwrap(); |
| 1754 | 1× | let bytes = b"not really ext4"; |
| 1755 | 1× | let hash = blake3::hash(bytes).to_hex().to_string(); |
| 1756 | 1× | let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({ |
| 1757 | 1× | "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated" |
| 1758 | | })) |
| 1759 | 1× | .unwrap(); |
| 1760 | 6× | let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free); |
| 1761 | 1× | assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner"); |
| 1762 | 1× | std::fs::write(home.rootfs(&hash), bytes).unwrap(); |
| 1763 | 1× | sb.rootfs = hash.clone(); |
| 1764 | 1× | assert_eq!(problem(&sb, Some(4096)), None); |
| 1765 | 1× | assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB"); |
| 1766 | 1× | std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap(); |
| 1767 | 1× | assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12])); |
| 1768 | 1× | sb.arch = Some("aarch64".into()); |
| 1769 | 1× | assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64"); |
| 1770 | 1× | sb.arch = None; |
| 1771 | 1× | std::fs::remove_file(home.image_manifest(&hash)).unwrap(); |
| 1772 | 1× | std::fs::write(home.rootfs(&hash), b"changed").unwrap(); |
| 1773 | 1× | assert!(problem(&sb, None).unwrap().contains("does not verify")); |
| 1774 | 1× | } |
| 1775 | |
|
| 1776 | | #[test] |
| 1777 | 1× | fn a_runner_reads_an_unrecorded_images_architecture_and_declines_another() { |
| 1778 | | |
| 1779 | | |
| 1780 | | |
| 1781 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 1782 | 1× | let home = Home::locate(Some(dir.path().join("home").as_path())).unwrap(); |
| 1783 | 1× | let tree = dir.path().join("tree"); |
| 1784 | 1× | std::fs::create_dir_all(tree.join("bin")).unwrap(); |
| 1785 | 1× | let mut elf = vec![0u8; 64]; |
| 1786 | 1× | elf[..7].copy_from_slice(b"\x7fELF\x02\x01\x01"); |
| 1787 | 1× | elf[18..20].copy_from_slice(&0xF3u16.to_le_bytes()); |
| 1788 | 1× | std::fs::write(tree.join("bin/sh"), &elf).unwrap(); |
| 1789 | 1× | let img = dir.path().join("rootfs.ext4"); |
| 1790 | 1× | let made = std::process::Command::new("mkfs.ext4").args(["-q", "-F", "-d"]).arg(&tree).arg(&img).arg("8M").status(); |
| 1791 | 1× | if !made.is_ok_and(|s| s.success()) { |
| 1792 | 0× | eprintln!("mkfs.ext4 not found: skipped"); |
| 1793 | 0× | return; |
| 1794 | 1× | } |
| 1795 | 1× | let hash = crate::home::hash_file(&img).unwrap(); |
| 1796 | 1× | std::fs::create_dir_all(home.rootfs(&hash).parent().unwrap()).unwrap(); |
| 1797 | 1× | std::fs::rename(&img, home.rootfs(&hash)).unwrap(); |
| 1798 | 1× | assert!(!home.image_manifest(&hash).exists()); |
| 1799 | 1× | let sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({ |
| 1800 | 1× | "name": "s", "runtime": "syscall", "rootfs": hash, "memory_mib": 64, "cpus": 1, "network": "isolated" |
| 1801 | | })) |
| 1802 | 1× | .unwrap(); |
| 1803 | 1× | assert_eq!( |
| 1804 | 1× | sandbox_problem(&home, &sb, "x86_64", None).unwrap(), |
| 1805 | 1× | format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]) |
| 1806 | | ); |
| 1807 | 1× | assert_eq!(sandbox_problem(&home, &sb, "riscv64", None), None, "a riscv64 runner takes it"); |
| 1808 | | |
| 1809 | 1× | let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(home.image_manifest(&hash)).unwrap()).unwrap(); |
| 1810 | 1× | assert_eq!(manifest["arch"], "riscv64"); |
| 1811 | 1× | assert_eq!(home.image_archs().get(&hash).map(String::as_str), Some("riscv64")); |
| 1812 | 1× | } |
| 1813 | |
|
| 1814 | | #[test] |
| 1815 | 1× | fn transient_network_errors_are_tried_again_and_others_are_not() { |
| 1816 | 1× | assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution")); |
| 1817 | 1× | assert!(transient("HTTP 503: the hub is restarting")); |
| 1818 | 1× | assert!(!transient("HTTP 404: no such repository")); |
| 1819 | 1× | let mut n = 0; |
| 1820 | 3× | let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || { |
| 1821 | 3× | n += 1; |
| 1822 | 3× | if n < 3 { |
| 1823 | 2× | Err(anyhow::anyhow!("connection refused")) |
| 1824 | | } else { |
| 1825 | 1× | Ok(7) |
| 1826 | | } |
| 1827 | 3× | }); |
| 1828 | 1× | assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success"); |
| 1829 | 1× | let mut m = 0; |
| 1830 | 1× | let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || { |
| 1831 | 1× | m += 1; |
| 1832 | 1× | Err(anyhow::anyhow!("HTTP 403: forbidden")) |
| 1833 | 1× | }); |
| 1834 | 1× | assert!(got.is_err()); |
| 1835 | 1× | assert_eq!(m, 1, "a refusal is not tried again"); |
| 1836 | 1× | let mut k = 0; |
| 1837 | 3× | let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || { |
| 1838 | 3× | k += 1; |
| 1839 | 3× | Err(anyhow::anyhow!("timed out")) |
| 1840 | 3× | }); |
| 1841 | 1× | assert!(got.is_err()); |
| 1842 | 1× | assert_eq!(k, 3, "gives up after the last wait"); |
| 1843 | 1× | } |
| 1844 | |
|
| 1845 | | |
| 1846 | 9× | fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) { |
| 1847 | 9× | fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes) |
| 1848 | 9× | } |
| 1849 | |
|
| 1850 | | |
| 1851 | 11× | fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) { |
| 1852 | | use std::io::{Read, Write}; |
| 1853 | 11× | let addr = listener.local_addr().unwrap(); |
| 1854 | 11× | let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| 1855 | 11× | let count = seen.clone(); |
| 1856 | 11× | std::thread::spawn(move || { |
| 1857 | 23× | for code in codes { |
| 1858 | 23× | let (mut s, _) = listener.accept().unwrap(); |
| 1859 | | |
| 1860 | | |
| 1861 | | |
| 1862 | 23× | let mut req = Vec::new(); |
| 1863 | 23× | let mut buf = [0u8; 8192]; |
| 1864 | | loop { |
| 1865 | 33× | let n = s.read(&mut buf).unwrap_or(0); |
| 1866 | 33× | if n == 0 { |
| 1867 | 0× | break; |
| 1868 | 33× | } |
| 1869 | 33× | req.extend_from_slice(&buf[..n]); |
| 1870 | 33× | let text = String::from_utf8_lossy(&req); |
| 1871 | 33× | if let Some(end) = text.find("\r\n\r\n") { |
| 1872 | 33× | let len = text[..end] |
| 1873 | 33× | .lines() |
| 1874 | 260× | .find_map(|l| { |
| 1875 | 260× | l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)) |
| 1876 | 260× | }) |
| 1877 | 33× | .unwrap_or(0); |
| 1878 | 33× | if req.len() >= end + 4 + len { |
| 1879 | 23× | break; |
| 1880 | 10× | } |
| 1881 | 0× | } |
| 1882 | | } |
| 1883 | 23× | count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
| 1884 | 23× | let reason = if code == 204 { "No Content" } else { "Error" }; |
| 1885 | 23× | let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); |
| 1886 | | } |
| 1887 | 11× | }); |
| 1888 | 11× | (format!("http://{addr}"), seen) |
| 1889 | 11× | } |
| 1890 | |
|
| 1891 | | |
| 1892 | | |
| 1893 | 1× | fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) { |
| 1894 | | use std::io::{Read, Write}; |
| 1895 | 1× | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 1896 | 1× | let addr = listener.local_addr().unwrap(); |
| 1897 | 1× | let (tx, rx) = std::sync::mpsc::channel(); |
| 1898 | 1× | std::thread::spawn(move || { |
| 1899 | 1× | let (mut s, _) = listener.accept().unwrap(); |
| 1900 | 1× | let mut req = Vec::new(); |
| 1901 | 1× | let mut buf = [0u8; 65536]; |
| 1902 | | loop { |
| 1903 | 2× | let n = s.read(&mut buf).unwrap_or(0); |
| 1904 | 2× | if n == 0 { |
| 1905 | 0× | break; |
| 1906 | 2× | } |
| 1907 | 2× | req.extend_from_slice(&buf[..n]); |
| 1908 | 2× | let text = String::from_utf8_lossy(&req); |
| 1909 | 2× | if let Some(end) = text.find("\r\n\r\n") { |
| 1910 | 2× | let len = text[..end] |
| 1911 | 2× | .lines() |
| 1912 | 14× | .find_map(|l| { |
| 1913 | 14× | l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)) |
| 1914 | 14× | }) |
| 1915 | 2× | .unwrap_or(0); |
| 1916 | 2× | if req.len() >= end + 4 + len { |
| 1917 | 1× | break; |
| 1918 | 1× | } |
| 1919 | 0× | } |
| 1920 | | } |
| 1921 | 1× | tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap(); |
| 1922 | 1× | let _ = write!( |
| 1923 | 1× | s, |
| 1924 | | "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", |
| 1925 | 1× | body.len() |
| 1926 | | ); |
| 1927 | 1× | }); |
| 1928 | 1× | (format!("http://{addr}"), rx) |
| 1929 | 1× | } |
| 1930 | |
|
| 1931 | | #[test] |
| 1932 | 1× | fn registering_binds_the_key_and_keeps_the_credential_private() { |
| 1933 | | use std::os::unix::fs::PermissionsExt; |
| 1934 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 1935 | 1× | let home = Home::locate(Some(dir.path())).unwrap(); |
| 1936 | 1× | let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#); |
| 1937 | 1× | let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap(); |
| 1938 | 1× | assert_eq!((got.id, got.name.as_str()), (7, "plex")); |
| 1939 | 1× | let req = sent.recv().unwrap(); |
| 1940 | 1× | assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}"); |
| 1941 | 1× | let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap(); |
| 1942 | 1× | assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()])); |
| 1943 | 1× | let key = home.runner_key("plex").unwrap(); |
| 1944 | 1× | assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity"); |
| 1945 | 1× | let path = dir.path().join(crate::home::RUNNER_TOML); |
| 1946 | 1× | assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); |
| 1947 | | |
| 1948 | 1× | let again = Home::locate(Some(dir.path())).unwrap(); |
| 1949 | 1× | let cfg = again.config.runner.unwrap(); |
| 1950 | 1× | assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex"))); |
| 1951 | 1× | assert_eq!(cfg.labels, ["gpu"]); |
| 1952 | 1× | } |
| 1953 | |
|
| 1954 | | |
| 1955 | | |
| 1956 | 3× | fn fake_certify_hub( |
| 1957 | 3× | sign_as: (oxy_core::identity::Identity, oxy_core::identity::SecretKey), |
| 1958 | 3× | cert_key: oxy_core::identity::Identity, |
| 1959 | 3× | published: oxy_core::identity::Identity, |
| 1960 | 3× | ) -> String { |
| 1961 | | use std::io::{Read, Write}; |
| 1962 | 3× | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 1963 | 3× | let addr = listener.local_addr().unwrap(); |
| 1964 | 3× | std::thread::spawn(move || { |
| 1965 | 3× | for _ in 0..2 { |
| 1966 | 6× | let Ok((mut s, _)) = listener.accept() else { return }; |
| 1967 | 6× | let mut req = Vec::new(); |
| 1968 | 6× | let mut buf = [0u8; 65536]; |
| 1969 | | loop { |
| 1970 | 6× | let n = s.read(&mut buf).unwrap_or(0); |
| 1971 | 6× | if n == 0 { |
| 1972 | 0× | break; |
| 1973 | 6× | } |
| 1974 | 6× | req.extend_from_slice(&buf[..n]); |
| 1975 | 6× | let text = String::from_utf8_lossy(&req); |
| 1976 | 6× | if let Some(end) = text.find("\r\n\r\n") { |
| 1977 | 6× | let len = text[..end] |
| 1978 | 6× | .lines() |
| 1979 | 39× | .find_map(|l| { |
| 1980 | 39× | l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)) |
| 1981 | 39× | }) |
| 1982 | 6× | .unwrap_or(0); |
| 1983 | 6× | if req.len() >= end + 4 + len { |
| 1984 | 6× | break; |
| 1985 | 0× | } |
| 1986 | 0× | } |
| 1987 | | } |
| 1988 | 6× | let line = String::from_utf8_lossy(&req).lines().next().unwrap_or("").to_string(); |
| 1989 | 6× | let body: Vec<u8> = if line.contains("/keyless/certify") { |
| 1990 | 3× | let cert = oxy_core::cert::SignedCertificate::sign( |
| 1991 | 3× | oxy_core::cert::Certificate { |
| 1992 | 3× | key: cert_key.clone(), |
| 1993 | 3× | issuer: sign_as.0.hash(), |
| 1994 | 3× | subject: "repo:andy/demo:bookmark:main".into(), |
| 1995 | 3× | claims: vec![], |
| 1996 | 3× | scope: oxy_core::identity::Scope::SEAL, |
| 1997 | 3× | not_before: 1, |
| 1998 | 3× | expires: 600_001, |
| 1999 | 3× | grant: None, |
| 2000 | 3× | }, |
| 2001 | 3× | &sign_as.0, |
| 2002 | 3× | &sign_as.1, |
| 2003 | | ); |
| 2004 | 3× | serde_json::json!({ "certificate": oxy_core::dsse::base64(&cert.encode()) }).to_string().into_bytes() |
| 2005 | | } else { |
| 2006 | 3× | published.encode() |
| 2007 | | }; |
| 2008 | 6× | let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); |
| 2009 | 6× | let _ = s.write_all(&body); |
| 2010 | | } |
| 2011 | 3× | }); |
| 2012 | 3× | format!("http://{addr}") |
| 2013 | 3× | } |
| 2014 | |
|
| 2015 | | |
| 2016 | | |
| 2017 | | #[test] |
| 2018 | 1× | fn a_certificate_is_taken_only_for_this_key_from_the_hubs_key() { |
| 2019 | | use oxy_core::identity::SecretKey; |
| 2020 | 1× | let hub_sk = SecretKey::generate(); |
| 2021 | 1× | let hub_id = hub_sk.identity("hub", "", 1); |
| 2022 | 1× | let sk = SecretKey::generate(); |
| 2023 | 1× | let id = sk.identity("earth-1 job 7", "", 1); |
| 2024 | 1× | let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge("flowjob_x")).unwrap(); |
| 2025 | |
|
| 2026 | 1× | let base = fake_certify_hub((hub_id.clone(), hub_sk.clone()), id.clone(), hub_id.clone()); |
| 2027 | 1× | let (cert, issuer) = hub_at(base).certify("flowjob_x", &id, &proof).unwrap(); |
| 2028 | 1× | assert_eq!((cert.cert.key_id(), issuer.hash()), (id.hash(), hub_id.hash())); |
| 2029 | |
|
| 2030 | 1× | let mallory_sk = SecretKey::generate(); |
| 2031 | 1× | let mallory = mallory_sk.identity("mallory", "", 1); |
| 2032 | 1× | let base = fake_certify_hub((mallory.clone(), mallory_sk), id.clone(), hub_id.clone()); |
| 2033 | 1× | let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string(); |
| 2034 | 1× | assert!(e.contains("not signed by the hub's published key"), "{e}"); |
| 2035 | |
|
| 2036 | 1× | let other = SecretKey::generate().identity("other", "", 1); |
| 2037 | 1× | let base = fake_certify_hub((hub_id.clone(), hub_sk), other, hub_id); |
| 2038 | 1× | let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string(); |
| 2039 | 1× | assert!(e.contains("another key"), "{e}"); |
| 2040 | 1× | } |
| 2041 | |
|
| 2042 | 16× | fn hub_at(base: String) -> Hub { |
| 2043 | 16× | Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() } |
| 2044 | 16× | } |
| 2045 | |
|
| 2046 | | |
| 2047 | | |
| 2048 | | #[test] |
| 2049 | 1× | fn a_build_is_kept_only_if_it_matches_the_hubs_blake3() { |
| 2050 | 1× | let (body, other) = (b"#!flow build 0.3.2".to_vec(), b"#!something else!!".to_vec()); |
| 2051 | 2× | let serve = |bytes: Vec<u8>| { |
| 2052 | 2× | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 2053 | 2× | let addr = listener.local_addr().unwrap(); |
| 2054 | 2× | std::thread::spawn(move || { |
| 2055 | | use std::io::{Read, Write}; |
| 2056 | 2× | let (mut s, _) = listener.accept().unwrap(); |
| 2057 | 2× | let mut buf = [0u8; 4096]; |
| 2058 | 2× | let _ = s.read(&mut buf); |
| 2059 | 2× | let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len()); |
| 2060 | 2× | let _ = s.write_all(&bytes); |
| 2061 | 2× | }); |
| 2062 | 2× | format!("http://{addr}") |
| 2063 | 2× | }; |
| 2064 | 1× | let offer = flow_core::api::UpdateOffer { |
| 2065 | 1× | version: "0.3.2".into(), |
| 2066 | 1× | asset: "flow-linux-x86_64".into(), |
| 2067 | 1× | url: "/andy/oxygenflow/asset/1/flow-linux-x86_64".into(), |
| 2068 | 1× | blake3: blake3::hash(&body).to_hex().to_string(), |
| 2069 | 1× | size: body.len() as u64, |
| 2070 | 1× | ..Default::default() |
| 2071 | 1× | }; |
| 2072 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 2073 | 1× | let to = dir.path().join("flow-0.3.2"); |
| 2074 | 1× | hub_at(serve(body.clone())).download_build(&offer, &to).unwrap(); |
| 2075 | 1× | assert_eq!(std::fs::read(&to).unwrap(), body); |
| 2076 | 1× | std::fs::remove_file(&to).unwrap(); |
| 2077 | 1× | let e = hub_at(serve(other)).download_build(&offer, &to).unwrap_err(); |
| 2078 | 1× | assert!(e.to_string().contains("does not match what the hub attests"), "{e:#}"); |
| 2079 | 1× | assert!(!to.exists() && !to.with_extension("partial").exists(), "nothing of it is left"); |
| 2080 | 1× | } |
| 2081 | |
|
| 2082 | | |
| 2083 | | |
| 2084 | | #[test] |
| 2085 | 1× | fn a_result_is_reported_until_the_hub_takes_it() { |
| 2086 | 1× | let f = Finish { |
| 2087 | 1× | exit_code: Some(0), |
| 2088 | 1× | duration_ms: 1, |
| 2089 | 1× | receipt: None, |
| 2090 | 1× | signed_receipt: None, |
| 2091 | 1× | error: None, |
| 2092 | 1× | peak_disk_mib: None, |
| 2093 | 1× | peak_memory_mib: None, |
| 2094 | 1× | }; |
| 2095 | 1× | let quick = Duration::from_millis(10); |
| 2096 | 1× | let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]); |
| 2097 | 1× | hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap(); |
| 2098 | 1× | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors"); |
| 2099 | 1× | let (base, seen) = fake_hub(vec![409]); |
| 2100 | 1× | assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err()); |
| 2101 | 1× | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final"); |
| 2102 | 1× | } |
| 2103 | |
|
| 2104 | | #[test] |
| 2105 | 1× | fn server_errors_are_tried_again_and_client_errors_are_not() { |
| 2106 | 1× | let quick = [Duration::from_millis(10); 3]; |
| 2107 | 1× | let (base, seen) = fake_hub(vec![500, 503, 204]); |
| 2108 | 1× | let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap(); |
| 2109 | 1× | assert!(r.is_none()); |
| 2110 | 1× | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted"); |
| 2111 | |
|
| 2112 | 1× | let (base, seen) = fake_hub(vec![500, 500, 500, 500]); |
| 2113 | 1× | let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string(); |
| 2114 | 1× | assert!(e.contains("HTTP 500"), "{e}"); |
| 2115 | 1× | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay"); |
| 2116 | |
|
| 2117 | 1× | let (base, seen) = fake_hub(vec![401]); |
| 2118 | 1× | assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err()); |
| 2119 | 1× | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final"); |
| 2120 | 1× | } |
| 2121 | |
|
| 2122 | | static NOT_LEAVING: std::sync::Mutex<Option<Leave>> = std::sync::Mutex::new(None); |
| 2123 | |
|
| 2124 | | |
| 2125 | | |
| 2126 | | |
| 2127 | | #[test] |
| 2128 | 1× | fn a_leaving_runner_stops_its_job_and_hands_it_back() { |
| 2129 | | use microvm::Sink; |
| 2130 | 1× | let leaving = std::sync::Mutex::new(None); |
| 2131 | 1× | let (base, seen) = fake_hub(vec![204, 204]); |
| 2132 | 1× | let hub = hub_at(base); |
| 2133 | 1× | let mut sink = LogSink { |
| 2134 | 1× | hub: &hub, |
| 2135 | 1× | job: 7, |
| 2136 | 1× | buf: Vec::new(), |
| 2137 | 1× | last: Instant::now(), |
| 2138 | 1× | gone: false, |
| 2139 | 1× | leaving: &leaving, |
| 2140 | 1× | started: Instant::now(), |
| 2141 | 1× | expected: None, |
| 2142 | 1× | }; |
| 2143 | 1× | sink.buf.push(Record { t: 1, stream: "out".into(), text: "step 1".into() }); |
| 2144 | 1× | assert!(!sink.cancelled()); |
| 2145 | 1× | *leaving.lock().unwrap() = Some(Leave { reason: "off Wi-Fi".into(), by: Instant::now() }); |
| 2146 | 1× | assert!(sink.cancelled()); |
| 2147 | 1× | assert!(sink.buf.is_empty(), "flushed before the job stops"); |
| 2148 | 1× | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1); |
| 2149 | 1× | hub.release(7, "off Wi-Fi").unwrap(); |
| 2150 | 1× | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2); |
| 2151 | 1× | } |
| 2152 | |
|
| 2153 | | |
| 2154 | | |
| 2155 | | |
| 2156 | | #[test] |
| 2157 | 1× | fn a_job_that_fits_the_notice_finishes_and_the_others_go_back() { |
| 2158 | | use microvm::Sink; |
| 2159 | 1× | let (base, _) = fake_hub(vec![204; 8]); |
| 2160 | 1× | let hub = hub_at(base); |
| 2161 | 1× | let leaving = std::sync::Mutex::new(Some(Leave { reason: "off charge".into(), by: Instant::now() + Duration::from_secs(60) })); |
| 2162 | 1× | let sink = |expected: Option<u64>, ran: u64| LogSink { |
| 2163 | 4× | hub: &hub, |
| 2164 | | job: 1, |
| 2165 | 4× | buf: Vec::new(), |
| 2166 | 4× | last: Instant::now(), |
| 2167 | | gone: false, |
| 2168 | 4× | leaving: &leaving, |
| 2169 | 4× | started: Instant::now() - Duration::from_secs(ran), |
| 2170 | 4× | expected: expected.map(Duration::from_secs), |
| 2171 | 4× | }; |
| 2172 | 1× | assert!(!sink(Some(70), 30).cancelled(), "40 s left of it, within a minute: it finishes"); |
| 2173 | 1× | assert!(sink(Some(600), 30).cancelled(), "nearly ten minutes left: handed back"); |
| 2174 | 1× | assert!(sink(None, 0).cancelled(), "no idea how long: handed back"); |
| 2175 | 1× | *leaving.lock().unwrap() = Some(Leave { reason: "off charge".into(), by: Instant::now() - Duration::from_secs(1) }); |
| 2176 | 1× | assert!(sink(Some(70), 30).cancelled(), "the notice is up: handed back whatever was expected"); |
| 2177 | 1× | } |
| 2178 | |
|
| 2179 | | |
| 2180 | | |
| 2181 | | #[test] |
| 2182 | 1× | fn a_quiet_job_learns_that_its_run_was_cancelled() { |
| 2183 | | use microvm::Sink; |
| 2184 | 2× | let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst); |
| 2185 | 1× | let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1); |
| 2186 | 1× | let (base, seen) = fake_hub(vec![204]); |
| 2187 | 1× | let hub = hub_at(base); |
| 2188 | 1× | let mut sink = LogSink { |
| 2189 | 1× | hub: &hub, |
| 2190 | 1× | job: 1, |
| 2191 | 1× | buf: Vec::new(), |
| 2192 | 1× | last: quiet, |
| 2193 | 1× | gone: false, |
| 2194 | 1× | leaving: &NOT_LEAVING, |
| 2195 | 1× | started: Instant::now(), |
| 2196 | 1× | expected: None, |
| 2197 | 1× | }; |
| 2198 | 1× | assert!(!sink.cancelled()); |
| 2199 | 1× | assert!(!sink.cancelled(), "asked again only after the heartbeat"); |
| 2200 | 1× | assert_eq!(n(&seen), 1); |
| 2201 | |
|
| 2202 | 1× | let (base, seen) = fake_hub(vec![409]); |
| 2203 | 1× | let hub = hub_at(base); |
| 2204 | 1× | let mut sink = LogSink { |
| 2205 | 1× | hub: &hub, |
| 2206 | 1× | job: 1, |
| 2207 | 1× | buf: Vec::new(), |
| 2208 | 1× | last: quiet, |
| 2209 | 1× | gone: false, |
| 2210 | 1× | leaving: &NOT_LEAVING, |
| 2211 | 1× | started: Instant::now(), |
| 2212 | 1× | expected: None, |
| 2213 | 1× | }; |
| 2214 | 1× | assert!(sink.cancelled()); |
| 2215 | 1× | sink.record(Record { t: 0, stream: "out".into(), text: "late".into() }); |
| 2216 | 1× | sink.flush(); |
| 2217 | 1× | assert!(sink.cancelled()); |
| 2218 | 1× | assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job"); |
| 2219 | 1× | } |
| 2220 | |
|
| 2221 | | |
| 2222 | | |
| 2223 | | |
| 2224 | | #[test] |
| 2225 | 1× | fn a_job_outlasts_a_hub_restart() { |
| 2226 | 2× | let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst); |
| 2227 | | |
| 2228 | 1× | let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
| 2229 | 1× | let base = format!("http://127.0.0.1:{port}"); |
| 2230 | 1× | let hub = hub_at(base.clone()); |
| 2231 | |
|
| 2232 | | |
| 2233 | 1× | let mut sink = LogSink { |
| 2234 | 1× | hub: &hub, |
| 2235 | 1× | job: 1, |
| 2236 | 1× | buf: Vec::new(), |
| 2237 | 1× | last: Instant::now(), |
| 2238 | 1× | gone: false, |
| 2239 | 1× | leaving: &NOT_LEAVING, |
| 2240 | 1× | started: Instant::now(), |
| 2241 | 1× | expected: None, |
| 2242 | 1× | }; |
| 2243 | 450× | for i in 0..450 { |
| 2244 | 450× | sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") }); |
| 2245 | 450× | } |
| 2246 | 1× | sink.send(); |
| 2247 | 1× | assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away"); |
| 2248 | 1× | let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]); |
| 2249 | 1× | sink.send(); |
| 2250 | 1× | assert!(sink.buf.is_empty() && !sink.gone); |
| 2251 | 1× | assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50"); |
| 2252 | |
|
| 2253 | | |
| 2254 | | |
| 2255 | 1× | let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
| 2256 | 1× | let url = format!("http://127.0.0.1:{port}/x"); |
| 2257 | 1× | let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build(); |
| 2258 | 1× | let later = std::thread::spawn(move || { |
| 2259 | 1× | std::thread::sleep(Duration::from_millis(150)); |
| 2260 | 1× | fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204]) |
| 2261 | 1× | }); |
| 2262 | 1× | let quick = [Duration::from_millis(50); 20]; |
| 2263 | 5× | let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap(); |
| 2264 | 1× | assert_eq!(r.status(), 204); |
| 2265 | 1× | assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub"); |
| 2266 | | |
| 2267 | 1× | let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
| 2268 | 1× | let url = format!("http://127.0.0.1:{port}/x"); |
| 2269 | 1× | assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err()); |
| 2270 | 1× | assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart"); |
| 2271 | 1× | } |
| 2272 | |
|
| 2273 | | |
| 2274 | | |
| 2275 | | #[test] |
| 2276 | 1× | fn a_stop_drains_the_runner() { |
| 2277 | 1× | assert!(!stopping()); |
| 2278 | 1× | on_stop(libc::SIGTERM); |
| 2279 | 1× | assert!(stopping(), "told to stop, it claims nothing more"); |
| 2280 | | |
| 2281 | 1× | STOPS.store(0, std::sync::atomic::Ordering::SeqCst); |
| 2282 | 1× | stop(); |
| 2283 | 1× | assert!(stopping()); |
| 2284 | 1× | STOPS.store(0, std::sync::atomic::Ordering::SeqCst); |
| 2285 | 1× | } |
| 2286 | |
|
| 2287 | | |
| 2288 | | #[test] |
| 2289 | 1× | fn unsent_log_lines_are_bounded() { |
| 2290 | 1× | let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
| 2291 | 1× | let hub = hub_at(format!("http://127.0.0.1:{port}")); |
| 2292 | 1× | let mut sink = LogSink { |
| 2293 | 1× | hub: &hub, |
| 2294 | 1× | job: 1, |
| 2295 | 1× | buf: Vec::new(), |
| 2296 | 1× | last: Instant::now(), |
| 2297 | 1× | gone: false, |
| 2298 | 1× | leaving: &NOT_LEAVING, |
| 2299 | 1× | started: Instant::now(), |
| 2300 | 1× | expected: None, |
| 2301 | 1× | }; |
| 2302 | 50010× | sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect(); |
| 2303 | 1× | sink.send(); |
| 2304 | 1× | assert_eq!(sink.buf.len(), MAX_UNSENT + 1); |
| 2305 | 1× | assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)"); |
| 2306 | 1× | assert_eq!(sink.buf[1].t, 10, "the oldest go first"); |
| 2307 | 1× | } |
| 2308 | |
|
| 2309 | | #[test] |
| 2310 | 1× | fn the_budget_admits_jobs_that_fit() { |
| 2311 | 1× | let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) }; |
| 2312 | 7× | let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus }; |
| 2313 | 1× | assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything"); |
| 2314 | 1× | assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4)))); |
| 2315 | 1× | assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left"); |
| 2316 | 1× | assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs"); |
| 2317 | 1× | let one = Budget { max_jobs: 1, memory_mib: None, cpus: None }; |
| 2318 | 1× | assert_eq!(one.room(used(0, 0, 0)), Some((None, None))); |
| 2319 | 1× | assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time"); |
| 2320 | 1× | let open = Budget { max_jobs: 2, memory_mib: None, cpus: None }; |
| 2321 | 1× | assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured"); |
| 2322 | 1× | } |
| 2323 | | } |