OXYGENHUB
# 67634e22f48f +4 oxygenflow / crates / flow / src / runner.rs
21.9 KiB · Rust · last changed Job artifacts: output declarations, extraction and upload 1 month ago source blame history raw
e0918b88 Andy McCall 1
//! `flow runner` (docs/m0.md §5): claim a job from OxygenHub, fetch the
2
//! repository into a cache with the job's token, write the state into a
3
//! workspace, run it in its tier, stream the log, report the receipt.
4
//! One job at a time.
5
47c1d64d Andy McCall ↶ 6
use crate::home::{Home, RunnerConfig, RunnerKey};
e0918b88 Andy McCall 7
use crate::{microvm, workspace};
8
use anyhow::{bail, Context, Result};
9
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, MAX_RECORD_TEXT};
10
use flow_core::{scope_tree_hash, Receipt};
11
use oxy_core::hash::ChangeHash;
12
use oxy_core::repo::Repo;
13
use std::path::PathBuf;
14
use std::time::{Duration, Instant};
15
16
pub struct Hub {
17
    base: String,
18
    token: String,
19
    agent: ureq::Agent,
20
}
21
22
impl Hub {
23
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
24
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
25
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
26
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
27
    }
28
1b2229da Andy McCall ↶ 29
    /// POST to the hub. A server error (5xx) means nothing was committed,
30
    /// so it is tried again after 1, 2 and 4 seconds before giving up: a
31
    /// busy hub costs a job a few seconds, not its log lines or its claim.
e0918b88 Andy McCall 32
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
1b2229da Andy McCall ↶ 33
        self.post_with(path, body, &RETRY_DELAYS)
34
    }
35
36
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
e0918b88 Andy McCall 37
        let url = format!("{}/api/v1{path}", self.base);
1b2229da Andy McCall ↶ 38
        let mut delays = delays.iter();
39
        loop {
40
            match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) {
41
                Ok(r) if r.status() == 204 => return Ok(None),
42
                Ok(r) => return Ok(Some(r)),
43
                Err(ureq::Error::Status(code, r)) => {
44
                    let text = r.into_string().unwrap_or_default();
45
                    match delays.next() {
46
                        Some(d) if code >= 500 => std::thread::sleep(*d),
47
                        _ => bail!("{url}: HTTP {code}: {text}"),
48
                    }
49
                }
50
                Err(e) => bail!("{url}: {e}"),
51
            }
e0918b88 Andy McCall 52
        }
53
    }
54
55
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
56
        match self.post("/flow/claim", req)? {
57
            Some(r) => Ok(Some(r.into_json()?)),
58
            None => Ok(None),
59
        }
60
    }
61
62
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
63
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
64
        Ok(())
65
    }
66
a67b97e9 Andy McCall ↶ 67
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
68
    /// a blob of the job's repository and answers with its ObjectRef.
69
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
70
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
71
        let mut delays = RETRY_DELAYS.iter();
72
        loop {
73
            let req = self
74
                .agent
75
                .post(&url)
76
                .timeout(Duration::from_secs(600))
77
                .set("Authorization", &format!("Bearer {}", self.token))
78
                .set("Content-Type", "application/octet-stream");
79
            match req.send_bytes(bytes) {
80
                Ok(r) => {
81
                    let v: serde_json::Value = r.into_json()?;
82
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
83
                }
84
                Err(ureq::Error::Status(code, r)) => {
85
                    let text = r.into_string().unwrap_or_default();
86
                    match delays.next() {
87
                        Some(d) if code >= 500 => std::thread::sleep(*d),
88
                        _ => bail!("{url}: HTTP {code}: {text}"),
89
                    }
90
                }
91
                Err(e) => bail!("{url}: {e}"),
92
            }
93
        }
94
    }
95
e0918b88 Andy McCall 96
    pub fn finish(&self, job: i64, f: &Finish) -> Result<()> {
97
        self.post(&format!("/flow/jobs/{job}/finish"), f)?;
98
        Ok(())
99
    }
