OXYGENHUB
◆ image-check oxygenhub / crates / hub / src / flow.rs
85.8 KiB · Rust · last changed Flow: key jobs held for a better runner by hub as well as job 24 days ago source blame history raw
1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        "on" => true,
27
        "off" => false,
28
        _ => app.config.flow.allows(&row.owner, &row.name),
29
    }
30
}
31
32
/// A bookmark moved by a push: start a run when the repository is allowed
33
/// and the new state has a pipeline.
34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
35
    if heads.is_empty() || !enabled(app, row) {
36
        return;
37
    }
38
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
39
    let started = match bookmark.strip_prefix("tags/") {
40
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
41
        None => {
42
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
43
            let started = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await;
44
            if let Ok(Some(n)) = started {
45
                supersede(app, row, bookmark, n).await;
46
            }
47
            started
48
        }
49
    };
50
    if let Err(e) = started {
51
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
52
    }
53
}
54
55
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
56
/// moving it. Returns the new run's number.
57
/// `jobs`, when not empty, are the only jobs to start (oxygenhub#130), with
58
/// the jobs they depend on.
59
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
60
    if !enabled(app, row) {
61
        return Err("OxygenFlow is not enabled for this repository".into());
62
    }
63
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
64
    let b = bookmark.to_string();
65
    let found = app
66
        .repos
67
        .read(&repo, move |r, _| {
68
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
69
            Ok(Some((st, r.store().get_state(&st)?.heads)))
70
        })
71
        .await
72
        .map_err(|e| e.to_string())?;
73
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
74
    if heads.is_empty() {
75
        return Err(format!("{bookmark} has no changes yet"));
76
    }
77
    start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs)
78
        .await?
79
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
80
}
81
82
/// "Run pipeline" on one committed change: the state whose only head is
83
/// `change`, whatever bookmarks hold it. Returns the new run's number.
84
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
85
    if !enabled(app, row) {
86
        return Err("OxygenFlow is not enabled for this repository".into());
87
    }
88
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
89
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
90
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
91
    if !known {
92
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
93
    }
94
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
95
    start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs)
96
        .await?
97
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
98
}
99
100
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
101
/// run whose checks replace the old ones. Returns the new run's number.
102
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
103
    if !enabled(app, row) {
104
        return Err("OxygenFlow is not enabled for this repository".into());
105
    }
106
    if run.live() {
107
        return Err(format!("run #{} is still {}", run.number, run.status));
108
    }
109
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
110
    if heads.is_empty() {
111
        return Err("the run names no changes".into());
112
    }
113
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
114
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
115
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
116
        .await?
117
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
118
}
119
120
/// Cancel a run still going (oxygenflow#32), its checks with it.
121
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
122
    if !run.live() {
123
        return Err(format!("run #{} is already {}", run.number, run.status));
124
    }
125
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
126
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
127
        "the run could not be cancelled".to_string()
128
    })
129
}
130
131
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
132
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
133
    report_changed(app, run.id, changed).await
134
}
135
136
/// A push makes the push runs still going on its bookmark moot: they are
137
/// cancelled, and the new run's scope reaches back past them.
138
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, number: i64) {
139
    let Ok(older) = fdb::live_push_runs_before(&app.db, row.id, bookmark, number).await else { return };
140
    for run in older {
141
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
142
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
143
        }
144
    }
145
}
146
147
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
148
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
149
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
150
    use flow_core::pipeline::Event;
151
    let t = match trigger.strip_prefix("rerun:") {
152
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
153
        None => trigger,
154
    };
155
    if let Some(tag) = t.strip_prefix("tag:") {
156
        Event::Tag(tag)
157
    } else if t == "push" {
158
        Event::Push(bookmark)
159
    } else if t == "schedule" {
160
        Event::Schedule
161
    } else {
162
        Event::Manual
163
    }
164
}
165
166
/// The event to repeat when rerunning `run`.
167
fn original_event(run: &fdb::Run) -> String {
168
    let t = run.trigger.as_str();
169
    match t.strip_prefix("rerun:") {
170
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
171
        None => t.to_string(),
172
    }
173
}
174
175
/// The paths the changes a push brought touch: the changes in `heads`'
176
/// state that the bookmark's previous run did not have, and the files their
177
/// edits and tree operations name, as the new state places them. `None`
178
/// (run every job) for the bookmark's first run, or when it cannot be told.
179
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
180
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
181
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
182
    if old.is_empty() {
183
        return None;
184
    }
185
    let repo = RepoName::new(&row.owner, &row.name)?;
186
    let new = heads.to_vec();
187
    app.repos
188
        .read(&repo, move |r, cache| {
189
            let fresh = r.negotiate(&old, &new)?;
190
            let loaded = cache.load(r, &new)?;
191
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
192
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
193
            let mut paths = std::collections::BTreeSet::new();
194
            for h in fresh {
195
                let (c, _) = r.store().get_change_lazy(&h)?;
196
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
197
                for n in nodes {
198
                    if let Some(p) = by_node.get(&n) {
199
                        paths.insert((*p).clone());
200
                    }
201
                }
202
            }
203
            Ok(paths.into_iter().collect::<Vec<String>>())
204
        })
205
        .await
206
        .ok()
207
}
208
209
/// Whether a job runs for a push that touched `touched`: always without a
210
/// scope or without knowing what was touched, else when a path matches.
211
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
212
    let Some(touched) = touched else { return true };
213
    if scope.is_empty() {
214
        return true;
215
    }
216
    let mut b = globset::GlobSetBuilder::new();
217
    for g in scope {
218
        match globset::Glob::new(g) {
219
            Ok(g) => {
220
                b.add(g);
221
            }
222
            Err(_) => return true,
223
        }
224
    }
225
    let Ok(set) = b.build() else { return true };
226
    touched.iter().any(|p| set.is_match(p))
227
}
228
229
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
230
/// left out of the run, unless a job that runs depends on them.
231
#[allow(clippy::too_many_arguments)]
232
pub async fn start_run_scoped(
233
    app: &Shared,
234
    row: &db::repos::RepoRow,
235
    bookmark: &str,
236
    state: &str,
237
    heads: &[ChangeHash],
238
    actor: &str,
239
    trigger: &str,
240
    touched: Option<&[String]>,
241
) -> Result<Option<i64>, String> {
242
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
243
}
244
245
/// Start a run of the pipeline in the state `heads` make (whose state
246
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
247
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
248
/// whose access the run's subrepo reads are checked against. Returns the
249
/// run's number, or `None` when the state has no pipeline. The caller
250
/// checks that Flow is enabled.
251
pub async fn start_run(
252
    app: &Shared,
253
    row: &db::repos::RepoRow,
254
    bookmark: &str,
255
    state: &str,
256
    heads: &[ChangeHash],
257
    actor: &str,
258
    trigger: &str,
259
) -> Result<Option<i64>, String> {
260
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
261
}
262
263
#[allow(clippy::too_many_arguments)]
264
async fn start_run_with(
265
    app: &Shared,
266
    row: &db::repos::RepoRow,
267
    bookmark: &str,
268
    state: &str,
269
    heads: &[ChangeHash],
270
    actor: &str,
271
    trigger: &str,
272
    touched: Option<&[String]>,
273
) -> Result<Option<i64>, String> {
274
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await
275
}
276
277
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock;
278
/// `only`, when not empty, the jobs a manual run starts (oxygenhub#130).
279
#[allow(clippy::too_many_arguments)]
280
async fn start_run_at(
281
    app: &Shared,
282
    row: &db::repos::RepoRow,
283
    bookmark: &str,
284
    state: &str,
285
    heads: &[ChangeHash],
286
    actor: &str,
287
    trigger: &str,
288
    touched: Option<&[String]>,
289
    at: Option<flow_core::pipeline::When>,
290
    only: &[String],
291
) -> Result<Option<i64>, String> {
292
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
293
    let h = heads.to_vec();
294
    let text = app
295
        .repos
296
        .read(&repo, move |r, cache| {
297
            let loaded = cache.load(r, &h)?;
298
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
299
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
300
                _ => None,
301
            })
302
        })
