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