100
}
1b2229da Andy McCall ↶ 101
102
/// Waits before trying a hub request again after a server error.
103
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
e0918b88 Andy McCall 104
bb643c25 Andy McCall ↶ 105
/// What the runner may run at once (flow.toml `[runner]`): at most
106
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
107
#[derive(Debug, Default, PartialEq, Eq)]
108
pub struct Budget {
109
    pub max_jobs: usize,
110
    pub memory_mib: Option<u32>,
111
    pub cpus: Option<u32>,
112
}
113
114
/// What is in use now.
115
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
116
pub struct Used {
117
    pub jobs: usize,
118
    pub memory_mib: u32,
119
    pub cpus: u32,
120
}
121
122
impl Budget {
123
    pub fn of(cfg: &RunnerConfig) -> Budget {
124
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
125
    }
126
127
    /// The room to ask the hub for, or `None` when no job more can start.
128
    /// A lone job may use the whole budget even if it asks for more, so a
129
    /// large sandbox is never starved.
130
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
131
        if used.jobs >= self.max_jobs {
132
            return None;
133
        }
134
        if used.jobs == 0 {
135
            return Some((None, None));
136
        }
137
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
138
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
139
        if mem == Some(0) || cpus == Some(0) {
140
            return None;
141
        }
142
        Some((mem, cpus))
143
    }
144
}
145
e0918b88 Andy McCall 146
pub fn serve(home: &Home) -> Result<()> {
147
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
148
    let hub = Hub::new(&cfg)?;
149
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
bb643c25 Andy McCall ↶ 150
    let budget = Budget::of(&cfg);
47c1d64d Andy McCall ↶ 151
    let key = home.runner_key(&name)?;
152
    eprintln!(
153
        "flow runner {name}: polling {} (up to {} jobs at once), signing receipts as {}",
154
        cfg.hub,
155
        budget.max_jobs,
156
        key.identity.hash().to_hex()
157
    );
bb643c25 Andy McCall ↶ 158
    let used = std::sync::Mutex::new(Used::default());
e0918b88 Andy McCall 159
    let mut idle = Duration::from_secs(2);
bb643c25 Andy McCall ↶ 160
    std::thread::scope(|scope| loop {
161
        let room = budget.room(*used.lock().unwrap());
162
        let Some((free_memory_mib, free_cpus)) = room else {
163
            std::thread::sleep(Duration::from_millis(500));
164
            continue;
165
        };
166
        let req = ClaimRequest { runner: name.clone(), runtimes: vec!["microvm".into()], free_memory_mib, free_cpus };
e0918b88 Andy McCall 167
        match hub.claim(&req) {
168
            Ok(Some(claim)) => {
169
                idle = Duration::from_secs(2);
bb643c25 Andy McCall ↶ 170
                let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
171
                {
172
                    let mut u = used.lock().unwrap();
173
                    u.jobs += 1;
174
                    u.memory_mib += mem;
175
                    u.cpus += cpus;
e0918b88 Andy McCall 176
                }
47c1d64d Andy McCall ↶ 177
                let (home, hub, cfg, used, key) = (home, &hub, &cfg, &used, &key);
bb643c25 Andy McCall ↶ 178
                scope.spawn(move || {
179
                    let id = claim.job_id;
180
                    eprintln!("job {id}: {} {} on {}", claim.repo, claim.job.name, claim.heads.first().map(|h| &h[..12]).unwrap_or("-"));
47c1d64d Andy McCall ↶ 181
                    let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
bb643c25 Andy McCall ↶ 182
                        exit_code: None,
183
                        duration_ms: 0,
184
                        receipt: None,
47c1d64d Andy McCall ↶ 185
                        signed_receipt: None,
bb643c25 Andy McCall ↶ 186
                        error: Some(format!("{e:#}")),
187
                    });
188
                    eprintln!(
189
                        "job {id}: exit {:?}{}",
190
                        finish.exit_code,
191
                        finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
192
                    );
193
                    if let Err(e) = hub.finish(id, &finish) {
194
                        eprintln!("job {id}: reporting failed: {e:#}");
195
                    }
196
                    let mut u = used.lock().unwrap();
197
                    u.jobs -= 1;
198
                    u.memory_mib -= mem;
199
                    u.cpus -= cpus;
200
                });
e0918b88 Andy McCall 201
            }
202
            Ok(None) => std::thread::sleep(idle),
203
            Err(e) => {
204
                eprintln!("claim: {e:#}");
205
                std::thread::sleep(idle);
206
                idle = (idle * 2).min(Duration::from_secs(60));
207
            }
208
        }
bb643c25 Andy McCall ↶ 209
    })