303
        .await
304
        .map_err(|e| format!("reading the pipeline: {e}"))?;
305
    let Some(text) = text else { return Ok(None) };
306
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
307
    hex.sort();
308
    let heads_s = hex.join("+");
309
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
310
    let id = match Pipeline::parse(&text) {
311
        Err(problems) => {
312
            let msg = problems.to_string();
313
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
314
        }
315
        Ok(p) => {
316
            let depths = p.depths();
317
            // Jobs out of the push's scope stay out, unless a kept job
318
            // needs them.
319
            // The jobs this run's event starts (`on`), out of those its
320
            // push touched (`scope`); then what they depend on.
321
            let event = match at {
322
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
323
                None => event_of(trigger, bookmark),
324
            };
325
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
326
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
327
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
328
            } else {
329
                // Named jobs (oxygenhub#130): each one this event starts.
330
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
331
                for name in only {
332
                    if !startable.contains(&name.as_str()) {
333
                        let what = if p.jobs.contains_key(name) {
334
                            "is not started by hand (no `on { manual }`)"
335
                        } else {
336
                            "is not in the pipeline"
337
                        };
338
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
339
                    }
340
                }
341
                only.iter().cloned().collect()
342
            };
343
            loop {
344
                let needed: Vec<String> = p
345
                    .order()
346
                    .into_iter()
347
                    .filter(|j| keep.contains(&j.name))
348
                    .flat_map(|j| j.depends_on.iter().cloned())
349
                    .filter(|d| !keep.contains(d))
350
                    .collect();
351
                if needed.is_empty() {
352
                    break;
353
                }
354
                keep.extend(needed);
355
            }
356
            let mut jobs: Vec<fdb::NewJob> = p
357
                .order()
358
                .into_iter()
359
                .filter(|j| keep.contains(&j.name))
360
                .map(|j| {
361
                    let sb = &p.sandboxes[&j.sandbox];
362
                    fdb::NewJob {
363
                        name: j.name.clone(),
364
                        depth: depths[j.name.as_str()] as i64,
365
                        depends: j.depends_on.clone(),
366
                        spec: serde_json::to_string(j).unwrap_or_default(),
367
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
368
                        runtime: sb.runtime.as_str().into(),
369
                        need_disk_mib: None,
370
                    }
371
                })
372
                .collect();
373
            // What each job needs, learned from its past runs (oxygenflow#41).
374
            for j in &mut jobs {
375
                j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?;
376
            }
377
            if jobs.is_empty() {
378
                // Nothing this event starts (or this push touched).
379
                return Ok(None);
380
            }
381
            let hash = hex::encode(p.hash());
382
            let id =
383
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
384
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
385
                for j in &jobs {
386
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
387
                }
388
                images_check(app, row, state, run.number, &jobs).await;
389
            }
390
            id
391
        }
392
    };
393
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
394
    Ok(Some(run.number))
395
}
396
397
/// The `pipeline/images` check (#137): set failing when an image the run's
398
/// jobs pin is held by neither the hub nor a runner, so a proposal pinning
399
/// one says so before it lands rather than its runs queueing for good;
400
/// passed again by a later run of the state once every image is held.
401
async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) {
402
    let mut pinned: Vec<String> = jobs
403
        .iter()
404
        .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok())
405
        .filter_map(|v| v["rootfs"].as_str().map(String::from))
406
        .filter(|r| !r.is_empty())
407
        .collect();
408
    pinned.sort();
409
    pinned.dedup();
410
    if pinned.is_empty() {
411
        return;
412
    }
413
    let mut held = crate::images::held(&app.db).await.unwrap_or_default();
414
    // Nobody can be said to lack an image unless some runner is known and
415
    // every one reports what it holds.
416
    let runners: Vec<db::runners::Runner> =
417
        db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect();
418
    if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) {
419
        return;
420
    }
421
    for r in &runners {
422
        held.extend(r.image_list().unwrap_or_default());
423
    }
424
    let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect();
425
    // Only a missing image makes the check; one that was failing on this
426
    // state turns to passed once its images are held.
427
    let failing_before = db::hooks::checks_for_state(&app.db, row.id, state)
428
        .await
429
        .unwrap_or_default()
430
        .iter()
431
        .any(|c| c.name == "pipeline/images" && c.status != "passed");
432
    if missing.is_empty() && !failing_before {
433
        return;
434
    }
435
    let (status, summary) = if missing.is_empty() {
436
        (
437
            "passed",
438
            format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }),
439
        )
440
    } else {
441
        let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect();
442
        ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", ")))
443
    };
444
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
445
    if let Ok(c) = db::hooks::upsert_check(
446
        &app.db,
447
        row.id,
448
        db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" },
449
    )
450
    .await
451
    {
452
        let api = crate::api::check_of(&c);
453
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
454
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
455
    }
456
}
457
458
/// Record a job's check; it links to the run's page by the run's number.
459
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
460
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
461
    let name = format!("flow/{job}");
462
    let c = db::hooks::upsert_check(
463
        &app.db,
464
        row.id,
465
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
466
    )
467
    .await;
468
    if let Ok(c) = c {
469
        let api = crate::api::check_of(&c);
470
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
471
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
472
    }
473
}
474
475
/// The check status a job's status shows as.
476
fn check_status(job_status: &str) -> &'static str {
477
    match job_status {
478
        "queued" => "queued",
479
        "running" => "running",
480
        "passed" => "passed",
481
        "skipped" => "skipped",
482
        "cancelled" => "cancelled",
483
        _ => "failed",
484
    }
485
}
486
487
/// The runner a bearer token belongs to: a registered runner, or one
488
/// configured with a hub.toml token (tracked from its first request). A
489
/// disabled runner is refused.
490
pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
491
    let Some(t) = oxy_server::bearer_token(headers) else {
492
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
493
    };
494
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
495
    let runner = match db::runners::by_token(&app.db, &hash).await? {
496
        Some(r) => r,
497
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
498
            db::runners::configured(&app.db, &hash, "runner").await?
499
        }
500
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
501
    };
502
    if runner.state == "disabled" {
503
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
504
    }
505
    Ok(runner)
506
}
507
508
/// `POST /api/v1/flow/runners/register`: a one-time registration token
509
/// from Admin → Runners buys a runner credential, binding the runner's
510
/// signing identity (hub spec §13.2).
511
pub async fn register(
512
    State(app): State<Shared>,
513
    Json(req): Json<flow_core::api::Register>,
514
) -> Result<Json<flow_core::api::Registered>, ApiError> {
515
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
516
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
517
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
518
    if name.is_empty() {
519
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
520
    }
521
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
522
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
523
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
524
    let runner =
525
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
526
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
527
        })?;
