| e0918b88 Andy McCall |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 47c1d64d Andy McCall ↶ |
6 |
use crate::home::{Home, RunnerConfig, RunnerKey}; |
| e0918b88 Andy McCall |
7 |
use crate::{microvm, workspace}; |
| 8 |
use anyhow::{bail, Context, Result}; |
| b11e30f5 Andy McCall ↶ |
9 |
use flow_core::api::{Claim, ClaimRequest, Decline, Finish, LogBatch, Record, Register, Registered, RunnerInfo, MAX_RECORD_TEXT}; |
| e0918b88 Andy McCall |
10 |
use flow_core::{scope_tree_hash, Receipt}; |
| 11 |
use oxy_core::hash::ChangeHash; |
| 12 |
use oxy_core::repo::Repo; |
| 46fe2c81 Andy McCall ↶ |
13 |
use std::path::{Path, PathBuf}; |
| e0918b88 Andy McCall |
14 |
use std::time::{Duration, Instant}; |
| 15 |
|
| 280ef951 Andy McCall ↶ |
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 |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 26 |
write!(f, "HTTP {}: {}", self.code, self.text) |
| 27 |
} |
| 28 |
} |
| 29 |
|
| 30 |
impl std::error::Error for Refused {} |
| 31 |
|
| 32 |
|
| 33 |
fn job_gone(e: &anyhow::Error) -> bool { |
| 34 |
e.downcast_ref::<Refused>().is_some_and(|r| r.code == 409) |
| 35 |
} |
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
const CANCELLED: &str = "the run was cancelled"; |
| 40 |
|
| 41 |
fn cancelled() -> Finish { |
| 8f211e20 Andy McCall ↶ |
42 |
Finish { |
| 43 |
exit_code: None, |
| 44 |
duration_ms: 0, |
| 45 |
receipt: None, |
| 46 |
signed_receipt: None, |
| 47 |
error: Some(CANCELLED.into()), |
| 48 |
peak_disk_mib: None, |
| 49 |
peak_memory_mib: None, |
| 50 |
} |
| 280ef951 Andy McCall ↶ |
51 |
} |
| 52 |
|
| e0918b88 Andy McCall |
53 |
pub struct Hub { |
| 54 |
base: String, |
| 55 |
token: String, |
| 56 |
agent: ureq::Agent, |
| 57 |
} |
| 58 |
|
| 59 |
impl Hub { |
| 60 |
pub fn new(cfg: &RunnerConfig) -> Result<Hub> { |
| 61 |
let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 62 |
let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build(); |
| 63 |
Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent }) |
| 64 |
} |
| 65 |
|
| 1b2229da Andy McCall ↶ |
66 |
|
| 67 |
|
| 68 |
|
| e0918b88 Andy McCall |
69 |
fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> { |
| 1b2229da Andy McCall ↶ |
70 |
self.post_with(path, body, &RETRY_DELAYS) |
| 71 |
} |
| 72 |
|
| 73 |
fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> { |
| e0918b88 Andy McCall |
74 |
let url = format!("{}/api/v1{path}", self.base); |
| 89171fca Andy McCall ↶ |
75 |
let r = retrying(&url, delays, false, || { |
| 76 |
self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new) |
| 77 |
})?; |
| 78 |
Ok(if r.status() == 204 { None } else { Some(r) }) |
| 79 |
} |
| 80 |
|
| 81 |
|
| 82 |
|
| 83 |
|
| 84 |
fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> { |
| 85 |
let url = format!("{}/api/v1{path}", self.base); |
| 86 |
let r = retrying(&url, &PATIENT_DELAYS, true, || { |
| 87 |
self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new) |
| 88 |
})?; |
| 89 |
Ok(if r.status() == 204 { None } else { Some(r) }) |
| e0918b88 Andy McCall |
90 |
} |
| 91 |
|
| 92 |
pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> { |
| 93 |
match self.post("/flow/claim", req)? { |
| 94 |
Some(r) => Ok(Some(r.into_json()?)), |
| 95 |
None => Ok(None), |
| 96 |
} |
| 97 |
} |
| 98 |
|
| b11e30f5 Andy McCall ↶ |
99 |
|
| 100 |
pub fn decline(&self, job: i64, reason: &str) -> Result<()> { |
| 89171fca Andy McCall ↶ |
101 |
self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?; |
| b11e30f5 Andy McCall ↶ |
102 |
Ok(()) |
| 103 |
} |
| 104 |
|
| a4b3f8cb Andy McCall ↶ |
105 |
|
| 106 |
|
| 107 |
|
| 35f7a849 Andy McCall ↶ |
108 |
|
| 109 |
|
| 110 |
|
| 111 |
pub fn certify( |
| 112 |
&self, |
| 113 |
job_token: &str, |
| 114 |
identity: &oxy_core::identity::Identity, |
| 115 |
proof: &oxy_core::identity::Signature, |
| 116 |
) -> Result<(oxy_core::cert::SignedCertificate, oxy_core::identity::Identity)> { |
| 117 |
use oxy_core::dsse::{base64, base64_decode}; |
| 118 |
let body = serde_json::json!({ |
| 119 |
"identity": base64(&identity.encode()), |
| 120 |
"proof": base64(&oxy_core::cbor::encode(&proof.to_value())), |
| 121 |
"scope": "seal", |
| 122 |
}); |
| 123 |
let url = format!("{}/api/v1/keyless/certify", self.base); |
| 124 |
let r = self |
| 125 |
.agent |
| 126 |
.post(&url) |
| 127 |
.set("Authorization", &format!("Bearer {job_token}")) |
| 128 |
.send_json(body) |
| 129 |
.map_err(|e| anyhow::anyhow!("asking the hub to certify the key: {e}"))?; |
| 130 |
let v: serde_json::Value = r.into_json()?; |
| 131 |
let cert = base64_decode(v["certificate"].as_str().unwrap_or("")) |
| 132 |
.context("the hub's answer has no certificate") |
| 133 |
.and_then(|b| oxy_core::cert::SignedCertificate::decode(&b).map_err(|e| anyhow::anyhow!("{e}")))?; |
| 134 |
let mut bytes = Vec::new(); |
| 135 |
std::io::Read::read_to_end( |
| 136 |
&mut self.agent.get(&format!("{}/.well-known/oxyhub-key", self.base)).call()?.into_reader(), |
| 137 |
&mut bytes, |
| 138 |
)?; |
| 139 |
let issuer = oxy_core::identity::Identity::decode(&bytes)?; |
| 140 |
if issuer.hash() != cert.cert.issuer || cert.verify(&issuer).is_err() { |
| 141 |
bail!("the certificate is not signed by the hub's published key"); |
| 142 |
} |
| 143 |
if cert.cert.key_id() != identity.hash() { |
| 144 |
bail!("the hub certified another key"); |
| 145 |
} |
| 146 |
Ok((cert, issuer)) |
| 147 |
} |
| 148 |
|
| a4b3f8cb Andy McCall ↶ |
149 |
pub fn heartbeat(&self) -> Result<()> { |
| 150 |
match self.post_with("/flow/runners/heartbeat", &serde_json::json!({}), &[]) { |
| 151 |
Ok(_) => Ok(()), |
| 152 |
Err(e) if format!("{e:#}").contains("404") => Ok(()), |
| 153 |
Err(e) => Err(e), |
| 154 |
} |
| 155 |
} |
| 156 |
|
| e0918b88 Andy McCall |
157 |
pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> { |
| 158 |
self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?; |
| 159 |
Ok(()) |
| 160 |
} |
| 161 |
|
| a67b97e9 Andy McCall ↶ |
162 |
|
| 163 |
|
| 164 |
pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> { |
| 165 |
let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base); |
| 89171fca Andy McCall ↶ |
166 |
let r = retrying(&url, &PATIENT_DELAYS, true, || { |
| 167 |
self.agent |
| a67b97e9 Andy McCall ↶ |
168 |
.post(&url) |
| 169 |
.timeout(Duration::from_secs(600)) |
| 170 |
.set("Authorization", &format!("Bearer {}", self.token)) |
| 89171fca Andy McCall ↶ |
171 |
.set("Content-Type", "application/octet-stream") |
| 172 |
.send_bytes(bytes) |
| 173 |
.map_err(Box::new) |
| 174 |
})?; |
| 175 |
let v: serde_json::Value = r.into_json()?; |
| 176 |
v["blob"].as_str().map(String::from).context("the hub named no blob") |
| a67b97e9 Andy McCall ↶ |
177 |
} |
| 178 |
|
| 46fe2c81 Andy McCall ↶ |
179 |
|
| 180 |
|
| 181 |
|
| 182 |
pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> { |
| 183 |
let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base); |
| 184 |
let r = retrying(&url, &PATIENT_DELAYS, false, || { |
| 185 |
self.agent |
| 186 |
.get(&url) |
| 187 |
.timeout(Duration::from_secs(600)) |
| 188 |
.set("Authorization", &format!("Bearer {}", self.token)) |
| 189 |
.call() |
| 190 |
.map_err(Box::new) |
| 191 |
})?; |
| 192 |
let mut bytes = Vec::new(); |
| 193 |
let mut reader = r.into_reader(); |
| 194 |
std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?; |
| 195 |
if bytes.len() > MAX_INPUT_BYTES { |
| 196 |
bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20); |
| 197 |
} |
| 198 |
Ok(bytes) |
| 199 |
} |
| 200 |
|
| 6d53fb7d Andy McCall ↶ |
201 |
|
| 202 |
|
| 203 |
|
| 204 |
pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> { |
| 205 |
let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base); |
| 89171fca Andy McCall ↶ |
206 |
let r = retrying(&url, &PATIENT_DELAYS, true, || { |
| 207 |
self.agent |
| 6d53fb7d Andy McCall ↶ |
208 |
.post(&url) |
| 209 |
.timeout(Duration::from_secs(600)) |
| 210 |
.set("Authorization", &format!("Bearer {}", self.token)) |
| 211 |
.set("Content-Type", "application/octet-stream") |
| 89171fca Andy McCall ↶ |
212 |
.set("X-Flow-Report-Path", path) |
| 213 |
.send_bytes(bytes) |
| 214 |
.map_err(Box::new) |
| 215 |
})?; |
| 216 |
let v: serde_json::Value = r.into_json()?; |
| 217 |
v["blob"].as_str().map(String::from).context("the hub named no blob") |
| 6d53fb7d Andy McCall ↶ |
218 |
} |
| 219 |
|
| 3007d8f0 Andy McCall ↶ |
220 |
|
| 221 |
|
| 222 |
|
| 223 |
|
| 224 |
|
| 225 |
pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> { |
| 226 |
let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base); |
| 227 |
let deadline = std::time::Instant::now() + patience; |
| 228 |
let mut wait = first_wait; |
| 229 |
loop { |
| 230 |
let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) { |
| 231 |
Ok(_) => return Ok(()), |
| 232 |
Err(ureq::Error::Status(code, r)) if code < 500 => { |
| 233 |
bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()) |
| 234 |
} |
| 235 |
Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"), |
| 236 |
Err(e) => e.to_string(), |
| 237 |
}; |
| 238 |
if std::time::Instant::now() + wait > deadline { |
| 239 |
bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60); |
| 240 |
} |
| 241 |
eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs()); |
| 242 |
std::thread::sleep(wait); |
| 243 |
wait = (wait * 2).min(Duration::from_secs(60)); |
| 244 |
} |
| 245 |
} |
| 246 |
} |
| 247 |
|
| 248 |
|
| 249 |
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600); |
| 250 |
|
| 251 |
|
| 252 |
|
| 253 |
fn pending_path(home: &Home, job: i64) -> PathBuf { |
| 254 |
home.root.join("pending").join(format!("{job}.json")) |
| 255 |
} |
| 256 |
|
| 257 |
|
| 258 |
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) { |
| 259 |
let path = pending_path(home, job); |
| 260 |
let kept = std::fs::create_dir_all(path.parent().unwrap()) |
| 261 |
.and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default())) |
| 262 |
.is_ok(); |
| 263 |
match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) { |
| 264 |
Ok(()) => {} |
| 265 |
Err(e) => eprintln!("job {job}: reporting failed: {e:#}"), |
| 266 |
} |
| 267 |
if kept { |
| 268 |
let _ = std::fs::remove_file(&path); |
| f98a8f78 Andy McCall ↶ |
269 |
} |
| 3007d8f0 Andy McCall ↶ |
270 |
} |
| 271 |
|
| 272 |
|
| 273 |
fn report_pending(home: &Home) -> Vec<(i64, Finish)> { |
| 274 |
let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else { |
| 275 |
return Vec::new(); |
| 276 |
}; |
| 277 |
dir.filter_map(|e| { |
| 278 |
let p = e.ok()?.path(); |
| 279 |
let job: i64 = p.file_stem()?.to_str()?.parse().ok()?; |
| 280 |
let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?; |
| 281 |
Some((job, f)) |
| 282 |
}) |
| 283 |
.collect() |
| f98a8f78 Andy McCall ↶ |
284 |
} |
| 285 |
|
| 286 |
|
| 287 |
|
| 288 |
|
| 289 |
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> { |
| 290 |
if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() { |
| 291 |
bail!("flow.toml already has a [runner] section; remove it to register this runner instead"); |
| 292 |
} |
| 293 |
let key = home.runner_key(name)?; |
| 294 |
let hub = hub.trim_end_matches('/').to_string(); |
| 295 |
let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 296 |
let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build(); |
| 297 |
let req = Register { |
| 298 |
token: token.trim().to_string(), |
| 299 |
name: name.to_string(), |
| 300 |
identity: hex::encode(key.identity.encode()), |
| 2d025bbf Andy McCall ↶ |
301 |
info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) }, |
| f98a8f78 Andy McCall ↶ |
302 |
}; |
| 303 |
let url = format!("{hub}/api/v1/flow/runners/register"); |
| 304 |
let got: Registered = match agent.post(&url).send_json(&req) { |
| 305 |
Ok(r) => r.into_json()?, |
| 306 |
Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()), |
| 307 |
Err(e) => bail!("{url}: {e}"), |
| 308 |
}; |
| 309 |
let mut text = format!( |
| 310 |
"# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n", |
| 311 |
got.credential, got.name |
| 312 |
); |
| 313 |
if let Some(ca) = &ca { |
| 314 |
text.push_str(&format!("ca = {:?}\n", ca.display().to_string())); |
| 315 |
} |
| 316 |
if !labels.is_empty() { |
| 317 |
text.push_str(&format!("labels = {labels:?}\n")); |
| 318 |
} |
| 319 |
let path = home.root.join(crate::home::RUNNER_TOML); |
| 320 |
{ |
| 321 |
use std::io::Write; |
| 322 |
use std::os::unix::fs::OpenOptionsExt; |
| 323 |
let tmp = path.with_extension("toml.tmp"); |
| 324 |
let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?; |
| 325 |
f.write_all(text.as_bytes())?; |
| 326 |
f.sync_all()?; |
| 327 |
std::fs::rename(&tmp, &path)?; |
| e0918b88 Andy McCall |
328 |
} |
| f98a8f78 Andy McCall ↶ |
329 |
Ok(got) |
| e0918b88 Andy McCall |
330 |
} |
| 1b2229da Andy McCall ↶ |
331 |
|
| 332 |
|
| 333 |
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)]; |
| 89171fca Andy McCall ↶ |
334 |
|
| 335 |
|
| 336 |
|
| 46fe2c81 Andy McCall ↶ |
337 |
|
| 338 |
|
| 339 |
fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> { |
| 340 |
let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy")) |
| 341 |
else { |
| 342 |
bail!("path {:?} is not a clean path under /src", i.path); |
| 343 |
}; |
| 344 |
let blob = Repo::blob_ref(bytes); |
| 345 |
if blob.to_hex() != i.blob { |
| 346 |
bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]); |
| 347 |
} |
| 348 |
let dest = tree.join(rel); |
| 349 |
if let Some(dir) = dest.parent() { |
| 350 |
std::fs::create_dir_all(dir)?; |
| 351 |
} |
| 352 |
std::fs::write(&dest, bytes)?; |
| 353 |
Ok(blob.0) |
| 354 |
} |
| 355 |
|
| 356 |
|
| 357 |
pub const MAX_INPUT_BYTES: usize = 512 << 20; |
| 358 |
|
| 89171fca Andy McCall ↶ |
359 |
const PATIENT_DELAYS: [Duration; 15] = { |
| 360 |
let mut d = [Duration::from_secs(15); 15]; |
| 361 |
d[0] = Duration::from_secs(1); |
| 362 |
d[1] = Duration::from_secs(2); |
| 363 |
d[2] = Duration::from_secs(4); |
| 364 |
d[3] = Duration::from_secs(8); |
| 365 |
d |
| 366 |
}; |
| 367 |
|
| 368 |
|
| 369 |
|
| 370 |
|
| 371 |
|
| 372 |
fn retrying( |
| 373 |
url: &str, |
| 374 |
delays: &[Duration], |
| 375 |
connection: bool, |
| 376 |
mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>, |
| 377 |
) -> Result<ureq::Response> { |
| 378 |
let mut delays = delays.iter(); |
| 379 |
loop { |
| 380 |
let why = match send().map_err(|e| *e) { |
| 381 |
Ok(r) => return Ok(r), |
| 382 |
Err(ureq::Error::Status(code, r)) if code < 500 => { |
| 383 |
let text = r.into_string().unwrap_or_default(); |
| 384 |
return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string())); |
| 385 |
} |
| 386 |
Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()), |
| 387 |
Err(e) if connection => e.to_string(), |
| 388 |
Err(e) => bail!("{url}: {e}"), |
| 389 |
}; |
| 390 |
match delays.next() { |
| 391 |
Some(d) => std::thread::sleep(*d), |
| 392 |
None => bail!("{url}: {why}"), |
| 393 |
} |
| 394 |
} |
| 395 |
} |
| e0918b88 Andy McCall |
396 |
|
| bb643c25 Andy McCall ↶ |
397 |
|
| 398 |
|
| 399 |
#[derive(Debug, Default, PartialEq, Eq)] |
| 400 |
pub struct Budget { |
| 401 |
pub max_jobs: usize, |
| 402 |
pub memory_mib: Option<u32>, |
| 403 |
pub cpus: Option<u32>, |
| 404 |
} |
| 405 |
|
| 406 |
|
| 407 |
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
| 408 |
pub struct Used { |
| 409 |
pub jobs: usize, |
| 410 |
pub memory_mib: u32, |
| 411 |
pub cpus: u32, |
| 412 |
} |
| 413 |
|
| 414 |
impl Budget { |
| 415 |
pub fn of(cfg: &RunnerConfig) -> Budget { |
| 416 |
Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus } |
| 417 |
} |
| 418 |
|
| 419 |
|
| 420 |
|
| 421 |
|
| 422 |
pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> { |
| 423 |
if used.jobs >= self.max_jobs { |
| 424 |
return None; |
| 425 |
} |
| 426 |
if used.jobs == 0 { |
| 427 |
return Some((None, None)); |
| 428 |
} |
| 429 |
let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib)); |
| 430 |
let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus)); |
| 431 |
if mem == Some(0) || cpus == Some(0) { |
| 432 |
return None; |
| 433 |
} |
| 434 |
Some((mem, cpus)) |
| 435 |
} |
| 436 |
} |
| 437 |
|
| c5b702e5 Andy McCall ↶ |
438 |
|
| ea56c56e Andy McCall ↶ |
439 |
|
| 440 |
|
| 441 |
|
| 442 |
|
| 443 |
|
| 444 |
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> { |
| 445 |
let mut have = Vec::new(); |
| 446 |
let mut missing = Vec::new(); |
| c5b702e5 Andy McCall ↶ |
447 |
let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm"); |
| 448 |
match (kvm, home.config.microvm.is_some()) { |
| ea56c56e Andy McCall ↶ |
449 |
(Ok(_), true) => have.push("microvm".to_string()), |
| 450 |
(Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()), |
| 451 |
(Err(e), _) => missing.push(format!( |
| 452 |
"microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group" |
| c5b702e5 Andy McCall ↶ |
453 |
)), |
| 454 |
} |
| ea56c56e Andy McCall ↶ |
455 |
match crate::syscall::available() { |
| 456 |
Ok(()) => have.push("syscall".to_string()), |
| 457 |
Err(e) => missing.push(format!("syscall: {e}")), |
| 458 |
} |
| 459 |
if have.is_empty() { |
| 460 |
return Err(format!("no isolation tier can run here: {}", missing.join("; "))); |
| 461 |
} |
| 462 |
Ok((have, missing)) |
| b11e30f5 Andy McCall ↶ |
463 |
} |
| 464 |
|
| 4cf0c99a Andy McCall ↶ |
465 |
|
| 466 |
|
| 467 |
|
| b14aeaae Andy McCall ↶ |
468 |
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) { |
| 4cf0c99a Andy McCall ↶ |
469 |
let hash = &claim.sandbox.rootfs; |
| 470 |
if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) { |
| 471 |
return; |
| 472 |
} |
| 473 |
let note = |text: String| { |
| 474 |
eprintln!("job {}: {text}", claim.job_id); |
| 475 |
hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok(); |
| 476 |
}; |
| 477 |
|
| 478 |
static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 479 |
let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner()); |
| 480 |
if home.rootfs(hash).is_file() { |
| 481 |
return; |
| 482 |
} |
| 483 |
let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) { |
| 484 |
Ok(c) => c, |
| 485 |
Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])), |
| 486 |
}; |
| b14aeaae Andy McCall ↶ |
487 |
let fetched = match swarm { |
| 488 |
Some(sw) => { |
| 489 |
note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12])); |
| 490 |
sw.fetch(home, hash) |
| 491 |
} |
| 492 |
None => { |
| 493 |
note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12])); |
| 494 |
client.fetch(home, hash) |
| 495 |
} |
| 496 |
}; |
| 497 |
match fetched { |
| 4cf0c99a Andy McCall ↶ |
498 |
Ok(Some(m)) => note(format!( |
| b14aeaae Andy McCall ↶ |
499 |
"image {}: fetched {} chunks, {}{} in {:.1} s", |
| 4cf0c99a Andy McCall ↶ |
500 |
&hash[..12], |
| 501 |
m.chunks, |
| 502 |
crate::images::human(m.bytes), |
| b14aeaae Andy McCall ↶ |
503 |
if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() }, |
| 4cf0c99a Andy McCall ↶ |
504 |
m.elapsed.as_secs_f64() |
| 505 |
)), |
| 506 |
Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])), |
| 507 |
Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])), |
| 508 |
} |
| 509 |
} |
| 510 |
|
| b11e30f5 Andy McCall ↶ |
511 |
|
| 512 |
|
| 513 |
|
| 514 |
|
| 515 |
fn preflight(home: &Home, claim: &Claim) -> Option<String> { |
| 516 |
let sb = &claim.sandbox; |
| 517 |
match sb.runtime { |
| 518 |
flow_core::Runtime::Microvm => { |
| 519 |
if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") { |
| 520 |
return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})")); |
| 521 |
} |
| 522 |
if home.config.microvm.is_none() { |
| 523 |
return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into()); |
| 524 |
} |
| 525 |
} |
| 526 |
flow_core::Runtime::Syscall => { |
| 527 |
if let Err(e) = crate::syscall::available() { |
| 528 |
return Some(format!("the syscall tier cannot run here: {e}")); |
| 529 |
} |
| 530 |
} |
| 531 |
flow_core::Runtime::Wasm => return Some("this runner has no wasm tier".into()), |
| 532 |
} |
| 533 |
sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib()) |
| 534 |
} |
| 535 |
|
| 536 |
|
| 537 |
|
| 538 |
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> { |
| 539 |
if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) { |
| 540 |
return Some(format!("the sandbox needs {want}; this runner is {here}")); |
| 541 |
} |
| 542 |
let short = &sb.rootfs[..12.min(sb.rootfs.len())]; |
| 543 |
let image = home.rootfs(&sb.rootfs); |
| 544 |
if !image.is_file() { |
| 545 |
return Some(format!("rootfs {short} is not on this runner")); |
| 546 |
} |
| 547 |
if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) { |
| 548 |
return Some(format!("rootfs {short} is for {arch}; this runner is {here}")); |
| 549 |
} |
| 550 |
if let Err(e) = home.verified(&image, &sb.rootfs) { |
| 551 |
return Some(format!("rootfs {short} does not verify here: {e:#}")); |
| 552 |
} |
| 553 |
if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) { |
| 554 |
return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib)); |
| 555 |
} |
| 556 |
None |
| 557 |
} |
| 558 |
|
| 559 |
|
| 560 |
fn available_memory_mib() -> Option<u64> { |
| 561 |
let info = std::fs::read_to_string("/proc/meminfo").ok()?; |
| 562 |
let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; |
| 563 |
Some(kib / 1024) |
| c5b702e5 Andy McCall ↶ |
564 |
} |
| 565 |
|
| e0918b88 Andy McCall |
566 |
pub fn serve(home: &Home) -> Result<()> { |
| 567 |
let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?; |
| c5b702e5 Andy McCall ↶ |
568 |
|
| 569 |
|
| ea56c56e Andy McCall ↶ |
570 |
let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?; |
| 571 |
for why in &missing { |
| 572 |
eprintln!("flow runner: not offering {why}"); |
| 573 |
} |
| e0918b88 Andy McCall |
574 |
let hub = Hub::new(&cfg)?; |
| 575 |
let name = cfg.name.clone().unwrap_or_else(|| "flow".into()); |
| bb643c25 Andy McCall ↶ |
576 |
let budget = Budget::of(&cfg); |
| 89171fca Andy McCall ↶ |
577 |
drain_on_stop(); |
| 47c1d64d Andy McCall ↶ |
578 |
let key = home.runner_key(&name)?; |
| b14aeaae Andy McCall ↶ |
579 |
|
| 580 |
|
| 581 |
let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| { |
| 582 |
eprintln!("flow runner: not joining the swarm: {e:#}"); |
| 583 |
None |
| 584 |
}); |
| 47c1d64d Andy McCall ↶ |
585 |
eprintln!( |
| c5b702e5 Andy McCall ↶ |
586 |
"flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}", |
| 47c1d64d Andy McCall ↶ |
587 |
cfg.hub, |
| 588 |
budget.max_jobs, |
| c5b702e5 Andy McCall ↶ |
589 |
runtimes.join(", "), |
| 47c1d64d Andy McCall ↶ |
590 |
key.identity.hash().to_hex() |
| 591 |
); |
| bb643c25 Andy McCall ↶ |
592 |
let used = std::sync::Mutex::new(Used::default()); |
| e0918b88 Andy McCall |
593 |
let mut idle = Duration::from_secs(2); |
| 3007d8f0 Andy McCall ↶ |
594 |
let pending = report_pending(home); |
| 1daca0b6 Andy McCall ↶ |
595 |
|
| 596 |
|
| 597 |
|
| 598 |
let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new()); |
| 599 |
held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job)); |
| 3007d8f0 Andy McCall ↶ |
600 |
std::thread::scope(|scope| { |
| 601 |
for (job, f) in pending { |
| 1daca0b6 Andy McCall ↶ |
602 |
let (hub, held) = (&hub, &held); |
| 3007d8f0 Andy McCall ↶ |
603 |
scope.spawn(move || { |
| 604 |
eprintln!("job {job}: reporting the result kept from before the restart"); |
| 605 |
report_finish(home, hub, job, &f); |
| 1daca0b6 Andy McCall ↶ |
606 |
held.lock().unwrap().remove(&job); |
| 3007d8f0 Andy McCall ↶ |
607 |
}); |
| 608 |
} |
| 62829d15 Andy McCall ↶ |
609 |
|
| 610 |
|
| 611 |
let measure = || { |
| 612 |
let (speed, ms) = crate::facts::bench(); |
| 613 |
eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed)); |
| 614 |
speed |
| 615 |
}; |
| 616 |
let mut speed = measure(); |
| 617 |
let mut benched = Instant::now(); |
| 89171fca Andy McCall ↶ |
618 |
let mut said_draining = false; |
| a4b3f8cb Andy McCall ↶ |
619 |
|
| 620 |
|
| 621 |
let mut heard = Instant::now(); |
| 622 |
let beat = |heard: &mut Instant| { |
| 623 |
if heard.elapsed() >= HUB_HEARTBEAT { |
| 624 |
if let Err(e) = hub.heartbeat() { |
| 625 |
eprintln!("flow runner {name}: heartbeat: {e:#}"); |
| 626 |
} |
| 627 |
*heard = Instant::now(); |
| 628 |
} |
| 629 |
}; |
| 3007d8f0 Andy McCall ↶ |
630 |
loop { |
| 89171fca Andy McCall ↶ |
631 |
|
| 632 |
|
| 633 |
if stopping() { |
| 634 |
let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len()); |
| 635 |
if jobs == 0 && holding == 0 { |
| 636 |
eprintln!("flow runner {name}: drained, stopping"); |
| 637 |
break; |
| 638 |
} |
| 639 |
if !said_draining { |
| 640 |
eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)"); |
| 641 |
said_draining = true; |
| 642 |
} |
| a4b3f8cb Andy McCall ↶ |
643 |
beat(&mut heard); |
| 89171fca Andy McCall ↶ |
644 |
std::thread::sleep(Duration::from_millis(500)); |
| 645 |
continue; |
| 646 |
} |
| 62829d15 Andy McCall ↶ |
647 |
if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) { |
| 648 |
speed = measure(); |
| 649 |
benched = Instant::now(); |
| 650 |
} |
| 3007d8f0 Andy McCall ↶ |
651 |
let room = budget.room(*used.lock().unwrap()); |
| 652 |
let Some((free_memory_mib, free_cpus)) = room else { |
| a4b3f8cb Andy McCall ↶ |
653 |
beat(&mut heard); |
| 3007d8f0 Andy McCall ↶ |
654 |
std::thread::sleep(Duration::from_millis(500)); |
| 655 |
continue; |
| 656 |
}; |
| a4b3f8cb Andy McCall ↶ |
657 |
heard = Instant::now(); |
| 1e6b9fb1 Andy McCall ↶ |
658 |
let cache_times = crate::cache::Pool::new(&home.root, cfg.cache_gb).slot_times(); |
| 3007d8f0 Andy McCall ↶ |
659 |
let req = ClaimRequest { |
| 660 |
runner: name.clone(), |
| c5b702e5 Andy McCall ↶ |
661 |
runtimes: runtimes.clone(), |
| 3007d8f0 Andy McCall ↶ |
662 |
free_memory_mib, |
| 663 |
free_cpus, |
| 664 |
info: Some(RunnerInfo { |
| 665 |
version: env!("OXY_VERSION_LONG").into(), |
| ea81796b Andy McCall ↶ |
666 |
..RunnerInfo::here(cfg.offered_labels(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus) |
| 3007d8f0 Andy McCall ↶ |
667 |
}), |
| 8c0a4e3a Andy McCall ↶ |
668 |
|
| 669 |
images: Some(home.images()), |
| 1abadd4a Andy McCall ↶ |
670 |
image_archs: Some(home.image_archs()), |
| 1daca0b6 Andy McCall ↶ |
671 |
holding: Some(held.lock().unwrap().iter().copied().collect()), |
| 4cf0c99a Andy McCall ↶ |
672 |
fetches_images: true, |
| 62829d15 Andy McCall ↶ |
673 |
facts: Some(crate::facts::now(home, Some(speed))), |
| b14aeaae Andy McCall ↶ |
674 |
swarm: swarm.as_ref().map(|s| s.addr.clone()), |
| 1e6b9fb1 Andy McCall ↶ |
675 |
caches: Some(cache_times.keys().cloned().collect()), |
| 676 |
cache_times: Some(cache_times), |
| 3007d8f0 Andy McCall ↶ |
677 |
}; |
| 678 |
match hub.claim(&req) { |
| 679 |
Ok(Some(claim)) => { |
| 680 |
idle = Duration::from_secs(2); |
| 681 |
let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus); |
| 682 |
{ |
| 683 |
let mut u = used.lock().unwrap(); |
| 684 |
u.jobs += 1; |
| 685 |
u.memory_mib += mem; |
| 686 |
u.cpus += cpus; |
| bb643c25 Andy McCall ↶ |
687 |
} |
| 1daca0b6 Andy McCall ↶ |
688 |
held.lock().unwrap().insert(claim.job_id); |
| b14aeaae Andy McCall ↶ |
689 |
let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref()); |
| 3007d8f0 Andy McCall ↶ |
690 |
scope.spawn(move || { |
| 691 |
let id = claim.job_id; |
| b14aeaae Andy McCall ↶ |
692 |
fetch_image(home, hub, cfg, swarm, &claim); |
| b11e30f5 Andy McCall ↶ |
693 |
if let Some(why) = preflight(home, &claim) { |
| 694 |
eprintln!("job {id}: declined: {why}"); |
| 695 |
if let Err(e) = hub.decline(id, &why) { |
| 696 |
eprintln!("job {id}: decline: {e:#}"); |
| 697 |
} |
| 698 |
held.lock().unwrap().remove(&id); |
| 699 |
let mut u = used.lock().unwrap(); |
| 700 |
u.jobs -= 1; |
| 701 |
u.memory_mib -= mem; |
| 702 |
u.cpus -= cpus; |
| 703 |
return; |
| 704 |
} |
| 3007d8f0 Andy McCall ↶ |
705 |
eprintln!( |
| 706 |
"job {id}: {} {} on {}", |
| 707 |
claim.repo, |
| 708 |
claim.job.name, |
| 709 |
claim.heads.first().map(|h| &h[..12]).unwrap_or("-") |
| 710 |
); |
| 711 |
let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish { |
| 712 |
exit_code: None, |
| 713 |
duration_ms: 0, |
| 714 |
receipt: None, |
| 715 |
signed_receipt: None, |
| 716 |
error: Some(format!("{e:#}")), |
| 8f211e20 Andy McCall ↶ |
717 |
peak_disk_mib: None, |
| 718 |
peak_memory_mib: None, |
| 3007d8f0 Andy McCall ↶ |
719 |
}); |
| 720 |
eprintln!( |
| 721 |
"job {id}: exit {:?}{}", |
| 722 |
finish.exit_code, |
| 723 |
finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default() |
| 724 |
); |
| 280ef951 Andy McCall ↶ |
725 |
if finish.error.as_deref() != Some(CANCELLED) { |
| 726 |
report_finish(home, hub, id, &finish); |
| 727 |
} |
| 1daca0b6 Andy McCall ↶ |
728 |
held.lock().unwrap().remove(&id); |
| 3007d8f0 Andy McCall ↶ |
729 |
let mut u = used.lock().unwrap(); |
| 730 |
u.jobs -= 1; |
| 731 |
u.memory_mib -= mem; |
| 732 |
u.cpus -= cpus; |
| 733 |
}); |
| 734 |
} |
| 735 |
Ok(None) => std::thread::sleep(idle), |
| 736 |
Err(e) => { |
| 737 |
eprintln!("claim: {e:#}"); |
| 738 |
std::thread::sleep(idle); |
| 739 |
idle = (idle * 2).min(Duration::from_secs(60)); |
| 740 |
} |
| e0918b88 Andy McCall |
741 |
} |
| 742 |
} |
| 89171fca Andy McCall ↶ |
743 |
Ok(()) |
| bb643c25 Andy McCall ↶ |
744 |
}) |
| 89171fca Andy McCall ↶ |
745 |
} |
| 746 |
|
| 747 |
|
| 748 |
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); |
| 749 |
|
| 750 |
extern "C" fn on_stop(_: libc::c_int) { |
| 751 |
|
| 752 |
if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 { |
| 753 |
unsafe { libc::_exit(143) }; |
| 754 |
} |
| 755 |
} |
| 756 |
|
| 757 |
|
| 758 |
|
| 759 |
|
| 760 |
fn drain_on_stop() { |
| 761 |
let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t; |
| 762 |
unsafe { |
| 763 |
libc::signal(libc::SIGTERM, handler); |
| 764 |
libc::signal(libc::SIGINT, handler); |
| 765 |
} |
| e0918b88 Andy McCall |
766 |
} |
| 767 |
|
| 89171fca Andy McCall ↶ |
768 |
fn stopping() -> bool { |
| 769 |
STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0 |
| 770 |
} |
| 771 |
|
| 280ef951 Andy McCall ↶ |
772 |
|
| 773 |
|
| 774 |
|
| e0918b88 Andy McCall |
775 |
struct LogSink<'a> { |
| 776 |
hub: &'a Hub, |
| 777 |
job: i64, |
| 778 |
buf: Vec<Record>, |
| 779 |
last: Instant, |
| 280ef951 Andy McCall ↶ |
780 |
gone: bool, |
| 781 |
} |
| 89171fca Andy McCall ↶ |
782 |
|
| 783 |
|
| 784 |
const BATCH: usize = 200; |
| 280ef951 Andy McCall ↶ |
785 |
|
| 89171fca Andy McCall ↶ |
786 |
|
| 787 |
const MAX_UNSENT: usize = 50_000; |
| 788 |
|
| 280ef951 Andy McCall ↶ |
789 |
|
| 790 |
const HEARTBEAT: Duration = Duration::from_secs(5); |
| 791 |
|
| 792 |
impl microvm::Sink for LogSink<'_> { |
| 793 |
fn record(&mut self, r: Record) { |
| 794 |
self.push(r) |
| 795 |
} |
| 796 |
|
| 797 |
fn cancelled(&mut self) -> bool { |
| 798 |
if !self.gone && self.last.elapsed() >= HEARTBEAT { |
| 799 |
self.send(); |
| 800 |
} |
| 801 |
self.gone |
| 802 |
} |
| e0918b88 Andy McCall |
803 |
} |
| 804 |
|
| 805 |
impl LogSink<'_> { |
| 806 |
fn push(&mut self, mut r: Record) { |
| 280ef951 Andy McCall ↶ |
807 |
if self.gone { |
| 808 |
return; |
| 809 |
} |
| e0918b88 Andy McCall |
810 |
if r.text.len() > MAX_RECORD_TEXT { |
| 811 |
let mut cut = MAX_RECORD_TEXT; |
| 812 |
while !r.text.is_char_boundary(cut) { |
| 813 |
cut -= 1; |
| 814 |
} |
| 815 |
r.text.truncate(cut); |
| 816 |
} |
| 817 |
self.buf.push(r); |
| 89171fca Andy McCall ↶ |
818 |
if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) { |
| e0918b88 Andy McCall |
819 |
self.flush(); |
| 820 |
} |
| 821 |
} |
| 822 |
|
| 823 |
fn flush(&mut self) { |
| 824 |
if !self.buf.is_empty() { |
| 280ef951 Andy McCall ↶ |
825 |
self.send(); |
| 826 |
} |
| 827 |
self.last = Instant::now(); |
| 828 |
} |
| 829 |
|
| 89171fca Andy McCall ↶ |
830 |
|
| 831 |
|
| 280ef951 Andy McCall ↶ |
832 |
fn send(&mut self) { |
| 89171fca Andy McCall ↶ |
833 |
loop { |
| 834 |
if self.gone { |
| 835 |
return; |
| 836 |
} |
| 837 |
let n = self.buf.len().min(BATCH); |
| 838 |
let batch: Vec<Record> = self.buf.drain(..n).collect(); |
| 839 |
match self.hub.log(self.job, batch.clone()) { |
| 840 |
Ok(()) => {} |
| 841 |
Err(e) if job_gone(&e) => self.gone = true, |
| 842 |
Err(e) => { |
| 843 |
|
| 844 |
|
| 845 |
|
| 846 |
eprintln!("job {}: log: {e:#}; kept for the next batch", self.job); |
| 847 |
let mut kept = batch; |
| 848 |
kept.append(&mut self.buf); |
| 849 |
let over = kept.len().saturating_sub(MAX_UNSENT); |
| 850 |
if over > 0 { |
| 851 |
kept.drain(..over); |
| 852 |
kept.insert( |
| 853 |
0, |
| 854 |
Record { |
| 855 |
t: 0, |
| 856 |
stream: "flow".into(), |
| 857 |
text: format!("({over} log lines lost while the hub could not be reached)"), |
| 858 |
}, |
| 859 |
); |
| 860 |
} |
| 861 |
self.buf = kept; |
| 862 |
break; |
| 863 |
} |
| 864 |
} |
| 865 |
if self.buf.is_empty() { |
| 866 |
break; |
| 867 |
} |
| e0918b88 Andy McCall |
868 |
} |
| 869 |
self.last = Instant::now(); |
| 870 |
} |
| 871 |
} |
| a4b3f8cb Andy McCall ↶ |
872 |
|
| 873 |
|
| 874 |
|
| 875 |
const HUB_HEARTBEAT: Duration = Duration::from_secs(30); |
| bb643c25 Andy McCall ↶ |
876 |
|
| 877 |
|
| 878 |
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| e0918b88 Andy McCall |
879 |
|
| 47c1d64d Andy McCall ↶ |
880 |
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> { |
| e0918b88 Andy McCall |
881 |
let started = Instant::now(); |
| 280ef951 Andy McCall ↶ |
882 |
let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false }; |
| e0918b88 Andy McCall |
883 |
let note = |
| 884 |
|sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text }); |
| 885 |
let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?; |
| 886 |
note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"))); |
| bb643c25 Andy McCall ↶ |
887 |
|
| 888 |
|
| 889 |
|
| 890 |
|
| 891 |
let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner())); |
| e0918b88 Andy McCall |
892 |
let repo = cached_repo(home, cfg, claim)?; |
| 893 |
let scratch = home.root.join("work").join(format!("job-{}", claim.job_id)); |
| 894 |
let _ = std::fs::remove_dir_all(&scratch); |
| 895 |
std::fs::create_dir_all(scratch.join("tree"))?; |
| 896 |
let result = (|| -> Result<Finish> { |
| c23d5191 Andy McCall ↶ |
897 |
let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?; |
| 898 |
|
| 899 |
|
| 900 |
for sub in &claim.subrepos { |
| 901 |
if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") { |
| 902 |
bail!("subrepo path {:?} is not a clean relative path", sub.path); |
| 903 |
} |
| 904 |
note( |
| 905 |
&mut sink, |
| 906 |
format!( |
| 907 |
"fetching subrepo {} at {} for {}", |
| 908 |
sub.repo, |
| 909 |
sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"), |
| 910 |
sub.path |
| 911 |
), |
| 912 |
); |
| 913 |
let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?; |
| 914 |
let sub_heads: Vec<ChangeHash> = |
| 915 |
sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?; |
| 916 |
let dir = scratch.join("tree").join(&sub.path); |
| 917 |
std::fs::create_dir_all(&dir)?; |
| 918 |
let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?; |
| 919 |
files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h))); |
| 920 |
} |
| e0918b88 Andy McCall |
921 |
let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h))); |
| 46fe2c81 Andy McCall ↶ |
922 |
|
| 923 |
|
| 924 |
|
| 925 |
let mut input_blobs = Vec::new(); |
| 926 |
for (index, i) in claim.inputs.iter().enumerate() { |
| 927 |
let what = format!("input {} from {}", i.artifact, i.from); |
| 928 |
let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?; |
| 929 |
let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?; |
| 930 |
note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12])); |
| 931 |
input_blobs.push(blob); |
| 932 |
} |
| ea56c56e Andy McCall ↶ |
933 |
|
| 934 |
let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall; |
| 82cf6e91 Andy McCall ↶ |
935 |
let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env()); |
| 936 |
|
| 937 |
|
| 938 |
|
| 939 |
|
| 940 |
let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb); |
| 941 |
let cache_ctx = |
| 942 |
crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs }; |
| 943 |
let mut cached: Vec<(usize, String, String)> = Vec::new(); |
| 944 |
let mut built_from: Option<crate::cache::Sources> = None; |
| 945 |
if !syscall_tier { |
| 946 |
for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() { |
| 947 |
let Some(name) = &m.cache else { continue }; |
| 948 |
let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h))); |
| 949 |
let how = match pool.take(&cache_ctx, m, &key, &scratch.join(format!("mount-{i}.ext4"))) { |
| 950 |
Some(t) => { |
| 951 |
if built_from.is_none() { |
| 952 |
built_from = t.sources; |
| 953 |
} |
| 954 |
t.how |
| 955 |
} |
| 956 |
None => format!("cache {name}: none kept yet, an empty disk"), |
| 957 |
}; |
| 958 |
note(&mut sink, format!("{} ({})", how, m.target)); |
| 959 |
cached.push((i, key, how)); |
| 960 |
} |
| 961 |
} |
| 962 |
let sources = if cached.is_empty() { |
| 963 |
crate::cache::Sources::new() |
| 964 |
} else { |
| 965 |
let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref()); |
| 966 |
if built_from.is_some() { |
| 967 |
note(&mut sink, format!("cache: {restored} of {} files unchanged since the cached build, their times kept", sources.len())); |
| 968 |
} |
| 969 |
sources |
| 970 |
}; |
| ea56c56e Andy McCall ↶ |
971 |
if !syscall_tier { |
| 972 |
workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?; |
| 973 |
std::fs::remove_dir_all(scratch.join("tree"))?; |
| 974 |
} |
| bb643c25 Andy McCall ↶ |
975 |
preparing.take(); |
| ea56c56e Andy McCall ↶ |
976 |
|
| 280ef951 Andy McCall ↶ |
977 |
if microvm::Sink::cancelled(&mut sink) { |
| 978 |
return Ok(cancelled()); |
| 979 |
} |
| e0918b88 Andy McCall |
980 |
note( |
| 981 |
&mut sink, |
| 982 |
format!( |
| ea56c56e Andy McCall ↶ |
983 |
"{}: {} {}, {} MiB, rootfs {}", |
| 984 |
if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" }, |
| e0918b88 Andy McCall |
985 |
claim.sandbox.cpus, |
| ea56c56e Andy McCall ↶ |
986 |
if syscall_tier { "CPU" } else { "vCPU" }, |
| e0918b88 Andy McCall |
987 |
claim.sandbox.memory_mib, |
| 988 |
&claim.sandbox.rootfs[..12] |
| 989 |
), |
| 990 |
); |
| ea56c56e Andy McCall ↶ |
991 |
let timeout = Duration::from_secs(claim.timeout_s); |
| d60aac6c Andy McCall ↶ |
992 |
let mut outcome = if syscall_tier { |
| ea56c56e Andy McCall ↶ |
993 |
let group = format!("job-{}", claim.job_id); |
| 994 |
crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)? |
| 995 |
} else { |
| 996 |
microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)? |
| 997 |
}; |
| 280ef951 Andy McCall ↶ |
998 |
if outcome.cancelled { |
| 999 |
return Ok(cancelled()); |
| d60aac6c Andy McCall ↶ |
1000 |
} |
| 1001 |
|
| 1002 |
let disk_mounts: Vec<&flow_core::pipeline::Mount> = |
| 1003 |
job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect(); |
| 1004 |
for (i, key, how) in &cached { |
| 1005 |
outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}"))); |
| 280ef951 Andy McCall ↶ |
1006 |
} |
| a67b97e9 Andy McCall ↶ |
1007 |
|
| 9a6fac2b Andy McCall ↶ |
1008 |
|
| 1009 |
|
| 1010 |
|
| a67b97e9 Andy McCall ↶ |
1011 |
let mut outputs = vec![]; |
| 1012 |
let mut output_error = None; |
| 9a6fac2b Andy McCall ↶ |
1013 |
let passed = outcome.exit_code == Some(0); |
| 1014 |
let wanted = claim.job.outputs.iter().filter(|o| passed || o.always); |
| 1015 |
for o in wanted { |
| 6d53fb7d Andy McCall ↶ |
1016 |
if o.kind == flow_core::OutputKind::Report { |
| 1017 |
let read = if syscall_tier { |
| 1018 |
let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
| 1019 |
crate::outputs::read_report_dir(&dir, &inner) |
| 1020 |
} else { |
| 1021 |
let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
| 1022 |
crate::outputs::read_report(&image, &inner, &scratch) |
| 1023 |
}; |
| 1024 |
let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files)); |
| 1025 |
match kept { |
| 1026 |
Ok((blob, files, bytes)) => { |
| 1027 |
let unattested = if passed { "" } else { " (the job failed: not in its receipt)" }; |
| 1028 |
let hex = blob.to_hex(); |
| 1029 |
note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12])); |
| 1030 |
if passed { |
| 1031 |
outputs.push(blob.0); |
| 1032 |
} |
| 1033 |
} |
| 1034 |
Err(e) if passed => { |
| 1035 |
output_error = Some(format!("report {}: {e:#}", o.name)); |
| 1036 |
break; |
| 1037 |
} |
| 1038 |
Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)), |
| 1039 |
} |
| 1040 |
continue; |
| 1041 |
} |
| 9a6fac2b Andy McCall ↶ |
1042 |
let read = if syscall_tier { |
| 1043 |
let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
| 1044 |
crate::outputs::read_dir_file(&dir, &inner) |
| 1045 |
} else { |
| 1046 |
let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
| 1047 |
crate::outputs::read(&image, &inner, &scratch) |
| 1048 |
}; |
| 1049 |
let kept = read.and_then(|bytes| { |
| 1050 |
let blob = Repo::blob_ref(&bytes); |
| 1051 |
let named = hub.artifact(claim.job_id, &o.name, &bytes)?; |
| 1052 |
if named != blob.to_hex() { |
| 1053 |
bail!("the hub kept it as {named}, not {}", blob.to_hex()); |
| 1054 |
} |
| 1055 |
let unattested = if passed { "" } else { " (the job failed: not in its receipt)" }; |
| 1056 |
note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12])); |
| 1057 |
Ok(blob.0) |
| 1058 |
}); |
| 1059 |
match kept { |
| 1060 |
Ok(b) if passed => outputs.push(b), |
| 1061 |
Ok(_) => {} |
| 1062 |
Err(e) if passed => { |
| 1063 |
output_error = Some(format!("artifact {}: {e:#}", o.name)); |
| 1064 |
break; |
| a67b97e9 Andy McCall ↶ |
1065 |
} |
| 9a6fac2b Andy McCall ↶ |
1066 |
Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)), |
| a67b97e9 Andy McCall ↶ |
1067 |
} |
| 1068 |
} |
| 1069 |
if let Some(e) = &output_error { |
| 1070 |
note(&mut sink, e.clone()); |
| 1071 |
} |
| 1072 |
let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt { |
| e0918b88 Andy McCall |
1073 |
job_name: claim.job.name.clone(), |
| 1074 |
pipeline_hash: hex32(&claim.pipeline_hash), |
| 1075 |
scope_tree_hash: scope_hash, |
| 46fe2c81 Andy McCall ↶ |
1076 |
inputs: input_blobs.clone(), |
| e0918b88 Andy McCall |
1077 |
environment: outcome.environment.hash(), |
| a67b97e9 Andy McCall ↶ |
1078 |
outputs, |
| e0918b88 Andy McCall |
1079 |
exit_code: code as u32, |
| 1080 |
duration_ms: outcome.duration_ms, |
| 1081 |
}); |
| 47c1d64d Andy McCall ↶ |
1082 |
let signed_receipt = match &receipt { |
| 35f7a849 Andy McCall ↶ |
1083 |
Some(r) => { |
| 1084 |
let mut signed = sign_receipt(&repo, &heads, cfg, key, claim, r)?; |
| 1085 |
if cfg.keyless { |
| 1086 |
match sign_keyless(&repo, &heads, cfg, hub, claim, r) { |
| 1087 |
Ok(k) => signed = k, |
| 1088 |
Err(e) => { |
| 1089 |
note(&mut sink, format!("receipt: signed with the runner's key; a short-lived key was not certified: {e:#}")) |
| 1090 |
} |
| 1091 |
} |
| 1092 |
} |
| 1093 |
Some(hex::encode(signed.encode())) |
| 1094 |
} |
| 47c1d64d Andy McCall ↶ |
1095 |
None => None, |
| 1096 |
}; |
| d60aac6c Andy McCall ↶ |
1097 |
|
| 1098 |
let peak_disk_mib = crate::facts::allocated_mib(&scratch); |
| 1099 |
|
| 1100 |
|
| 1101 |
if passed && output_error.is_none() { |
| 1102 |
for (i, key, _) in &cached { |
| 82cf6e91 Andy McCall ↶ |
1103 |
if let Err(e) = pool.keep(&cache_ctx, disk_mounts[*i], key, &scratch.join(format!("mount-{i}.ext4")), &sources) { |
| d60aac6c Andy McCall ↶ |
1104 |
note(&mut sink, format!("cache: not kept: {e:#}")); |
| 1105 |
} |
| 1106 |
} |
| 1107 |
} |
| e0918b88 Andy McCall |
1108 |
Ok(Finish { |
| 1109 |
exit_code: outcome.exit_code, |
| 1110 |
duration_ms: started.elapsed().as_millis() as u64, |
| 1111 |
receipt, |
| 47c1d64d Andy McCall ↶ |
1112 |
signed_receipt, |
| a67b97e9 Andy McCall ↶ |
1113 |
error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())), |
| 8f211e20 Andy McCall ↶ |
1114 |
|
| 1115 |
|
| d60aac6c Andy McCall ↶ |
1116 |
peak_disk_mib, |
| 8f211e20 Andy McCall ↶ |
1117 |
peak_memory_mib: outcome.peak_memory_mib, |
| e0918b88 Andy McCall |
1118 |
}) |
| 1119 |
})(); |
| ea56c56e Andy McCall ↶ |
1120 |
let _ = workspace::remove_tree(&scratch); |
| e0918b88 Andy McCall |
1121 |
sink.flush(); |
| 1122 |
result |
| 47c1d64d Andy McCall ↶ |
1123 |
} |
| 1124 |
|
| 1125 |
|
| 1126 |
|
| 1127 |
|
| 6d53fb7d Andy McCall ↶ |
1128 |
|
| 1129 |
|
| 1130 |
|
| 1131 |
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> { |
| 1132 |
let mut listed = Vec::with_capacity(files.len()); |
| 1133 |
let mut total = 0u64; |
| 1134 |
for (path, bytes) in files { |
| 1135 |
let blob = Repo::blob_ref(bytes).to_hex(); |
| 1136 |
let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?; |
| 1137 |
if named != blob { |
| 1138 |
bail!("the hub kept {path} as {named}, not {blob}"); |
| 1139 |
} |
| 1140 |
total += bytes.len() as u64; |
| 1141 |
listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() })); |
| 1142 |
} |
| 1143 |
let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?; |
| 1144 |
let blob = Repo::blob_ref(&manifest); |
| 1145 |
let named = hub.artifact(job, name, &manifest)?; |
| 1146 |
if named != blob.to_hex() { |
| 1147 |
bail!("the hub kept the manifest as {named}, not {}", blob.to_hex()); |
| 1148 |
} |
| 1149 |
Ok((blob, files.len(), total)) |
| 1150 |
} |
| 1151 |
|
| 47c1d64d Andy McCall ↶ |
1152 |
fn sign_receipt( |
| 1153 |
repo: &Repo, |
| 1154 |
heads: &[ChangeHash], |
| 1155 |
cfg: &RunnerConfig, |
| 1156 |
key: &RunnerKey, |
| 1157 |
claim: &Claim, |
| 1158 |
receipt: &Receipt, |
| 1159 |
) -> Result<oxy_core::receipt::SignedReceipt> { |
| 1160 |
let state = state_of(heads); |
| 1161 |
let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number); |
| 1162 |
Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret)) |
| 1163 |
} |
| 1164 |
|
| 35f7a849 Andy McCall ↶ |
1165 |
|
| 1166 |
|
| 1167 |
|
| 1168 |
|
| 1169 |
fn sign_keyless( |
| 1170 |
repo: &Repo, |
| 1171 |
heads: &[ChangeHash], |
| 1172 |
cfg: &RunnerConfig, |
| 1173 |
hub: &Hub, |
| 1174 |
claim: &Claim, |
| 1175 |
receipt: &Receipt, |
| 1176 |
) -> Result<oxy_core::receipt::SignedReceipt> { |
| 1177 |
let sk = oxy_core::identity::SecretKey::generate(); |
| 1178 |
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); |
| 1179 |
let name = format!("{} job {}", cfg.name.as_deref().unwrap_or("runner"), claim.job_id); |
| 1180 |
let id = sk.identity(&name, "", now); |
| 1181 |
let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge(&claim.token)).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1182 |
let (cert, issuer) = hub.certify(&claim.token, &id, &proof)?; |
| 1183 |
let state = state_of(heads); |
| 1184 |
let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number); |
| 1185 |
Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &id, &sk).with_certificate(cert, issuer)) |
| 1186 |
} |
| 1187 |
|
| 47c1d64d Andy McCall ↶ |
1188 |
|
| 1189 |
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef { |
| 1190 |
use oxy_core::hash::{hash_object, ObjectType}; |
| 1191 |
hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode()) |
| e0918b88 Andy McCall |
1192 |
} |
| 1193 |
|
| 1194 |
fn hex32(s: &str) -> [u8; 32] { |
| 1195 |
let mut out = [0; 32]; |
| 1196 |
if let Ok(v) = hex::decode(s) { |
| 1197 |
if v.len() == 32 { |
| 1198 |
out.copy_from_slice(&v); |
| 1199 |
} |
| 1200 |
} |
| 1201 |
out |
| 1202 |
} |
| 1203 |
|
| f53e6d47 Andy McCall ↶ |
1204 |
|
| 1205 |
|
| 1206 |
|
| 1207 |
fn transient(msg: &str) -> bool { |
| 1208 |
let m = msg.to_lowercase(); |
| 1209 |
[ |
| 1210 |
"dns failed", |
| 1211 |
"name resolution", |
| 1212 |
"connection refused", |
| 1213 |
"connection reset", |
| 1214 |
"timed out", |
| 1215 |
"timeout", |
| 1216 |
"http 502", |
| 1217 |
"http 503", |
| 1218 |
"http 504", |
| 1219 |
"broken pipe", |
| 1220 |
"unexpected eof", |
| 1221 |
] |
| 1222 |
.iter() |
| 1223 |
.any(|k| m.contains(k)) |
| 1224 |
} |
| 1225 |
|
| 1226 |
|
| 1227 |
|
| 1228 |
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> { |
| 1229 |
let mut tries = waits.iter(); |
| 1230 |
loop { |
| 1231 |
match f() { |
| 1232 |
Ok(v) => return Ok(v), |
| 1233 |
Err(e) => match tries.next() { |
| 1234 |
Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w), |
| 1235 |
_ => return Err(e), |
| 1236 |
}, |
| 1237 |
} |
| 1238 |
} |
| 1239 |
} |
| 1240 |
|
| 1241 |
|
| 1242 |
const FETCH_WAITS: [Duration; 5] = |
| 1243 |
[Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)]; |
| 1244 |
|
| e0918b88 Andy McCall |
1245 |
|
| 1246 |
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> { |
| c23d5191 Andy McCall ↶ |
1247 |
cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token) |
| 1248 |
} |
| 1249 |
|
| 1250 |
|
| 1251 |
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> { |
| 1252 |
let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__")); |
| e0918b88 Andy McCall |
1253 |
let set_remote = |repo: &mut Repo| -> Result<()> { |
| c23d5191 Andy McCall ↶ |
1254 |
repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1255 |
repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?; |
| e0918b88 Andy McCall |
1256 |
if let Some(ca) = &cfg.ca { |
| 1257 |
repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1258 |
} |
| 1259 |
repo.save_config().map_err(|e| anyhow::anyhow!("{e}")) |
| 1260 |
}; |
| 1261 |
if dir.join(".oxy").is_dir() { |
| 1262 |
let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 1263 |
set_remote(&mut repo)?; |
| f53e6d47 Andy McCall ↶ |
1264 |
with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?; |
| e0918b88 Andy McCall |
1265 |
return Ok(repo); |
| 1266 |
} |
| 1267 |
|
| c23d5191 Andy McCall ↶ |
1268 |
std::env::set_var("OXY_TOKEN", token); |
| e0918b88 Andy McCall |
1269 |
if let Some(ca) = &cfg.ca { |
| 1270 |
std::env::set_var("OXY_CA", ca); |
| 1271 |
} |
| f53e6d47 Andy McCall ↶ |
1272 |
let cloned = with_retries(&FETCH_WAITS, || { |
| 1273 |
let _ = std::fs::remove_dir_all(&dir); |
| 1274 |
Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}")) |
| 1275 |
}); |
| e0918b88 Andy McCall |
1276 |
std::env::remove_var("OXY_TOKEN"); |
| 1277 |
std::env::remove_var("OXY_CA"); |
| 1278 |
let mut repo = cloned.map_err(|e| { |
| 1279 |
let _ = std::fs::remove_dir_all(&dir); |
| c23d5191 Andy McCall ↶ |
1280 |
anyhow::anyhow!("clone {}: {e}", clone_url) |
| e0918b88 Andy McCall |
1281 |
})?; |
| 1282 |
set_remote(&mut repo)?; |
| 1283 |
Ok(repo) |
| bb643c25 Andy McCall ↶ |
1284 |
} |
| 1285 |
|
| 1286 |
#[cfg(test)] |
| 1287 |
mod tests { |
| 46fe2c81 Andy McCall ↶ |
1288 |
|
| 1289 |
#[test] |
| 1290 |
fn an_input_is_placed_only_when_it_is_what_the_receipt_names() { |
| 1291 |
let dir = tempfile::tempdir().unwrap(); |
| 1292 |
let bytes = b"the reviewed plan"; |
| 1293 |
let blob = Repo::blob_ref(bytes).to_hex(); |
| 1294 |
let input = |path: &str, blob: &str| flow_core::api::ArtifactInput { |
| 1295 |
artifact: "tfplan".into(), |
| 1296 |
from: "plan".into(), |
| 1297 |
path: path.into(), |
| 1298 |
blob: blob.into(), |
| 1299 |
}; |
| 1300 |
let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap(); |
| 1301 |
assert_eq!(hex::encode(got), blob); |
| 1302 |
assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes); |
| 1303 |
let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err(); |
| 1304 |
assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}"); |
| 1305 |
assert!(!dir.path().join("other").exists(), "nothing written when it does not match"); |
| 1306 |
for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] { |
| 1307 |
assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}"); |
| 1308 |
} |
| 1309 |
} |
| 1310 |
|
| bb643c25 Andy McCall ↶ |
1311 |
use super::*; |
| b11e30f5 Andy McCall ↶ |
1312 |
|
| 1313 |
#[test] |
| 1314 |
fn a_runner_names_what_stops_a_sandbox() { |
| 1315 |
let dir = tempfile::tempdir().unwrap(); |
| 1316 |
let home = Home::locate(Some(dir.path())).unwrap(); |
| 1317 |
let bytes = b"not really ext4"; |
| 1318 |
let hash = blake3::hash(bytes).to_hex().to_string(); |
| 1319 |
let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({ |
| 1320 |
"name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated" |
| 1321 |
})) |
| 1322 |
.unwrap(); |
| 1323 |
let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free); |
| 1324 |
assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner"); |
| 1325 |
std::fs::write(home.rootfs(&hash), bytes).unwrap(); |
| 1326 |
sb.rootfs = hash.clone(); |
| 1327 |
assert_eq!(problem(&sb, Some(4096)), None); |
| 1328 |
assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB"); |
| 1329 |
std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap(); |
| 1330 |
assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12])); |
| 1331 |
sb.arch = Some("aarch64".into()); |
| 1332 |
assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64"); |
| 1333 |
sb.arch = None; |
| 1334 |
std::fs::remove_file(home.image_manifest(&hash)).unwrap(); |
| 1335 |
std::fs::write(home.rootfs(&hash), b"changed").unwrap(); |
| 1336 |
assert!(problem(&sb, None).unwrap().contains("does not verify")); |
| 1337 |
} |
| f53e6d47 Andy McCall ↶ |
1338 |
|
| 1339 |
#[test] |
| 1340 |
fn transient_network_errors_are_tried_again_and_others_are_not() { |
| 1341 |
assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution")); |
| 1342 |
assert!(transient("HTTP 503: the hub is restarting")); |
| 1343 |
assert!(!transient("HTTP 404: no such repository")); |
| 1344 |
let mut n = 0; |
| 1345 |
let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || { |
| 1346 |
n += 1; |
| 1347 |
if n < 3 { |
| 1348 |
Err(anyhow::anyhow!("connection refused")) |
| 1349 |
} else { |
| 1350 |
Ok(7) |
| 1351 |
} |
| 1352 |
}); |
| 1353 |
assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success"); |
| 1354 |
let mut m = 0; |
| 1355 |
let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || { |
| 1356 |
m += 1; |
| 1357 |
Err(anyhow::anyhow!("HTTP 403: forbidden")) |
| 1358 |
}); |
| 1359 |
assert!(got.is_err()); |
| 1360 |
assert_eq!(m, 1, "a refusal is not tried again"); |
| 1361 |
let mut k = 0; |
| 1362 |
let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || { |
| 1363 |
k += 1; |
| 1364 |
Err(anyhow::anyhow!("timed out")) |
| 1365 |
}); |
| 1366 |
assert!(got.is_err()); |
| 1367 |
assert_eq!(k, 3, "gives up after the last wait"); |
| 1368 |
} |
| 1b2229da Andy McCall ↶ |
1369 |
|
| 1370 |
|
| 1371 |
fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) { |
| 89171fca Andy McCall ↶ |
1372 |
fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes) |
| 1373 |
} |
| 1374 |
|
| 1375 |
|
| 1376 |
fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) { |
| 1b2229da Andy McCall ↶ |
1377 |
use std::io::{Read, Write}; |
| 1378 |
let addr = listener.local_addr().unwrap(); |
| 1379 |
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| 1380 |
let count = seen.clone(); |
| 1381 |
std::thread::spawn(move || { |
| 1382 |
for code in codes { |
| 1383 |
let (mut s, _) = listener.accept().unwrap(); |
| 1384 |
let mut buf = [0u8; 8192]; |
| 1385 |
let _ = s.read(&mut buf); |
| 1386 |
count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
| 1387 |
let reason = if code == 204 { "No Content" } else { "Error" }; |
| 1388 |
let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); |
| 1389 |
} |
| 1390 |
}); |
| 1391 |
(format!("http://{addr}"), seen) |
| f98a8f78 Andy McCall ↶ |
1392 |
} |
| 1393 |
|
| 1394 |
|
| 1395 |
|
| 1396 |
fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) { |
| 1397 |
use std::io::{Read, Write}; |
| 1398 |
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 1399 |
let addr = listener.local_addr().unwrap(); |
| 1400 |
let (tx, rx) = std::sync::mpsc::channel(); |
| 1401 |
std::thread::spawn(move || { |
| 1402 |
let (mut s, _) = listener.accept().unwrap(); |
| 1403 |
let mut req = Vec::new(); |
| 1404 |
let mut buf = [0u8; 65536]; |
| 1405 |
loop { |
| 1406 |
let n = s.read(&mut buf).unwrap_or(0); |
| 1407 |
if n == 0 { |
| 1408 |
break; |
| 1409 |
} |
| 1410 |
req.extend_from_slice(&buf[..n]); |
| 1411 |
let text = String::from_utf8_lossy(&req); |
| 1412 |
if let Some(end) = text.find("\r\n\r\n") { |
| 1413 |
let len = text[..end] |
| 1414 |
.lines() |
| 1415 |
.find_map(|l| { |
| 1416 |
l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)) |
| 1417 |
}) |
| 1418 |
.unwrap_or(0); |
| 1419 |
if req.len() >= end + 4 + len { |
| 1420 |
break; |
| 1421 |
} |
| 1422 |
} |
| 1423 |
} |
| 1424 |
tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap(); |
| 1425 |
let _ = write!( |
| 1426 |
s, |
| 1427 |
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", |
| 1428 |
body.len() |
| 1429 |
); |
| 1430 |
}); |
| 1431 |
(format!("http://{addr}"), rx) |
| 1432 |
} |
| 1433 |
|
| 1434 |
#[test] |
| 1435 |
fn registering_binds_the_key_and_keeps_the_credential_private() { |
| 1436 |
use std::os::unix::fs::PermissionsExt; |
| 1437 |
let dir = tempfile::tempdir().unwrap(); |
| 1438 |
let home = Home::locate(Some(dir.path())).unwrap(); |
| 1439 |
let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#); |
| 1440 |
let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap(); |
| 1441 |
assert_eq!((got.id, got.name.as_str()), (7, "plex")); |
| 1442 |
let req = sent.recv().unwrap(); |
| 1443 |
assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}"); |
| 1444 |
let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap(); |
| 1445 |
assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()])); |
| 1446 |
let key = home.runner_key("plex").unwrap(); |
| 1447 |
assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity"); |
| 1448 |
let path = dir.path().join(crate::home::RUNNER_TOML); |
| 1449 |
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); |
| 1450 |
|
| 1451 |
let again = Home::locate(Some(dir.path())).unwrap(); |
| 1452 |
let cfg = again.config.runner.unwrap(); |
| 1453 |
assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex"))); |
| 1454 |
assert_eq!(cfg.labels, ["gpu"]); |
| 35f7a849 Andy McCall ↶ |
1455 |
} |
| 1456 |
|
| 1457 |
|
| 1458 |
|
| 1459 |
fn fake_certify_hub( |
| 1460 |
sign_as: (oxy_core::identity::Identity, oxy_core::identity::SecretKey), |
| 1461 |
cert_key: oxy_core::identity::Identity, |
| 1462 |
published: oxy_core::identity::Identity, |
| 1463 |
) -> String { |
| 1464 |
use std::io::{Read, Write}; |
| 1465 |
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 1466 |
let addr = listener.local_addr().unwrap(); |
| 1467 |
std::thread::spawn(move || { |
| 1468 |
for _ in 0..2 { |
| 1469 |
let Ok((mut s, _)) = listener.accept() else { return }; |
| 1470 |
let mut req = Vec::new(); |
| 1471 |
let mut buf = [0u8; 65536]; |
| 1472 |
loop { |
| 1473 |
let n = s.read(&mut buf).unwrap_or(0); |
| 1474 |
if n == 0 { |
| 1475 |
break; |
| 1476 |
} |
| 1477 |
req.extend_from_slice(&buf[..n]); |
| 1478 |
let text = String::from_utf8_lossy(&req); |
| 1479 |
if let Some(end) = text.find("\r\n\r\n") { |
| 1480 |
let len = text[..end] |
| 1481 |
.lines() |
| 1482 |
.find_map(|l| { |
| 1483 |
l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)) |
| 1484 |
}) |
| 1485 |
.unwrap_or(0); |
| 1486 |
if req.len() >= end + 4 + len { |
| 1487 |
break; |
| 1488 |
} |
| 1489 |
} |
| 1490 |
} |
| 1491 |
let line = String::from_utf8_lossy(&req).lines().next().unwrap_or("").to_string(); |
| 1492 |
let body: Vec<u8> = if line.contains("/keyless/certify") { |
| 1493 |
let cert = oxy_core::cert::SignedCertificate::sign( |
| 1494 |
oxy_core::cert::Certificate { |
| 1495 |
key: cert_key.clone(), |
| 1496 |
issuer: sign_as.0.hash(), |
| 1497 |
subject: "repo:andy/demo:bookmark:main".into(), |
| 1498 |
claims: vec![], |
| 1499 |
scope: oxy_core::identity::Scope::SEAL, |
| 1500 |
not_before: 1, |
| 1501 |
expires: 600_001, |
| fc2a6d18 Andy McCall ↶ |
1502 |
grant: None, |
| 35f7a849 Andy McCall ↶ |
1503 |
}, |
| 1504 |
&sign_as.0, |
| 1505 |
&sign_as.1, |
| 1506 |
); |
| 1507 |
serde_json::json!({ "certificate": oxy_core::dsse::base64(&cert.encode()) }).to_string().into_bytes() |
| 1508 |
} else { |
| 1509 |
published.encode() |
| 1510 |
}; |
| 1511 |
let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); |
| 1512 |
let _ = s.write_all(&body); |
| 1513 |
} |
| 1514 |
}); |
| 1515 |
format!("http://{addr}") |
| 1516 |
} |
| 1517 |
|
| 1518 |
|
| 1519 |
|
| 1520 |
#[test] |
| 1521 |
fn a_certificate_is_taken_only_for_this_key_from_the_hubs_key() { |
| 1522 |
use oxy_core::identity::SecretKey; |
| 1523 |
let hub_sk = SecretKey::generate(); |
| 1524 |
let hub_id = hub_sk.identity("hub", "", 1); |
| 1525 |
let sk = SecretKey::generate(); |
| 1526 |
let id = sk.identity("earth-1 job 7", "", 1); |
| 1527 |
let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge("flowjob_x")).unwrap(); |
| 1528 |
|
| 1529 |
let base = fake_certify_hub((hub_id.clone(), hub_sk.clone()), id.clone(), hub_id.clone()); |
| 1530 |
let (cert, issuer) = hub_at(base).certify("flowjob_x", &id, &proof).unwrap(); |
| 1531 |
assert_eq!((cert.cert.key_id(), issuer.hash()), (id.hash(), hub_id.hash())); |
| 1532 |
|
| 1533 |
let mallory_sk = SecretKey::generate(); |
| 1534 |
let mallory = mallory_sk.identity("mallory", "", 1); |
| 1535 |
let base = fake_certify_hub((mallory.clone(), mallory_sk), id.clone(), hub_id.clone()); |
| 1536 |
let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string(); |
| 1537 |
assert!(e.contains("not signed by the hub's published key"), "{e}"); |
| 1538 |
|
| 1539 |
let other = SecretKey::generate().identity("other", "", 1); |
| 1540 |
let base = fake_certify_hub((hub_id.clone(), hub_sk), other, hub_id); |
| 1541 |
let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string(); |
| 1542 |
assert!(e.contains("another key"), "{e}"); |
| 1b2229da Andy McCall ↶ |
1543 |
} |
| 1544 |
|
| 1545 |
fn hub_at(base: String) -> Hub { |
| 1546 |
Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() } |
| 3007d8f0 Andy McCall ↶ |
1547 |
} |
| 1548 |
|
| 1549 |
|
| 1550 |
|
| 1551 |
#[test] |
| 1552 |
fn a_result_is_reported_until_the_hub_takes_it() { |
| 8f211e20 Andy McCall ↶ |
1553 |
let f = Finish { |
| 1554 |
exit_code: Some(0), |
| 1555 |
duration_ms: 1, |
| 1556 |
receipt: None, |
| 1557 |
signed_receipt: None, |
| 1558 |
error: None, |
| 1559 |
peak_disk_mib: None, |
| 1560 |
peak_memory_mib: None, |
| 1561 |
}; |
| 3007d8f0 Andy McCall ↶ |
1562 |
let quick = Duration::from_millis(10); |
| 1563 |
let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]); |
| 1564 |
hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap(); |
| 1565 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors"); |
| 1566 |
let (base, seen) = fake_hub(vec![409]); |
| 1567 |
assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err()); |
| 1568 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final"); |
| 1b2229da Andy McCall ↶ |
1569 |
} |
| 1570 |
|
| 1571 |
#[test] |
| 1572 |
fn server_errors_are_tried_again_and_client_errors_are_not() { |
| 1573 |
let quick = [Duration::from_millis(10); 3]; |
| 1574 |
let (base, seen) = fake_hub(vec![500, 503, 204]); |
| 1575 |
let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap(); |
| 1576 |
assert!(r.is_none()); |
| 1577 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted"); |
| 1578 |
|
| 1579 |
let (base, seen) = fake_hub(vec![500, 500, 500, 500]); |
| 1580 |
let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string(); |
| 1581 |
assert!(e.contains("HTTP 500"), "{e}"); |
| 1582 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay"); |
| 1583 |
|
| 1584 |
let (base, seen) = fake_hub(vec![401]); |
| 1585 |
assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err()); |
| 1586 |
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final"); |
| 280ef951 Andy McCall ↶ |
1587 |
} |
| 1588 |
|
| 1589 |
|
| 1590 |
|
| 1591 |
#[test] |
| 1592 |
fn a_quiet_job_learns_that_its_run_was_cancelled() { |
| 1593 |
use microvm::Sink; |
| 1594 |
let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst); |
| 1595 |
let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1); |
| 1596 |
let (base, seen) = fake_hub(vec![204]); |
| 1597 |
let hub = hub_at(base); |
| 1598 |
let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false }; |
| 1599 |
assert!(!sink.cancelled()); |
| 1600 |
assert!(!sink.cancelled(), "asked again only after the heartbeat"); |
| 1601 |
assert_eq!(n(&seen), 1); |
| 1602 |
|
| 1603 |
let (base, seen) = fake_hub(vec![409]); |
| 1604 |
let hub = hub_at(base); |
| 1605 |
let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false }; |
| 1606 |
assert!(sink.cancelled()); |
| 1607 |
sink.record(Record { t: 0, stream: "out".into(), text: "late".into() }); |
| 1608 |
sink.flush(); |
| 1609 |
assert!(sink.cancelled()); |
| 1610 |
assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job"); |
| 89171fca Andy McCall ↶ |
1611 |
} |
| 1612 |
|
| 1613 |
|
| 1614 |
|
| 1615 |
|
| 1616 |
#[test] |
| 1617 |
fn a_job_outlasts_a_hub_restart() { |
| 1618 |
let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst); |
| 1619 |
|
| 1620 |
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
| 1621 |
let base = format!("http://127.0.0.1:{port}"); |
| 1622 |
let hub = hub_at(base.clone()); |
| 1623 |
|
| 1624 |
|
| 1625 |
let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false }; |
| 1626 |
for i in 0..450 { |
| 1627 |
sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") }); |
| 1628 |
} |
| 1629 |
sink.send(); |
| 1630 |
assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away"); |
| 1631 |
let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]); |
| 1632 |
sink.send(); |
| 1633 |
assert!(sink.buf.is_empty() && !sink.gone); |
| 1634 |
assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50"); |
| 1635 |
|
| 1636 |
|
| 1637 |
|
| 1638 |
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
| 1639 |
let url = format!("http://127.0.0.1:{port}/x"); |
| 1640 |
let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build(); |
| 1641 |
let later = std::thread::spawn(move || { |
| 1642 |
std::thread::sleep(Duration::from_millis(150)); |
| 1643 |
fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204]) |
| 1644 |
}); |
| 1645 |
let quick = [Duration::from_millis(50); 20]; |
| 1646 |
let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap(); |
| 1647 |
assert_eq!(r.status(), 204); |
| 1648 |
assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub"); |
| 1649 |
|
| 1650 |
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
| 1651 |
let url = format!("http://127.0.0.1:{port}/x"); |
| 1652 |
assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err()); |
| 1653 |
assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart"); |
| 1654 |
} |
| 1655 |
|
| 1656 |
|
| 1657 |
|
| 1658 |
#[test] |
| 1659 |
fn a_stop_drains_the_runner() { |
| 1660 |
assert!(!stopping()); |
| 1661 |
on_stop(libc::SIGTERM); |
| 1662 |
assert!(stopping(), "told to stop, it claims nothing more"); |
| 1663 |
} |
| 1664 |
|
| 1665 |
|
| 1666 |
#[test] |
| 1667 |
fn unsent_log_lines_are_bounded() { |
| 1668 |
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
| 1669 |
let hub = hub_at(format!("http://127.0.0.1:{port}")); |
| 1670 |
let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false }; |
| 1671 |
sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect(); |
| 1672 |
sink.send(); |
| 1673 |
assert_eq!(sink.buf.len(), MAX_UNSENT + 1); |
| 1674 |
assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)"); |
| 1675 |
assert_eq!(sink.buf[1].t, 10, "the oldest go first"); |
| 1b2229da Andy McCall ↶ |
1676 |
} |
| bb643c25 Andy McCall ↶ |
1677 |
|
| 1678 |
#[test] |
| 1679 |
fn the_budget_admits_jobs_that_fit() { |
| 1680 |
let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) }; |
| 1681 |
let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus }; |
| 1682 |
assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything"); |
| 1683 |
assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4)))); |
| 1684 |
assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left"); |
| 1685 |
assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs"); |
| 1686 |
let one = Budget { max_jobs: 1, memory_mib: None, cpus: None }; |
| 1687 |
assert_eq!(one.room(used(0, 0, 0)), Some((None, None))); |
| 1688 |
assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time"); |
| 1689 |
let open = Budget { max_jobs: 2, memory_mib: None, cpus: None }; |
| 1690 |
assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured"); |
| 1691 |
} |
| e0918b88 Andy McCall |
1692 |
} |