e0918b88 Andy McCall 210
}
211
212
/// Batches records to the hub every second (or every 200 lines).
213
struct LogSink<'a> {
214
    hub: &'a Hub,
215
    job: i64,
216
    buf: Vec<Record>,
217
    last: Instant,
218
}
219
220
impl LogSink<'_> {
221
    fn push(&mut self, mut r: Record) {
222
        if r.text.len() > MAX_RECORD_TEXT {
223
            let mut cut = MAX_RECORD_TEXT;
224
            while !r.text.is_char_boundary(cut) {
225
                cut -= 1;
226
            }
227
            r.text.truncate(cut);
228
        }
229
        self.buf.push(r);
230
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
231
            self.flush();
232
        }
233
    }
234
235
    fn flush(&mut self) {
236
        if !self.buf.is_empty() {
237
            if let Err(e) = self.hub.log(self.job, std::mem::take(&mut self.buf)) {
238
                eprintln!("job {}: log: {e:#}", self.job);
239
            }
240
        }
241
        self.last = Instant::now();
242
    }
243
}
bb643c25 Andy McCall ↶ 244
245
/// Held while a job's workspace is prepared (see `run_claim`).
246
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
e0918b88 Andy McCall 247
47c1d64d Andy McCall ↶ 248
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
e0918b88 Andy McCall 249
    let started = Instant::now();
250
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now() };
251
    let note =
252
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
253
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
254
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
bb643c25 Andy McCall ↶ 255
    // Preparing a workspace (the cached repositories, the export, the
256
    // image) is one job at a time: fetches into a cached repository must not
257
    // overlap, and the first clone passes its token through the process
258
    // environment. Only the microVMs run side by side.
259
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
e0918b88 Andy McCall 260
    let repo = cached_repo(home, cfg, claim)?;
261
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
262
    let _ = std::fs::remove_dir_all(&scratch);
263
    std::fs::create_dir_all(scratch.join("tree"))?;
264
    let result = (|| -> Result<Finish> {
c23d5191 Andy McCall ↶ 265
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
266
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
267
        // workspace and of the scope hash.
268
        for sub in &claim.subrepos {
269
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
270
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
271
            }
272
            note(
273
                &mut sink,
274
                format!(
275
                    "fetching subrepo {} at {} for {}",
276
                    sub.repo,
277
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
278
                    sub.path
279
                ),
280
            );
281
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
282
            let sub_heads: Vec<ChangeHash> =
283
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
284
            let dir = scratch.join("tree").join(&sub.path);
285
            std::fs::create_dir_all(&dir)?;
286
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
287
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
288
        }
e0918b88 Andy McCall 289
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
290
        workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
291
        std::fs::remove_dir_all(scratch.join("tree"))?;
bb643c25 Andy McCall ↶ 292
        preparing.take();
e0918b88 Andy McCall 293
        note(
294
            &mut sink,
295
            format!(
296
                "booting a microVM: {} vCPU, {} MiB, rootfs {}",
297
                claim.sandbox.cpus,
298
                claim.sandbox.memory_mib,
299
                &claim.sandbox.rootfs[..12]
300
            ),
301
        );
a72abeee Andy McCall ↶ 302
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
e0918b88 Andy McCall 303
        let outcome = microvm::run(
304
            home,
305
            &claim.sandbox,
c4285535 Andy McCall ↶ 306
            &job,
e0918b88 Andy McCall 307
            &scratch.join("workspace.ext4"),
308
            &scratch,
309
            Duration::from_secs(claim.timeout_s),
310
            &mut |r| sink.push(r),
311
        )?;
a67b97e9 Andy McCall ↶ 312
        // A passing job's artifacts, read from its disks and uploaded; a
313
        // declared artifact that cannot be kept fails the job.
314
        let mut outputs = vec![];
315
        let mut output_error = None;
316
        if outcome.exit_code == Some(0) {
317
            for o in &claim.job.outputs {
318
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
319
                let kept = crate::outputs::read(&image, &inner, &scratch).and_then(|bytes| {
320
                    let blob = Repo::blob_ref(&bytes);
321
                    let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
322
                    if named != blob.to_hex() {
323
                        bail!("the hub kept it as {named}, not {}", blob.to_hex());
324
                    }
325
                    note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12]));
326
                    Ok(blob.0)
327
                });