528
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
529
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
530
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
531
}
532
533
/// `POST /api/v1/flow/claim`.
534
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
535
    let me = runner_auth(&app, &headers).await?;
536
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
537
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
538
    if let Some(holding) = &req.holding {
539
        end_jobs_not_held(&app, &me, holding).await?;
540
    }
541
    // A draining runner finishes what it has and takes nothing new.
542
    if me.state == "draining" {
543
        return Ok(StatusCode::NO_CONTENT.into_response());
544
    }
545
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
546
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
547
    // What it holds and what it is (OxygenFlow spec §11): the images'
548
    // architectures it reports are remembered for every runner's claims.
549
    if let Some(archs) = &req.image_archs {
550
        fdb::record_image_archs(&app.db, archs).await?;
551
    }
552
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
553
    // What it offers, kept for the placement pass; a job it declined is
554
    // not offered to it again while this stays the same (spec §11.5).
555
    let profile = profile_of(&req, &arch);
556
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
557
    if let Some(f) = &req.facts {
558
        db::runners::set_facts(&app.db, me.id, f).await?;
559
    }
560
    let room = fdb::Room {
561
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
562
        memory_mib: req.free_memory_mib,
563
        cpus: req.free_cpus,
564
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
565
        arch,
566
        image_archs: fdb::image_archs(&app.db).await?,
567
        fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() },
568
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
569
    };
570
    // The labels it reports now, or those it registered with, and those
571
    // an administrator gave it (#98).
572
    let mut labels: Vec<String> = match &req.info {
573
        Some(i) => i.labels.clone(),
574
        None => me.reported_label_list().into_iter().map(String::from).collect(),
575
    };
576
    for l in me.admin_label_list() {
577
        if !labels.iter().any(|x| x == l) {
578
            labels.push(l.to_string());
579
        }
580
    }
581
    // Ranking (oxygenflow#42): a job another free runner would do better
582
    // (it holds the image, or is faster) is held for it briefly.
583
    let runners = db::runners::list(&app.db).await?;
584
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
585
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
586
    let owners: std::collections::HashMap<i64, (String, i64)> =
587
        fdb::queue(&app.db).await?.into_iter().map(|q| (q.job_id, (q.owner, q.repo_id))).collect();
588
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
589
    let now = db::now_ms();
590
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
591
    let hub_key = app.repos.root().display().to_string();
592
    let better = |j: &fdb::Job| -> bool {
593
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
594
        let rootfs = rootfs_of(j);
595
        let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner());
596
        let holds = holds.get_or_insert_with(Default::default);
597
        holds.retain(|_, t| now - *t < 600_000);
598
        if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
599
            return false;
600
        }
601
        let mine = rank(&me_now, &rootfs);
602
        let other = runners.iter().any(|r| {
603
            r.id != me.id
604
                && r.state == "active"
605
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
606
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
607
                && rank(r, &rootfs) > mine
608
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
609
        });
610
        if other {
611
            holds.entry((hub_key.clone(), j.id)).or_insert(now);
612
        }
613
        other
614
    };
615
    let Some(job) =
616
        fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await?
617
    else {
618
        return Ok(StatusCode::NO_CONTENT.into_response());
619
    };
620
    // Why here (oxygenflow#42).
621
    let waited =
622
        HELD.lock().unwrap_or_else(|p| p.into_inner()).as_mut().and_then(|h| h.remove(&(hub_key.clone(), job.id))).map(|t| now - t);
623
    let rootfs = rootfs_of(&job);
624
    let mut why = vec![if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
625
        "holds the image".to_string()
626
    } else {
627
        "fetches the image from the hub".to_string()
628
    }];
629
    match running.get(&me.id).copied().unwrap_or(0) {
630
        0 => why.push("idle".into()),
631
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
632
    }
633
    if let Some(sp) = me_now.speed {
634
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
635
    }
636
    if let Some(w) = waited {
637
        why.push(format!("after {} s held for a better runner", w / 1000));
638
    }
639
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
640
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
641
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
642
    let row =
643
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
644
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
645
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
646
    // The job's limit: its own timeout, else its sandbox's, else the site
647
    // default, never past the site maximum; recorded for the reaper.
648
    let limits = limits(&app).await;
649
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
650
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
651
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
652
    // ...and never past the runner's own limit, when it has one (#98).
653
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
654
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
655
    }
656
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
657
    let claim = Claim {
658
        job_id: job.id,
659
        run_id: run.id,
660
        repo: format!("{}/{}", row.owner, row.name),
661
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
662
        token: secret,
663
        heads: run.head_list().into_iter().map(String::from).collect(),
664
        pipeline_hash: run.pipeline_hash.clone(),
665
        job: spec,
666
        sandbox,
667
        timeout_s,
668
        subrepos: vec![],
669
        state: run.state.clone(),
670
        bookmark: run.bookmark.clone(),
671
        run_number: run.number,
672
        // The site's, the owner's and the repository's variables (#102).
673
        env: crate::variables::env_for_repo(&app.db, &row).await?,
674
        secrets: vec![],
675
    };
676
    let mut claim = claim;
677
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
678
    // The secrets the job declares, as files (#107); one not set is said
679
    // at the top of the log and left out, so the job fails on the file.
680
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
681
    let found = crate::secrets::for_job(&app, &row, &names).await?;
682
    // The identities it asks for (#103): a token each, as a file.
683
    if !claim.job.identities.is_empty() {
684
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
685
        for i in &claim.job.identities {
686
            let token = issuer.mint(&crate::oidc::JobClaims {
687
                issuer: app.config.base_url(),
688
                audience: &i.audience,
689
                owner: &row.owner,
690
                name: &row.name,
691
                visibility: &row.visibility,
692
                bookmark: &run.bookmark,
693
                run_number: run.number,
694
                job: &job.name,
695
                state: &run.state,
696
                runner: &runner,
697
                actor: &run.actor,
698
                now_ms: db::now_ms(),
699
                ttl_s: timeout_s,
700
            });
701
            claim.secrets.push(flow_core::api::SecretInput {
702
                name: format!("identity:{}", i.audience),
703
                path: i.mount_path.clone(),
704
                value: token,
705
            });
706
        }
707
    }
708
    for s in &claim.job.secrets {
709
        match found.iter().find(|(n, _)| *n == s.name) {
710
            Some((name, value)) => {
711
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
712
            }
713
            None => left_out.push(format!(
714
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
715
                s.name, s.mount_path
716
            )),
717
        }
718
    }
719
    if !ids.is_empty() {
720
        fdb::set_also_read(&app.db, job.id, &ids).await?;
721
    }
722
    // Say at the top of the job's log what it will not have (oxygenhub#56),
723
    // rather than let it fail later on missing files.
724
    if !left_out.is_empty() {
725
        let records: Vec<flow_core::api::Record> =
726
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
727
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
728
    }
729
    claim.subrepos = inputs;
730
    Ok(Json(claim).into_response())
731
}
732
733
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
734
/// labels and capacity, each list sorted.
735
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
736
    let sorted = |v: &[String]| {
737
        let mut v = v.to_vec();
738
        v.sort();
739
        v
740
    };
741
    let info = req.info.as_ref();
742
    let v = serde_json::json!({
743
        "arch": arch,
744
        "tiers": sorted(&req.runtimes),
745
        "images": req.images.as_deref().map(sorted),
746
        "labels": info.map(|i| sorted(&i.labels)),
747
        "memory_mib": info.and_then(|i| i.memory_mib),
748
        "cpus": info.and_then(|i| i.cpus),
749
    });
750
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
751
}
752
753
/// A job handed back by this many different runners is unplaceable.
754
pub const UNPLACEABLE_AFTER: usize = 3;
755
756
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
757
/// runner will not run a job it was handed. The job is queued again and
758
/// not offered to that runner while its profile stays the same (and for
759
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
760
/// different runners have declined it, it ends as unplaceable with their
761
/// reasons. A decline is never a failed check.
762
pub async fn decline(
763
    State(app): State<Shared>,
764
    headers: HeaderMap,
765
    Path(id): Path<i64>,
766
    Json(d): Json<flow_core::api::Decline>,
767
) -> Result<StatusCode, ApiError> {
768
    let me = runner_auth(&app, &headers).await?;
769
    db::runners::seen(&app.db, me.id, None, None).await?;
770
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
771
    let reason = match reason.trim() {
772
        "" => "no reason given".to_string(),
773
        r => r.to_string(),
774
    };
775
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
776
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
777
    };
778
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
779
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
780
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
781
    if runners.len() >= UNPLACEABLE_AFTER {
782
        let error = format!(
783
            "unplaceable: declined by {} runners: {}",
784
            runners.len(),
785
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
786
        );
787
        let changed =
788
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
789
                .await?;
790
        report_changed(&app, job.run_id, changed).await?;
791
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
792
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
793
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
794
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
795
            fdb::set_waiting(&app.db, id, &summary).await?;
796
        }
797
    }
798
    Ok(StatusCode::NO_CONTENT)
799
}
800
801
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
802
/// now (spec §11.4), from what each runner offered at its last claim:
803
/// empty when one could (it waits its turn or a free slot), else what
804
/// each one lacks. `declines` are the job's.
805
/// What placement knows of the fleet: images' architectures, the images
806
/// the hub holds, and the time.
807
pub struct Fleet<'a> {
808
    pub image_archs: &'a std::collections::HashMap<String, String>,
809
    pub hub_images: &'a std::collections::HashSet<String>,
810
    pub now: i64,
811
}
812
813
/// Why runner `r` cannot take `job` of a repository of `owner` (id
814
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
815
/// when it can. `declines` are the job's.
816
pub fn runner_lacks(
817
    job: &fdb::Job,
818
    owner: &str,
819
    repo_id: i64,
820
    r: &db::runners::Runner,
821
    fleet: &Fleet,
822
    declines: &[fdb::DeclineRow],
823
) -> Option<String> {
824
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
825
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
826
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
827
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
828
    if r.state == "draining" {
829
        return Some("is draining".into());
830
    }
831
    let scope = r.scope();
832
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
833
        return Some("serves other repositories".into());
834
    }
835
    let tiers = r.tier_list();
836
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
837
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
838
    }
839
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
840
        return Some(format!("is {}; the job needs {w}", r.arch));
841
    }
842
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
843
    if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
844
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
845
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
846
    }
847
    let labels = r.label_list();
848
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
849
        return Some(format!("lacks label {l}"));
850
    }
851
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
852
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
853
    }
854
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
855
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
856
    }
857
    // The disk it learned it needs (oxygenflow#41).
858
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
859
        if free < n {
860
            return Some(format!(
861
                "has {} free; the job needs ~{} (its recent runs took up to {})",
862
                mib(free),
863
                mib(n),
864
                mib((n - 64) * 4 / 5)
865
            ));
866
        }
867
    }
868
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
869
        return Some(format!("declined it: {}", d.reason));
870
    }
871
    None
872
}
873
874
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
875
/// now (spec §11.4), from what each runner offered at its last claim:
876
/// empty when one could (it waits its turn or a free slot), else what
877
/// each one lacks. `declines` are the job's.
878
#[allow(clippy::too_many_arguments)]
879
pub fn why_waiting(
880
    job: &fdb::Job,
881
    owner: &str,
882
    repo_id: i64,
883
    runners: &[db::runners::Runner],
884
    image_archs: &std::collections::HashMap<String, String>,
885
    declines: &[fdb::DeclineRow],
886
    now: i64,
887
    hub_images: &std::collections::HashSet<String>,
888
) -> String {
889
    let fleet = Fleet { image_archs, hub_images, now };
890
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
891
    if online.is_empty() {
892
        return "no runner is online".into();
893
    }
894
    let mut why = Vec::new();
895
    for r in &online {
896
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
897
            None => return String::new(),
898
            Some(w) => why.push(format!("{} {w}", r.name)),
899
        }
900
    }
901
    let more = why.len().saturating_sub(4);
902
    why.truncate(4);
903
    let mut text = format!("no runner can take it: {}", why.join("; "));
904
    if more > 0 {
905
        text.push_str(&format!("; and {more} more"));
906
    }
907
    text
908
}
909
910
/// How long a job is held for a better runner before any runner that fits
911
/// takes it (oxygenflow#42).
912
const BETTER_HOLD_MS: i64 = 15_000;
913
/// A runner counts as there to take a job when it asked this recently.
914
const POLLING_MS: i64 = 10_000;
915
916
/// When each job was first passed over for a better runner.
917
/// Keyed by hub (tests run several in one process) and job.
918
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
919
920
/// How good runner `r` is for a job on image `rootfs`: holding the image
921
/// counts most, then the speed class.
922
fn rank(r: &db::runners::Runner, rootfs: &str) -> i64 {
923
    let holds = r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs));
924
    (holds as i64) * 10 + r.speed_class.unwrap_or(0)
925
}
926
927
fn rootfs_of(job: &fdb::Job) -> String {
928
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
929
}
930
931
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
932
fn mib(m: i64) -> String {
933
    if m >= 1024 {
934
        format!("{:.1} GiB", m as f64 / 1024.0)
935
    } else {
936
        format!("{m} MiB")
937
    }
938
}
939
940
/// One placement pass: say why each ready queued job waits.
941
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
942
    let ready = fdb::ready_queued(&app.db).await?;
943
    if ready.is_empty() {
944
        return Ok(());
945
    }
946
    let runners = db::runners::list(&app.db).await?;
947
    let archs = fdb::image_archs(&app.db).await?;
948
    let held = crate::images::held(&app.db).await?;
949
    let now = db::now_ms();
950
    for (job, owner, repo_id) in ready {
951
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
952
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
953
        if why == job.waiting {
954
            continue;
955
        }
956
        fdb::set_waiting(&app.db, job.id, &why).await?;
957
        // The job's check says it too, so a proposal shows why it is stuck
958
        // (oxygenhub#28).
959
        if let (Some(run), Some(row)) = (fdb::run_by_id(&app.db, job.run_id).await?, db::repos::get_by_id(&app.db, repo_id).await?) {
960
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
961
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
962
        }
963
    }
964
    Ok(())