328
                match kept {
329
                    Ok(b) => outputs.push(b),
330
                    Err(e) => {
331
                        output_error = Some(format!("artifact {}: {e:#}", o.name));
332
                        break;
333
                    }
334
                }
335
            }
336
        }
337
        if let Some(e) = &output_error {
338
            note(&mut sink, e.clone());
339
        }
340
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
e0918b88 Andy McCall 341
            job_name: claim.job.name.clone(),
342
            pipeline_hash: hex32(&claim.pipeline_hash),
343
            scope_tree_hash: scope_hash,
344
            inputs: vec![],
345
            environment: outcome.environment.hash(),
a67b97e9 Andy McCall ↶ 346
            outputs,
e0918b88 Andy McCall 347
            exit_code: code as u32,
348
            duration_ms: outcome.duration_ms,
349
        });
47c1d64d Andy McCall ↶ 350
        let signed_receipt = match &receipt {
351
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
352
            None => None,
353
        };
e0918b88 Andy McCall 354
        Ok(Finish {
355
            exit_code: outcome.exit_code,
356
            duration_ms: started.elapsed().as_millis() as u64,
357
            receipt,
47c1d64d Andy McCall ↶ 358
            signed_receipt,
a67b97e9 Andy McCall ↶ 359
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
e0918b88 Andy McCall 360
        })
361
    })();
362
    let _ = std::fs::remove_dir_all(&scratch);
363
    sink.flush();
364
    result
47c1d64d Andy McCall ↶ 365
}
366
367
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
368
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
369
/// the claim, so the runner vouches only for what it fetched.
370
fn sign_receipt(
371
    repo: &Repo,
372
    heads: &[ChangeHash],
373
    cfg: &RunnerConfig,
374
    key: &RunnerKey,
375
    claim: &Claim,
376
    receipt: &Receipt,
377
) -> Result<oxy_core::receipt::SignedReceipt> {
378
    let state = state_of(heads);
379
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
380
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
381
}
382
383
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
384
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
385
    use oxy_core::hash::{hash_object, ObjectType};
386
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
e0918b88 Andy McCall 387
}
388
389
fn hex32(s: &str) -> [u8; 32] {
390
    let mut out = [0; 32];
391
    if let Ok(v) = hex::decode(s) {
392
        if v.len() == 32 {
393
            out.copy_from_slice(&v);
394
        }
395
    }
396
    out
397
}
398
399
/// The runner's copy of a repository, fetched up to date with the job's token.
400
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
c23d5191 Andy McCall ↶ 401
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
402
}
403
404
/// The runner's copy of `repo`, cloned or fetched with `token`.
405
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
406
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
e0918b88 Andy McCall 407
    let set_remote = |repo: &mut Repo| -> Result<()> {
c23d5191 Andy McCall ↶ 408
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
409
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
e0918b88 Andy McCall 410
        if let Some(ca) = &cfg.ca {
411
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
412
        }
413
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
414
    };