965
}
966
967
/// Run a placement pass every 15 seconds.
968
pub async fn placement(app: Shared) {
969
    loop {
970
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
971
        if let Err(e) = explain_waits(&app).await {
972
            tracing::warn!("flow: placement pass: {e}");
973
        }
974
    }
975
}
976
977
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
978
async fn pins_in(
979
    app: &Shared,
980
    name: crate::repos::RepoName,
981
    heads: Vec<oxy_core::hash::ChangeHash>,
982
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
983
    app.repos
984
        .read(&name, move |repo, cache| {
985
            let loaded = cache.load(repo, &heads)?;
986
            let mut out = Vec::new();
987
            for (path, f) in &loaded.snapshot.files {
988
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
989
                    continue;
990
                }
991
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
992
                    if let Some(b) = blobs.first() {
993
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
994
                            .read_blob(b)
995
                            .map_err(crate::repos::RepoError::Oxy)
996
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
997
                        {
998
                            out.push((path.clone(), id, heads));
999
                        }
1000
                    }
1001
                }
1002
            }
1003
            Ok(out)
1004
        })
1005
        .await
1006
        .unwrap_or_default()
1007
}
1008
1009
/// How deep subrepos of subrepos are followed.
1010
const MAX_SUBREPO_DEPTH: usize = 3;
1011
1012
/// The Oxygen subrepos the run's state pins, and those their states pin in
1013
/// turn (parents first, paths joined), that live on this hub and that the
1014
/// run's pusher may read: what the job's workspace needs beside its own
1015
/// tree. A pin the hub cannot serve is left out, with what it pins, and
1016
/// the third list says why, for the job's log.
1017
async fn subrepo_inputs(
1018
    app: &Shared,
1019
    row: &db::repos::RepoRow,
1020
    run: &fdb::Run,
1021
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
1022
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
1023
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1024
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1025
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
1026
    let mut inputs = Vec::new();
1027
    let mut ids = Vec::new();
1028
    let mut left_out = Vec::new();
1029
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
1030
    // (path prefix, repository, heads, depth), parents before children.
1031
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
1032
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
1033
        if depth == MAX_SUBREPO_DEPTH {
1034
            continue;
1035
        }
1036
        for (path, id, pinned) in pins_in(app, name, heads).await {
1037
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
1038
            let mut hosted = false;
1039
            for other in &rows {
1040
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
1041
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
1042
                if !matches {
1043
                    continue;
1044
                }
1045
                hosted = true;
1046
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
1047
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
1048
                    inputs.push(flow_core::api::SubrepoInput {
1049
                        path: path.clone(),
1050
                        repo: format!("{}/{}", other.owner, other.name),
1051
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
1052
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
1053
                    });
1054
                    if !ids.contains(&other.id) {
1055
                        ids.push(other.id);
1056
                    }
1057
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
1058
                } else {
1059
                    left_out.push(format!(
1060
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
1061
                        other.owner, other.name
1062
                    ));
1063
                }
1064
                break;
1065
            }
1066
            if !hosted {
1067
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
1068
            }
1069
        }
1070
    }
1071
    (inputs, ids, left_out)
1072
}
1073
1074
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
1075
    match fdb::job(&app.db, id).await? {
1076
        Some(j) if j.status == "running" => Ok(j),
1077
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
1078
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
1079
    }
1080
}
1081
1082
/// `POST /api/v1/flow/jobs/{id}/log`.
1083
pub async fn log(
1084
    State(app): State<Shared>,
1085
    headers: HeaderMap,
1086
    Path(id): Path<i64>,
1087
    Json(mut batch): Json<LogBatch>,
1088
) -> Result<StatusCode, ApiError> {
1089
    let me = runner_auth(&app, &headers).await?;
1090
    let job = running_job(&app, id).await?;
1091
    // A runner busy with its only job claims nothing, so its log and
1092
    // finish calls are what keep it online (oxygenhub#78).
1093
    db::runners::seen(&app.db, me.id, None, None).await?;
1094
    // An empty batch asks only whether the job still runs: a cancelled
1095
    // one answers 409 and its runner stops it (oxygenflow#32).
1096
    if batch.records.is_empty() {
1097
        return Ok(StatusCode::NO_CONTENT);
1098
    }
1099
    // A secret's value never reaches the stored log (#107).
1100
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
1101
        Ok(spec) if !spec.secrets.is_empty() => {
1102
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
1103
            let row = match run {
1104
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
1105
                None => None,
1106
            };
1107
            match row {
1108
                Some(row) => {
1109
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
1110
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
1111
                }
1112
                None => vec![],
1113
            }
1114
        }
1115
        _ => vec![],
1116
    };
1117
    let kid = app.oidc.get().map(|i| i.kid.clone());
1118
    for r in &mut batch.records {
1119
        if !values.is_empty() {
1120
            r.text = crate::secrets::mask(&r.text, &values);
1121
        }
1122
        if let Some(kid) = &kid {
1123
            r.text = crate::oidc::mask(&r.text, kid);
1124
        }
1125
        if r.text.len() > MAX_RECORD_TEXT {
1126
            let mut cut = MAX_RECORD_TEXT;
1127
            while !r.text.is_char_boundary(cut) {
1128
                cut -= 1;
1129
            }
1130
            r.text.truncate(cut);
1131
        }
1132
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
1133
            r.stream = "console".into();
1134
        }
1135
    }
1136
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
1137
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
1138
        tracing::info!("flow: job {} reached the log limit", job.id);
1139
    }
1140
    Ok(StatusCode::NO_CONTENT)
1141
}
1142
1143
/// Largest artifact the hub keeps; the runner refuses larger ones first.
1144
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
1145
1146
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
1147
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
1148
/// repository. Answers `{"blob"}`.
1149
pub async fn artifact(
1150
    State(app): State<Shared>,
1151
    headers: HeaderMap,
1152
    Path((id, name)): Path<(i64, String)>,
1153
    body: axum::body::Bytes,
1154
) -> Result<Response, ApiError> {
1155
    runner_auth(&app, &headers).await?;
1156
    let job = running_job(&app, id).await?;
1157
    let spec: flow_core::Job =
1158
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1159
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
1160
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
1161
    };
1162
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1163
    let row =
1164
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1165
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1166
    // A report's artifact is its manifest (#127): checked, and its files
1167
    // found in the store, before it is kept.
1168
    let report = match out.kind {
1169
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
1170
        flow_core::OutputKind::Artifact => None,
1171
    };
1172
    let content = blake3::hash(&body).to_hex().to_string();
1173
    let size = body.len() as i64;
1174
    let artifact = report.is_none();
1175
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
1176
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
1177
    let blob = app
1178
        .repos
1179
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1180
        .await
1181
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
1182
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
1183
    fdb::add_artifact(&app.db, &a).await?;
1184
    if let Some((manifest, declaration)) = report {
1185
        let origin = crate::reports::Origin {
1186
            repo_id: row.id,
1187
            run_id: run.id,
1188
            run_number: run.number,
1189
            job_id: job.id,
1190
            job: &job.name,
1191
            bookmark: &run.bookmark,
1192
            state: &run.state,
1193
        };
1194
        let said = match declaration {
1195
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
1196
                Ok(id) => {
1197
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
1198
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
1199
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
1200
                }
1201
                Err(e) => format!("report {}: not indexed: {e}", a.name),
1202
            },
1203
            Err(why) => {
1204
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
1205
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
1206
                format!("report {}: report.toml: {why}", a.name)
1207
            }
1208
        };
1209
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1210
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1211
    }
1212
    // A coverage report (#113) is read now; what it says, or why it could
1213
    // not be read, goes in the job's log. It never fails the upload.
1214
    if let Some(bytes) = report_bytes {
1215
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
1216
            Ok(text) | Err(text) => text,
1217
        };
1218
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1219
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1220
    }
1221
    // A test report (#114) likewise.
1222
    if let Some(bytes) = junit_bytes {
1223
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
1224
            Ok(text) | Err(text) => text,
1225
        };
1226
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1227
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1228
    }
1229
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1230
}
1231
1232
/// A check per metric with a threshold (#127, docs/reports.md §5), named
1233
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
1234
/// `fail_above` or `fail_below`, which a proposal may require.
1235
async fn report_threshold_checks(
1236
    app: &Shared,
1237
    row: &db::repos::RepoRow,
1238
    run: &fdb::Run,
1239
    name: &str,
1240
    id: i64,
1241
    metrics: &[crate::reports::Metric],
1242
) {
1243
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
1244
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
1245
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
1246
        let above = m.fail_above.filter(|a| m.value > *a);
1247
        let below = m.fail_below.filter(|b| m.value < *b);
1248
        let (status, summary) = match (above, below) {
1249
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
1250
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
1251
            _ => {
1252
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
1253
                    .into_iter()
1254
                    .flatten()
1255
                    .collect();
1256
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
1257
            }
1258
        };
1259
        let check = format!("report/{name}/{}", m.key);
1260
        if let Ok(c) = db::hooks::upsert_check(
1261
            &app.db,
1262
            row.id,
1263
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
1264
        )
1265
        .await
1266
        {
1267
            let api = crate::api::check_of(&c);
1268
            app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1269
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1270
        }
1271
    }
1272
}
1273
1274
/// Check report manifest `body` (#127) and that the repository holds every
1275
/// file it names; answers it with the report's declaration, or why that
1276
/// could not be read.
1277
async fn report_manifest(
1278
    app: &Shared,
1279
    repo: &RepoName,
1280
    name: &str,
1281
    body: &[u8],
1282
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
1283
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
1284
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
1285
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
1286
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
1287
    let found = app
1288
        .repos
1289
        .read(repo, move |r, _| {
1290
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
1291
                return Ok(Err(path.clone()));
1292
            }
1293
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
1294
        })
1295
        .await
1296
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
1297
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
1298
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
1299
    Ok((manifest, declaration))
1300
}
1301
1302
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
1303
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
1304
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
1305
/// the files follows as the report's artifact.
1306
pub async fn report_file(
1307
    State(app): State<Shared>,
1308
    headers: HeaderMap,
1309
    Path((id, name)): Path<(i64, String)>,
1310
    body: axum::body::Bytes,
1311
) -> Result<Response, ApiError> {
1312
    runner_auth(&app, &headers).await?;
1313
    let job = running_job(&app, id).await?;
1314
    let spec: flow_core::Job =
1315
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1316
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
1317
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
1318
    }
1319
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
1320
    if !crate::reports::path_ok(path) {
1321
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
1322
    }
1323
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1324
    let row =
1325
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1326
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1327
    let blob = app
1328
        .repos
1329
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1330
        .await
1331
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
1332
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
1333
}
1334
1335
/// Parse and keep a job's JUnit report: what it says, or why not.
1336
async fn read_junit(
1337
    app: &Shared,
1338
    row: &db::repos::RepoRow,
1339
    run: &fdb::Run,
1340
    job: &fdb::Job,
1341
    blob: &str,
1342
    bytes: axum::body::Bytes,
1343
) -> Result<String, String> {
1344
    if bytes.len() > crate::junit::MAX_REPORT {
1345
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
1346
    }
1347
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
1348
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
1349
        .await
1350
        .map_err(|e| format!("tests: {e}"))?
1351
        .map_err(|e| format!("tests: {e}"))?;
1352
    let origin = crate::junit::Origin {
1353
        repo_id: row.id,
1354
        run_id: run.id,
1355
        run_number: run.number,
1356
        job_id: job.id,
1357
        job: &job.name,
1358
        bookmark: &run.bookmark,
1359
        state: &run.state,
1360
        blob,
1361
    };
1362
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
1363
    let r = crate::junit::for_run(&app.db, run.id)
1364
        .await
1365
        .map_err(|e| format!("tests: {e}"))?
1366
        .into_iter()
1367
        .find(|r| r.job_id == job.id)
1368
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
1369
    // The `tests` check on the run's state: what fails, and which of those
1370
    // passed in the same job's latest report on the default bookmark, so a
1371
    // proposal names what it broke.
1372
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
1373
    let newly = match &base {
1374
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
1375
        _ => vec![],
1376
    };
1377
    let (status, summary) = if r.broken() == 0 {
1378
        ("passed", format!("{} passed", r.passed))
1379
    } else if let Some(b) = &base {
1380
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
1381
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
1382
        let new = if newly.is_empty() {
1383
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
1384
        } else {
1385
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
1386
        };
1387
        ("failed", format!("{} failing; {new}", r.broken()))
1388
    } else {
1389
        ("failed", format!("{} failing", r.broken()))
1390
    };
1391
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
1392
    if let Ok(c) = db::hooks::upsert_check(
1393
        &app.db,
1394
        row.id,
1395
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
1396
    )
1397
    .await
1398
    {
1399
        let api = crate::api::check_of(&c);
1400
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1401
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1402
    }
1403
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
1404
}
1405
1406
/// Parse and keep a job's coverage report: what it says, or why not.
1407
async fn read_coverage(
1408
    app: &Shared,
1409
    repo: &RepoName,
1410
    row: &db::repos::RepoRow,
1411
    run: &fdb::Run,
1412
    job: &fdb::Job,
1413
    blob: &str,
1414
    bytes: axum::body::Bytes,
1415
) -> Result<String, String> {
1416
    if bytes.len() > crate::coverage::MAX_REPORT {
1417
        return Err(format!(
1418
            "coverage: the report is {} MiB; at most {} MiB is read",
1419
            bytes.len() >> 20,
1420
            crate::coverage::MAX_REPORT >> 20
1421
        ));
1422
    }
1423
    let text = String::from_utf8(bytes.to_vec())
1424
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
1425
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
1426
        .await
1427
        .map_err(|e| format!("coverage: {e}"))?
1428
        .map_err(|e| format!("coverage: {e}"))?;
1429
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
1430
    let in_state: std::collections::HashSet<String> = app
1431
        .repos
1432
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
1433
        .await
1434
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
1435
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
1436
    let origin = crate::coverage::Origin {
1437
        repo_id: row.id,
1438
        run_id: Some(run.id),
1439
        run_number: run.number,
1440
        job_id: Some(job.id),
1441
        uploader: "",
1442
        job: &job.name,
1443
        bookmark: &run.bookmark,
1444
        state: &run.state,
1445
        heads: &heads,
1446
        blob,
1447
        format,
1448
    };
1449
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
1450
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
1451
        return Err("coverage: the report was not kept".into());
1452
    };
1453
    // The `coverage` check on the run's state (#113): against the default
1454
    // bookmark, under the repository's rules.
1455
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
1456
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
1457
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
1458
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
1459
    if let Ok(c) = db::hooks::upsert_check(
1460
        &app.db,
1461
        row.id,
1462
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
1463
    )