415
    if dir.join(".oxy").is_dir() {
416
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
417
        set_remote(&mut repo)?;
c23d5191 Andy McCall ↶ 418
        repo.fetch("origin").map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}"))?;
e0918b88 Andy McCall 419
        return Ok(repo);
420
    }
421
    // First job for this repository: clone with the job's token.
c23d5191 Andy McCall ↶ 422
    std::env::set_var("OXY_TOKEN", token);
e0918b88 Andy McCall 423
    if let Some(ca) = &cfg.ca {
424
        std::env::set_var("OXY_CA", ca);
425
    }
c23d5191 Andy McCall ↶ 426
    let cloned = Repo::clone(clone_url, &dir);
e0918b88 Andy McCall 427
    std::env::remove_var("OXY_TOKEN");
428
    std::env::remove_var("OXY_CA");
429
    let mut repo = cloned.map_err(|e| {
430
        let _ = std::fs::remove_dir_all(&dir);
c23d5191 Andy McCall ↶ 431
        anyhow::anyhow!("clone {}: {e}", clone_url)
e0918b88 Andy McCall 432
    })?;
433
    set_remote(&mut repo)?;
434
    Ok(repo)
bb643c25 Andy McCall ↶ 435
}
436
437
#[cfg(test)]
438
mod tests {
439
    use super::*;
1b2229da Andy McCall ↶ 440
441
    /// A hub that answers each request with the next of `codes`, counting them.
442
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
443
        use std::io::{Read, Write};
444
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
445
        let addr = listener.local_addr().unwrap();
446
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
447
        let count = seen.clone();
448
        std::thread::spawn(move || {
449
            for code in codes {
450
                let (mut s, _) = listener.accept().unwrap();
f3b4fdeb Andy McCall ↶ 451
                // Read the whole request (headers, then Content-Length bytes
452
                // of body) before answering: closing early would reset the
453
                // connection while the client is still sending.
454
                let mut req = Vec::new();
1b2229da Andy McCall ↶ 455
                let mut buf = [0u8; 8192];
f3b4fdeb Andy McCall ↶ 456
                loop {
457
                    let n = s.read(&mut buf).unwrap_or(0);
458
                    if n == 0 {
459
                        break;
460
                    }
461
                    req.extend_from_slice(&buf[..n]);
462
                    let text = String::from_utf8_lossy(&req);
463
                    if let Some(end) = text.find("\r\n\r\n") {
464
                        let len = text[..end]
465
                            .lines()
466
                            .find_map(|l| {
467
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
468
                            })
469
                            .unwrap_or(0);
470
                        if req.len() >= end + 4 + len {
471
                            break;
472
                        }
473
                    }
474
                }
1b2229da Andy McCall ↶ 475
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
476
                let reason = if code == 204 { "No Content" } else { "Error" };
477
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
478
            }
479
        });
480
        (format!("http://{addr}"), seen)
481
    }
482
483
    fn hub_at(base: String) -> Hub {
484
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
485
    }
486
487
    #[test]
488
    fn server_errors_are_tried_again_and_client_errors_are_not() {
489
        let quick = [Duration::from_millis(10); 3];
490
        let (base, seen) = fake_hub(vec![500, 503, 204]);
491
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
492
        assert!(r.is_none());
493
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
494
495
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
496
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
497
        assert!(e.contains("HTTP 500"), "{e}");
498
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
499
500
        let (base, seen) = fake_hub(vec![401]);
501
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
502
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
503
    }
bb643c25 Andy McCall ↶ 504
505
    #[test]
506
    fn the_budget_admits_jobs_that_fit() {
507
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
508
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
509
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
510
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
511
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
512
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
513
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
514
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
515
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
516
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
517
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
518
    }
e0918b88 Andy McCall 519
}