1464
    .await
1465
    {
1466
        let api = crate::api::check_of(&c);
1467
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1468
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1469
    }
1470
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
1471
    let dropped =
1472
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
1473
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
1474
}
1475
1476
/// `POST /api/v1/flow/jobs/{id}/finish`.
1477
pub async fn finish(
1478
    State(app): State<Shared>,
1479
    headers: HeaderMap,
1480
    Path(id): Path<i64>,
1481
    Json(f): Json<Finish>,
1482
) -> Result<StatusCode, ApiError> {
1483
    let me = runner_auth(&app, &headers).await?;
1484
    let job = running_job(&app, id).await?;
1485
    db::runners::seen(&app.db, me.id, None, None).await?;
1486
    let status = match (f.exit_code, &f.error) {
1487
        (Some(0), None) => "passed",
1488
        (Some(_), _) => "failed",
1489
        (None, _) => "error",
1490
    };
1491
    let (receipt, receipt_hash) = match &f.receipt {
1492
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
1493
            // A passing job's receipt names exactly the artifacts uploaded,
1494
            // in name order; a failing one's names none, though it may have
1495
            // kept `when "always"` artifacts (oxygenflow#37).
1496
            let kept: Vec<String> =
1497
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
1498
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
1499
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
1500
            }
1501
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
1502
        }
1503
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
1504
        None => (String::new(), String::new()),
1505
    };
1506
    let error = f.error.clone().unwrap_or_default();
1507
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
1508
    let changed = fdb::finish(
1509
        &app.db,
1510
        job.id,
1511
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
1512
    )
1513
    .await?;
1514
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1515
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1516
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
1517
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
1518
                    Ok((attest, signer)) => (attest, signer, String::new()),
1519
                    Err(e) => {
1520
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
1521
                        (String::new(), String::new(), e)
1522
                    }
1523
                };
1524
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
1525
            }
1526
        }
1527
    }
1528
    report_changed(&app, job.run_id, changed).await?;
1529
    Ok(StatusCode::NO_CONTENT)
1530
}
1531
1532
/// Report the checks of jobs whose status changed.
1533
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
1534
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
1535
        return Ok(());
1536
    };
1537
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
1538
        return Ok(());
1539
    };
1540
    for j in changed {
1541
        let summary = match j.status.as_str() {
1542
            "passed" => format!("passed in {}", duration(&j)),
1543
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
1544
            "skipped" => "skipped: a dependency did not pass".into(),
1545
            "cancelled" => j.error.clone(),
1546
            _ => format!("could not run: {}", j.error),
1547
        };
1548
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
1549
    }
1550
    Ok(())
1551
}
1552
1553
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
1554
/// Settings, else from `hub.toml`.
1555
pub struct Limits {
1556
    /// For a job whose pipeline names none.
1557
    pub default_s: u64,
1558
    /// The most a pipeline may ask for.
1559
    pub max_s: u64,
1560
    /// Grace past a job's limit before the hub gives it up: a runner stops
1561
    /// a job at its limit and reports it within moments, so one still
1562
    /// running this long after is one the runner no longer reports (it was
1563
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
1564
    pub grace_ms: i64,
1565
}
1566
1567
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
1568
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
1569
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
1570
1571
pub async fn limits(app: &Shared) -> Limits {
1572
    let minutes = |key: &'static str| async move {
1573
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
1574
    };
1575
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
1576
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
1577
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
1578
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
1579
}
1580
1581
/// End every job still running past its time limit plus the grace:
1582
/// it is marked an error with the reason, the jobs that depended on it are
1583
/// skipped and its run settled, as when a runner reports an error.
1584
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
1585
    let limits = limits(app).await;
1586
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
1587
    let n = lost.len();
1588
    for job in lost {
1589
        let error = format!(
1590
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
1591
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
1592
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
1593
        );
1594
        let changed =
1595
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1596
                .await?;
1597
        tracing::warn!("flow: job {} {error}", job.id);
1598
        report_changed(app, job.run_id, changed).await?;
1599
    }
1600
    Ok(n)
1601
}
1602
1603
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
1604
/// it restarted since claiming them, or never received the claim. A
1605
/// runner claims one job at a time, so a job it holds is always listed.
1606
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
1607
    for job in fdb::running_on(&app.db, runner.id).await? {
1608
        if holding.contains(&job.id) {
1609
            continue;
1610
        }
1611
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
1612
        let changed =
1613
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1614
                .await?;
1615
        tracing::warn!("flow: job {} {error}", job.id);
1616
        report_changed(app, job.run_id, changed).await?;
1617
    }
1618
    Ok(())
1619
}
1620
1621
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
1622
/// repository that runs Flow, the jobs of its default bookmark's pipeline
1623
/// with a `schedule` line due then. Returns how many runs started.
1624
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
1625
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
1626
    let mut started = 0;
1627
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
1628
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
1629
        let b = row.default_bookmark.clone();
1630
        let found = app
1631
            .repos
1632
            .read(&repo, move |r, _| {
1633
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
1634
                Ok(Some((st, r.store().get_state(&st)?.heads)))
1635
            })
1636
            .await;
1637
        let Ok(Some((state, heads))) = found else { continue };
1638
        if heads.is_empty() {
1639
            continue;
1640
        }
1641
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
1642
            Ok(Some(_)) => started += 1,
1643
            Ok(None) => {}
1644
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
1645
        }
1646
    }
1647
    started
1648
}
1649
1650
/// The hub's clock as a [`When`](flow_core::pipeline::When).
1651
pub fn when_now() -> flow_core::pipeline::When {
1652
    let now = time::OffsetDateTime::now_utc();
1653
    flow_core::pipeline::When {
1654
        minute: now.minute() as u32,
1655
        hour: now.hour() as u32,
1656
        day: now.day() as u32,
1657
        month: u8::from(now.month()) as u32,
1658
        weekday: now.weekday().number_days_from_sunday() as u32,
1659
    }
1660
}
1661
1662
/// Start the scheduled runs due each minute, once a minute.
1663
pub async fn scheduler(app: Shared) {
1664
    let mut last = None;
1665
    loop {
1666
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1667
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
1668
        if last == Some(minute) {
1669
            continue;
1670
        }
1671
        last = Some(minute);
1672
        let n = run_due(&app, when_now()).await;
1673
        if n > 0 {
1674
            tracing::info!("flow: {n} scheduled run(s) started");
1675
        }
1676
    }
1677
}
1678
1679
/// Look for lost jobs once a minute.
1680
pub async fn reaper(app: Shared) {
1681
    loop {
1682
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1683
        if let Err(e) = reap_lost_jobs(&app).await {
1684
            tracing::warn!("flow: looking for lost jobs: {e}");
1685
        }
1686
    }
1687
}
1688
1689
/// Check a runner's signed receipt for a job of `run` and keep it in the
1690
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
1691
/// name, or why it was not kept. It must be the job's receipt, for the
1692
/// run's state and repository, signed by the key the runner's token first
1693
/// signed with.
1694
pub async fn keep_receipt(
1695
    app: &Shared,
1696
    runner: &db::runners::Runner,
1697
    row: &db::repos::RepoRow,
1698
    run: &fdb::Run,
1699
    receipt: &flow_core::Receipt,
1700
    signed_hex: &str,
1701
) -> Result<(String, String), String> {
1702
    use oxy_core::receipt::SignedReceipt;
1703
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
1704
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
1705
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
1706
    if sr.receipt.hash().0 != receipt.hash() {
1707
        return Err("it signs another receipt than the job's".into());
1708
    }
1709
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1710
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
1711
    if sr.state() != state {
1712
        return Err("it is for another state than the run's".into());
1713
    }
1714
    let key = sr.runner.hash().to_hex();
1715
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
1716
    if bound != key {
1717
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
1718
    }
1719
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
1720
    let signer = sr.runner.name.clone();
1721
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
1722
    Ok((attest.to_hex(), signer))
1723
}
1724
1725
/// A job's wall time, as the pages show it.
1726
pub fn duration(j: &fdb::Job) -> String {
1727
    match (j.started_ms, j.finished_ms) {
1728
        (Some(a), Some(b)) => human_ms(b - a),
1729
        (Some(a), None) => human_ms(db::now_ms() - a),
1730
        _ => "–".into(),
1731
    }
1732
}
1733
1734
pub fn human_ms(ms: i64) -> String {
1735
    let ms = ms.max(0);
1736
    if ms < 1000 {
1737
        format!("{ms} ms")
1738
    } else if ms < 60_000 {
1739
        format!("{:.1} s", ms as f64 / 1000.0)
1740
    } else {
1741
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
1742
    }
1743
}
1744
1745
/// Where a live run stands among the runners' work: what its viewers may
1746
/// know of it. Jobs of other repositories are named only when those are
1747
/// public; the rest are counted.
1748
#[derive(Clone, Debug, Default, PartialEq, Eq)]
1749
pub struct QueueInfo {
1750
    /// 1-based place of the run's first queued job among all queued jobs.
1751
    pub position: Option<usize>,
1752
    /// Jobs running now, and queued jobs ahead of the run's first one.
1753
    pub running: Vec<String>,
1754
    pub ahead: Vec<String>,
1755
    /// Of those, jobs in repositories the page may not name.
1756
    pub hidden: usize,
1757
}
1758
1759
impl QueueInfo {
1760
    /// "3rd in line" and the like.
1761
    pub fn place(&self) -> Option<String> {
1762
        self.position.map(|n| {
1763
            let suffix = match (n % 10, n % 100) {
1764
                (1, 11) | (2, 12) | (3, 13) => "th",
1765
                (1, _) => "st",
1766
                (2, _) => "nd",
1767
                (3, _) => "rd",
1768
                _ => "th",
1769
            };
1770
            format!("{n}{suffix} in line")
1771
        })
1772
    }
1773
}
1774
1775
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
1776
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
1777
    let mut info = QueueInfo::default();
1778
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
1779
    info.position = first.map(|i| i + 1);
1780
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
1781
    let mut queued_seen = 0;
1782
    for e in queue {
1783
        if e.run_id == run_id {
1784
            continue;
1785
        }
1786
        let ahead = match e.status.as_str() {
1787
            "running" => true,
1788
            _ => {
1789
                queued_seen += 1;
1790
                first.is_some_and(|f| queued_seen <= f)
1791
            }
1792
        };
1793
        if !ahead {
1794
            continue;
1795
        }
1796
        if e.repo_id != repo_id && e.visibility != "public" {
1797
            info.hidden += 1;
1798
        } else if e.status == "running" {
1799
            info.running.push(label(e));
1800
        } else {
1801
            info.ahead.push(label(e));
1802
        }
1803
    }
1804
    info
1805
}
1806
1807
#[cfg(test)]
1808
mod queue_tests {
1809
    use super::*;
1810
1811
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
1812
        fdb::QueueEntry {
1813
            job_id,
1814
            run_id,
1815
            repo_id,
1816
            owner: "o".into(),
1817
            name: format!("r{repo_id}"),
1818
            visibility: vis.into(),
1819
            run_number: run_id,
1820
            job: job.into(),
1821
            status: status.into(),
1822
            runner: String::new(),
1823
            spec: String::new(),
1824
            sandbox: String::new(),
1825
        }
1826
    }
1827
1828
    #[test]
1829
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
1830
        // Running first, then queued in claim order (as `fdb::queue` returns them).
1831
        let q = vec![
1832
            e(1, 10, 1, "public", "build", "running"),
1833
            e(2, 11, 2, "private", "test", "running"),
1834
            e(3, 10, 1, "public", "test", "queued"),
1835
            e(4, 12, 3, "private", "lint", "queued"),
1836
            e(5, 13, 4, "public", "build", "queued"),
1837
            e(6, 13, 4, "public", "test", "queued"),
1838
            e(7, 14, 5, "public", "late", "queued"),
1839
        ];
1840
        let info = queue_info(&q, 4, 13);
1841
        assert_eq!(info.position, Some(3));
1842
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
1843
        assert_eq!(info.running, ["o/r1 #10 build"]);
1844
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
1845
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
1846
        // A private repository's own jobs are named to its viewers.
1847
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
1848
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
1849
        // A run with nothing queued has no place.
1850
        assert_eq!(queue_info(&q, 1, 99).position, None);
1851
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
1852
        assert_eq!(
1853
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
1854
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
1855
        );
1856
    }
1857
}
1858
1859
#[cfg(test)]
1860
mod placement_tests {
1861
    use super::*;
1862
1863
    #[test]
1864
    fn a_waiting_job_says_what_each_runner_lacks() {
1865
        use crate::db::runners::Runner;
1866
        let rootfs = "ab".repeat(32);
1867
        let job = fdb::Job {
1868
            id: 7,
1869
            runtime: "syscall".into(),
1870
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
1871
            spec: serde_json::json!({ "name": "build" }).to_string(),
1872
            ..Default::default()
1873
        };
1874
        let now = db::now_ms();
1875
        let runner = |id: i64, name: &str| Runner {
1876
            id,
1877
            name: name.into(),
1878
            state: "active".into(),
1879
            last_seen_ms: Some(now),
1880
            arch: "x86_64".into(),
1881
            tiers: "microvm,syscall".into(),
1882
            images: serde_json::to_string(&[&rootfs]).unwrap(),
1883
            memory_mib: Some(16384),
1884
            cpus: Some(8),
1885
            ..Default::default()
1886
        };
1887
        let archs = std::collections::HashMap::new();
1888
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
1889
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
1890
        };
1891
        assert_eq!(why(&[], &[]), "no runner is online");
1892
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
1893
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
1894
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
1895
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
1896
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
1897
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
1898
        assert_eq!(
1899
            why(&[microvm_only, small, other, offline], &[]),
1900
            "no runner can take it: earth-1 has no syscall tier (it offers microvm); pi has 2048 MiB; the job needs 4096 MiB; bobs serves other repositories"
1901
        );
1902
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
1903
        assert_eq!(
1904
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
1905
            "no runner can take it: narvi is riscv64; the job needs x86_64"
1906
        );
1907
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
1908
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
1909
        let d = fdb::DeclineRow {
1910
            job_id: 7,
1911
            runner_id: 1,
1912
            runner: "earth-1".into(),
1913
            reason: "only 512 MiB of memory is free here".into(),
1914
            ms: now,
1915
        };
1916
        assert_eq!(
1917
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
1918
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
1919
        );
1920
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
1921
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
1922
    }
1923
}