OXYGENHUB
# 32d97df55ab7 oxygenhub / crates / hub / src / flow.rs
86.6 KiB · Rust · last changed Diffusion: a bump's run reads the upstream it bumps 23 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
    app.swarm.claimed(me.id, req.swarm.as_ref());
561
    let room = fdb::Room {
562
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
563
        memory_mib: req.free_memory_mib,
564
        cpus: req.free_cpus,
565
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
566
        arch,
567
        image_archs: fdb::image_archs(&app.db).await?,
568
        fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() },
569
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
570
    };
571
    // The labels it reports now, or those it registered with, and those
572
    // an administrator gave it (#98).
573
    let mut labels: Vec<String> = match &req.info {
574
        Some(i) => i.labels.clone(),
575
        None => me.reported_label_list().into_iter().map(String::from).collect(),
576
    };
577
    for l in me.admin_label_list() {
578
        if !labels.iter().any(|x| x == l) {
579
            labels.push(l.to_string());
580
        }
581
    }
582
    // Ranking (oxygenflow#42): a job another free runner would do better
583
    // (it holds the image, or is faster) is held for it briefly.
584
    let runners = db::runners::list(&app.db).await?;
585
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
586
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
587
    let owners: std::collections::HashMap<i64, (String, i64)> =
588
        fdb::queue(&app.db).await?.into_iter().map(|q| (q.job_id, (q.owner, q.repo_id))).collect();
589
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
590
    let now = db::now_ms();
591
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
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(&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(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 = HELD.lock().unwrap_or_else(|p| p.into_inner()).as_mut().and_then(|h| h.remove(&job.id)).map(|t| now - t);
622
    let rootfs = rootfs_of(&job);
623
    let mut why = vec![if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
624
        "holds the image".to_string()
625
    } else {
626
        "fetches the image from the hub".to_string()
627
    }];
628
    match running.get(&me.id).copied().unwrap_or(0) {
629
        0 => why.push("idle".into()),
630
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
631
    }
632
    if let Some(sp) = me_now.speed {
633
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
634
    }
635
    if let Some(w) = waited {
636
        why.push(format!("after {} s held for a better runner", w / 1000));
637
    }
638
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
639
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
640
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
641
    let row =
642
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
643
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
644
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
645
    // The job's limit: its own timeout, else its sandbox's, else the site
646
    // default, never past the site maximum; recorded for the reaper.
647
    let limits = limits(&app).await;
648
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
649
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
650
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
651
    // ...and never past the runner's own limit, when it has one (#98).
652
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
653
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
654
    }
655
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
656
    let claim = Claim {
657
        job_id: job.id,
658
        run_id: run.id,
659
        repo: format!("{}/{}", row.owner, row.name),
660
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
661
        token: secret,
662
        heads: run.head_list().into_iter().map(String::from).collect(),
663
        pipeline_hash: run.pipeline_hash.clone(),
664
        job: spec,
665
        sandbox,
666
        timeout_s,
667
        subrepos: vec![],
668
        state: run.state.clone(),
669
        bookmark: run.bookmark.clone(),
670
        run_number: run.number,
671
        // The site's, the owner's and the repository's variables (#102).
672
        env: crate::variables::env_for_repo(&app.db, &row).await?,
673
        secrets: vec![],
674
    };
675
    let mut claim = claim;
676
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
677
    // The secrets the job declares, as files (#107); one not set is said
678
    // at the top of the log and left out, so the job fails on the file.
679
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
680
    let found = crate::secrets::for_job(&app, &row, &names).await?;
681
    // The identities it asks for (#103): a token each, as a file.
682
    if !claim.job.identities.is_empty() {
683
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
684
        for i in &claim.job.identities {
685
            let token = issuer.mint(&crate::oidc::JobClaims {
686
                issuer: app.config.base_url(),
687
                audience: &i.audience,
688
                owner: &row.owner,
689
                name: &row.name,
690
                visibility: &row.visibility,
691
                bookmark: &run.bookmark,
692
                run_number: run.number,
693
                job: &job.name,
694
                state: &run.state,
695
                runner: &runner,
696
                actor: &run.actor,
697
                now_ms: db::now_ms(),
698
                ttl_s: timeout_s,
699
            });
700
            claim.secrets.push(flow_core::api::SecretInput {
701
                name: format!("identity:{}", i.audience),
702
                path: i.mount_path.clone(),
703
                value: token,
704
            });
705
        }
706
    }
707
    for s in &claim.job.secrets {
708
        match found.iter().find(|(n, _)| *n == s.name) {
709
            Some((name, value)) => {
710
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
711
            }
712
            None => left_out.push(format!(
713
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
714
                s.name, s.mount_path
715
            )),
716
        }
717
    }
718
    if !ids.is_empty() {
719
        fdb::set_also_read(&app.db, job.id, &ids).await?;
720
    }
721
    // Say at the top of the job's log what it will not have (oxygenhub#56),
722
    // rather than let it fail later on missing files.
723
    if !left_out.is_empty() {
724
        let records: Vec<flow_core::api::Record> =
725
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
726
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
727
    }
728
    claim.subrepos = inputs;
729
    Ok(Json(claim).into_response())
730
}
731
732
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
733
/// labels and capacity, each list sorted.
734
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
735
    let sorted = |v: &[String]| {
736
        let mut v = v.to_vec();
737
        v.sort();
738
        v
739
    };
740
    let info = req.info.as_ref();
741
    let v = serde_json::json!({
742
        "arch": arch,
743
        "tiers": sorted(&req.runtimes),
744
        "images": req.images.as_deref().map(sorted),
745
        "labels": info.map(|i| sorted(&i.labels)),
746
        "memory_mib": info.and_then(|i| i.memory_mib),
747
        "cpus": info.and_then(|i| i.cpus),
748
    });
749
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
750
}
751
752
/// A job handed back by this many different runners is unplaceable.
753
pub const UNPLACEABLE_AFTER: usize = 3;
754
755
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
756
/// runner will not run a job it was handed. The job is queued again and
757
/// not offered to that runner while its profile stays the same (and for
758
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
759
/// different runners have declined it, it ends as unplaceable with their
760
/// reasons. A decline is never a failed check.
761
pub async fn decline(
762
    State(app): State<Shared>,
763
    headers: HeaderMap,
764
    Path(id): Path<i64>,
765
    Json(d): Json<flow_core::api::Decline>,
766
) -> Result<StatusCode, ApiError> {
767
    let me = runner_auth(&app, &headers).await?;
768
    db::runners::seen(&app.db, me.id, None, None).await?;
769
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
770
    let reason = match reason.trim() {
771
        "" => "no reason given".to_string(),
772
        r => r.to_string(),
773
    };
774
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
775
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
776
    };
777
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
778
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
779
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
780
    if runners.len() >= UNPLACEABLE_AFTER {
781
        let error = format!(
782
            "unplaceable: declined by {} runners: {}",
783
            runners.len(),
784
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
785
        );
786
        let changed =
787
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
788
                .await?;
789
        report_changed(&app, job.run_id, changed).await?;
790
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
791
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
792
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
793
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
794
            fdb::set_waiting(&app.db, id, &summary).await?;
795
        }
796
    }
797
    Ok(StatusCode::NO_CONTENT)
798
}
799
800
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
801
/// now (spec §11.4), from what each runner offered at its last claim:
802
/// empty when one could (it waits its turn or a free slot), else what
803
/// each one lacks. `declines` are the job's.
804
/// What placement knows of the fleet: images' architectures, the images
805
/// the hub holds, and the time.
806
pub struct Fleet<'a> {
807
    pub image_archs: &'a std::collections::HashMap<String, String>,
808
    pub hub_images: &'a std::collections::HashSet<String>,
809
    pub now: i64,
810
}
811
812
/// Why runner `r` cannot take `job` of a repository of `owner` (id
813
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
814
/// when it can. `declines` are the job's.
815
pub fn runner_lacks(
816
    job: &fdb::Job,
817
    owner: &str,
818
    repo_id: i64,
819
    r: &db::runners::Runner,
820
    fleet: &Fleet,
821
    declines: &[fdb::DeclineRow],
822
) -> Option<String> {
823
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
824
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
825
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
826
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
827
    if r.state == "draining" {
828
        return Some("is draining".into());
829
    }
830
    let scope = r.scope();
831
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
832
        return Some("serves other repositories".into());
833
    }
834
    let tiers = r.tier_list();
835
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
836
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
837
    }
838
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
839
        return Some(format!("is {}; the job needs {w}", r.arch));
840
    }
841
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
842
    if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
843
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
844
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
845
    }
846
    let labels = r.label_list();
847
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
848
        return Some(format!("lacks label {l}"));
849
    }
850
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
851
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
852
    }
853
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
854
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
855
    }
856
    // The disk it learned it needs (oxygenflow#41).
857
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
858
        if free < n {
859
            return Some(format!(
860
                "has {} free; the job needs ~{} (its recent runs took up to {})",
861
                mib(free),
862
                mib(n),
863
                mib((n - 64) * 4 / 5)
864
            ));
865
        }
866
    }
867
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
868
        return Some(format!("declined it: {}", d.reason));
869
    }
870
    None
871
}
872
873
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
874
/// now (spec §11.4), from what each runner offered at its last claim:
875
/// empty when one could (it waits its turn or a free slot), else what
876
/// each one lacks. `declines` are the job's.
877
#[allow(clippy::too_many_arguments)]
878
pub fn why_waiting(
879
    job: &fdb::Job,
880
    owner: &str,
881
    repo_id: i64,
882
    runners: &[db::runners::Runner],
883
    image_archs: &std::collections::HashMap<String, String>,
884
    declines: &[fdb::DeclineRow],
885
    now: i64,
886
    hub_images: &std::collections::HashSet<String>,
887
) -> String {
888
    let fleet = Fleet { image_archs, hub_images, now };
889
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
890
    if online.is_empty() {
891
        return "no runner is online".into();
892
    }
893
    let mut why = Vec::new();
894
    for r in &online {
895
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
896
            None => return String::new(),
897
            Some(w) => why.push(format!("{} {w}", r.name)),
898
        }
899
    }
900
    let more = why.len().saturating_sub(4);
901
    why.truncate(4);
902
    let mut text = format!("no runner can take it: {}", why.join("; "));
903
    if more > 0 {
904
        text.push_str(&format!("; and {more} more"));
905
    }
906
    text
907
}
908
909
/// How long a job is held for a better runner before any runner that fits
910
/// takes it (oxygenflow#42).
911
const BETTER_HOLD_MS: i64 = 15_000;
912
/// A runner counts as there to take a job when it asked this recently.
913
const POLLING_MS: i64 = 10_000;
914
915
/// When each job was first passed over for a better runner.
916
static HELD: std::sync::Mutex<Option<std::collections::HashMap<i64, i64>>> = std::sync::Mutex::new(None);
917
918
/// How good runner `r` is for a job on image `rootfs`: holding the image
919
/// counts most, then the speed class.
920
fn rank(r: &db::runners::Runner, rootfs: &str) -> i64 {
921
    let holds = r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs));
922
    (holds as i64) * 10 + r.speed_class.unwrap_or(0)
923
}
924
925
fn rootfs_of(job: &fdb::Job) -> String {
926
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
927
}
928
929
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
930
fn mib(m: i64) -> String {
931
    if m >= 1024 {
932
        format!("{:.1} GiB", m as f64 / 1024.0)
933
    } else {
934
        format!("{m} MiB")
935
    }
936
}
937
938
/// One placement pass: say why each ready queued job waits.
939
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
940
    let ready = fdb::ready_queued(&app.db).await?;
941
    if ready.is_empty() {
942
        return Ok(());
943
    }
944
    let runners = db::runners::list(&app.db).await?;
945
    let archs = fdb::image_archs(&app.db).await?;
946
    let held = crate::images::held(&app.db).await?;
947
    let now = db::now_ms();
948
    for (job, owner, repo_id) in ready {
949
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
950
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
951
        if why == job.waiting {
952
            continue;
953
        }
954
        fdb::set_waiting(&app.db, job.id, &why).await?;
955
        // The job's check says it too, so a proposal shows why it is stuck
956
        // (oxygenhub#28).
957
        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?) {
958
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
959
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
960
        }
961
    }
962
    Ok(())
963
}
964
965
/// Run a placement pass every 15 seconds.
966
pub async fn placement(app: Shared) {
967
    loop {
968
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
969
        if let Err(e) = explain_waits(&app).await {
970
            tracing::warn!("flow: placement pass: {e}");
971
        }
972
    }
973
}
974
975
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
976
pub(crate) async fn pins_in(
977
    app: &Shared,
978
    name: crate::repos::RepoName,
979
    heads: Vec<oxy_core::hash::ChangeHash>,
980
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
981
    app.repos
982
        .read(&name, move |repo, cache| {
983
            let loaded = cache.load(repo, &heads)?;
984
            let mut out = Vec::new();
985
            for (path, f) in &loaded.snapshot.files {
986
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
987
                    continue;
988
                }
989
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
990
                    if let Some(b) = blobs.first() {
991
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
992
                            .read_blob(b)
993
                            .map_err(crate::repos::RepoError::Oxy)
994
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
995
                        {
996
                            out.push((path.clone(), id, heads));
997
                        }
998
                    }
999
                }
1000
            }
1001
            Ok(out)
1002
        })
1003
        .await
1004
        .unwrap_or_default()
1005
}
1006
1007
/// How deep subrepos of subrepos are followed.
1008
const MAX_SUBREPO_DEPTH: usize = 3;
1009
1010
/// The Oxygen subrepos the run's state pins, and those their states pin in
1011
/// turn (parents first, paths joined), that live on this hub and that the
1012
/// run's pusher may read: what the job's workspace needs beside its own
1013
/// tree. A pin the hub cannot serve is left out, with what it pins, and
1014
/// the third list says why, for the job's log.
1015
async fn subrepo_inputs(
1016
    app: &Shared,
1017
    row: &db::repos::RepoRow,
1018
    run: &fdb::Run,
1019
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
1020
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
1021
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1022
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1023
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
1024
    let mut inputs = Vec::new();
1025
    let mut ids = Vec::new();
1026
    let mut left_out = Vec::new();
1027
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
1028
    // (path prefix, repository, heads, depth), parents before children.
1029
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
1030
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
1031
        if depth == MAX_SUBREPO_DEPTH {
1032
            continue;
1033
        }
1034
        for (path, id, pinned) in pins_in(app, name, heads).await {
1035
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
1036
            let mut hosted = false;
1037
            for other in &rows {
1038
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
1039
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
1040
                if !matches {
1041
                    continue;
1042
                }
1043
                hosted = true;
1044
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
1045
                // A Diffusion bump's run reads the upstream it bumps: the one
1046
                // the consumer pins (someone who may write it committed the
1047
                // pin), and no other (#150).
1048
                let diffused = run.actor == crate::diffusion::ACCOUNT
1049
                    && run.bookmark.starts_with("diffusion/")
1050
                    && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ? AND upstream_repo_id = ?")
1051
                        .bind(run.repo_id)
1052
                        .bind(other.id)
1053
                        .fetch_one(&app.db)
1054
                        .await
1055
                        .unwrap_or(0)
1056
                        > 0;
1057
                if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) {
1058
                    inputs.push(flow_core::api::SubrepoInput {
1059
                        path: path.clone(),
1060
                        repo: format!("{}/{}", other.owner, other.name),
1061
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
1062
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
1063
                    });
1064
                    if !ids.contains(&other.id) {
1065
                        ids.push(other.id);
1066
                    }
1067
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
1068
                } else {
1069
                    left_out.push(format!(
1070
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
1071
                        other.owner, other.name
1072
                    ));
1073
                }
1074
                break;
1075
            }
1076
            if !hosted {
1077
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
1078
            }
1079
        }
1080
    }
1081
    (inputs, ids, left_out)
1082
}
1083
1084
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
1085
    match fdb::job(&app.db, id).await? {
1086
        Some(j) if j.status == "running" => Ok(j),
1087
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
1088
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
1089
    }
1090
}
1091
1092
/// `POST /api/v1/flow/jobs/{id}/log`.
1093
pub async fn log(
1094
    State(app): State<Shared>,
1095
    headers: HeaderMap,
1096
    Path(id): Path<i64>,
1097
    Json(mut batch): Json<LogBatch>,
1098
) -> Result<StatusCode, ApiError> {
1099
    runner_auth(&app, &headers).await?;
1100
    let job = running_job(&app, id).await?;
1101
    // An empty batch asks only whether the job still runs: a cancelled
1102
    // one answers 409 and its runner stops it (oxygenflow#32).
1103
    if batch.records.is_empty() {
1104
        return Ok(StatusCode::NO_CONTENT);
1105
    }
1106
    // A secret's value never reaches the stored log (#107).
1107
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
1108
        Ok(spec) if !spec.secrets.is_empty() => {
1109
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
1110
            let row = match run {
1111
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
1112
                None => None,
1113
            };
1114
            match row {
1115
                Some(row) => {
1116
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
1117
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
1118
                }
1119
                None => vec![],
1120
            }
1121
        }
1122
        _ => vec![],
1123
    };
1124
    let kid = app.oidc.get().map(|i| i.kid.clone());
1125
    for r in &mut batch.records {
1126
        if !values.is_empty() {
1127
            r.text = crate::secrets::mask(&r.text, &values);
1128
        }
1129
        if let Some(kid) = &kid {
1130
            r.text = crate::oidc::mask(&r.text, kid);
1131
        }
1132
        if r.text.len() > MAX_RECORD_TEXT {
1133
            let mut cut = MAX_RECORD_TEXT;
1134
            while !r.text.is_char_boundary(cut) {
1135
                cut -= 1;
1136
            }
1137
            r.text.truncate(cut);
1138
        }
1139
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
1140
            r.stream = "console".into();
1141
        }
1142
    }
1143
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
1144
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
1145
        tracing::info!("flow: job {} reached the log limit", job.id);
1146
    }
1147
    Ok(StatusCode::NO_CONTENT)
1148
}
1149
1150
/// Largest artifact the hub keeps; the runner refuses larger ones first.
1151
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
1152
1153
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
1154
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
1155
/// repository. Answers `{"blob"}`.
1156
pub async fn artifact(
1157
    State(app): State<Shared>,
1158
    headers: HeaderMap,
1159
    Path((id, name)): Path<(i64, String)>,
1160
    body: axum::body::Bytes,
1161
) -> Result<Response, ApiError> {
1162
    runner_auth(&app, &headers).await?;
1163
    let job = running_job(&app, id).await?;
1164
    let spec: flow_core::Job =
1165
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1166
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
1167
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
1168
    };
1169
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1170
    let row =
1171
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1172
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1173
    // A report's artifact is its manifest (#127): checked, and its files
1174
    // found in the store, before it is kept.
1175
    let report = match out.kind {
1176
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
1177
        flow_core::OutputKind::Artifact => None,
1178
    };
1179
    let content = blake3::hash(&body).to_hex().to_string();
1180
    let size = body.len() as i64;
1181
    let artifact = report.is_none();
1182
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
1183
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
1184
    let blob = app
1185
        .repos
1186
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1187
        .await
1188
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
1189
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
1190
    fdb::add_artifact(&app.db, &a).await?;
1191
    if let Some((manifest, declaration)) = report {
1192
        let origin = crate::reports::Origin {
1193
            repo_id: row.id,
1194
            run_id: run.id,
1195
            run_number: run.number,
1196
            job_id: job.id,
1197
            job: &job.name,
1198
            bookmark: &run.bookmark,
1199
            state: &run.state,
1200
        };
1201
        let said = match declaration {
1202
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
1203
                Ok(id) => {
1204
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
1205
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
1206
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
1207
                }
1208
                Err(e) => format!("report {}: not indexed: {e}", a.name),
1209
            },
1210
            Err(why) => {
1211
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
1212
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
1213
                format!("report {}: report.toml: {why}", a.name)
1214
            }
1215
        };
1216
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1217
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1218
    }
1219
    // A coverage report (#113) is read now; what it says, or why it could
1220
    // not be read, goes in the job's log. It never fails the upload.
1221
    if let Some(bytes) = report_bytes {
1222
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
1223
            Ok(text) | Err(text) => text,
1224
        };
1225
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1226
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1227
    }
1228
    // A test report (#114) likewise.
1229
    if let Some(bytes) = junit_bytes {
1230
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
1231
            Ok(text) | Err(text) => text,
1232
        };
1233
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1234
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1235
    }
1236
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1237
}
1238
1239
/// A check per metric with a threshold (#127, docs/reports.md §5), named
1240
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
1241
/// `fail_above` or `fail_below`, which a proposal may require.
1242
async fn report_threshold_checks(
1243
    app: &Shared,
1244
    row: &db::repos::RepoRow,
1245
    run: &fdb::Run,
1246
    name: &str,
1247
    id: i64,
1248
    metrics: &[crate::reports::Metric],
1249
) {
1250
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
1251
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
1252
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
1253
        let above = m.fail_above.filter(|a| m.value > *a);
1254
        let below = m.fail_below.filter(|b| m.value < *b);
1255
        let (status, summary) = match (above, below) {
1256
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
1257
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
1258
            _ => {
1259
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
1260
                    .into_iter()
1261
                    .flatten()
1262
                    .collect();
1263
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
1264
            }
1265
        };
1266
        let check = format!("report/{name}/{}", m.key);
1267
        if let Ok(c) = db::hooks::upsert_check(
1268
            &app.db,
1269
            row.id,
1270
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
1271
        )
1272
        .await
1273
        {
1274
            let api = crate::api::check_of(&c);
1275
            app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1276
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1277
        }
1278
    }
1279
}
1280
1281
/// Check report manifest `body` (#127) and that the repository holds every
1282
/// file it names; answers it with the report's declaration, or why that
1283
/// could not be read.
1284
async fn report_manifest(
1285
    app: &Shared,
1286
    repo: &RepoName,
1287
    name: &str,
1288
    body: &[u8],
1289
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
1290
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
1291
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
1292
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
1293
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
1294
    let found = app
1295
        .repos
1296
        .read(repo, move |r, _| {
1297
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
1298
                return Ok(Err(path.clone()));
1299
            }
1300
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
1301
        })
1302
        .await
1303
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
1304
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
1305
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
1306
    Ok((manifest, declaration))
1307
}
1308
1309
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
1310
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
1311
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
1312
/// the files follows as the report's artifact.
1313
pub async fn report_file(
1314
    State(app): State<Shared>,
1315
    headers: HeaderMap,
1316
    Path((id, name)): Path<(i64, String)>,
1317
    body: axum::body::Bytes,
1318
) -> Result<Response, ApiError> {
1319
    runner_auth(&app, &headers).await?;
1320
    let job = running_job(&app, id).await?;
1321
    let spec: flow_core::Job =
1322
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1323
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
1324
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
1325
    }
1326
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
1327
    if !crate::reports::path_ok(path) {
1328
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
1329
    }
1330
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1331
    let row =
1332
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1333
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1334
    let blob = app
1335
        .repos
1336
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1337
        .await
1338
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
1339
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
1340
}
1341
1342
/// Parse and keep a job's JUnit report: what it says, or why not.
1343
async fn read_junit(
1344
    app: &Shared,
1345
    row: &db::repos::RepoRow,
1346
    run: &fdb::Run,
1347
    job: &fdb::Job,
1348
    blob: &str,
1349
    bytes: axum::body::Bytes,
1350
) -> Result<String, String> {
1351
    if bytes.len() > crate::junit::MAX_REPORT {
1352
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
1353
    }
1354
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
1355
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
1356
        .await
1357
        .map_err(|e| format!("tests: {e}"))?
1358
        .map_err(|e| format!("tests: {e}"))?;
1359
    let origin = crate::junit::Origin {
1360
        repo_id: row.id,
1361
        run_id: run.id,
1362
        run_number: run.number,
1363
        job_id: job.id,
1364
        job: &job.name,
1365
        bookmark: &run.bookmark,
1366
        state: &run.state,
1367
        blob,
1368
    };
1369
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
1370
    let r = crate::junit::for_run(&app.db, run.id)
1371
        .await
1372
        .map_err(|e| format!("tests: {e}"))?
1373
        .into_iter()
1374
        .find(|r| r.job_id == job.id)
1375
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
1376
    // The `tests` check on the run's state: what fails, and which of those
1377
    // passed in the same job's latest report on the default bookmark, so a
1378
    // proposal names what it broke.
1379
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
1380
    let newly = match &base {
1381
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
1382
        _ => vec![],
1383
    };
1384
    let (status, summary) = if r.broken() == 0 {
1385
        ("passed", format!("{} passed", r.passed))
1386
    } else if let Some(b) = &base {
1387
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
1388
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
1389
        let new = if newly.is_empty() {
1390
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
1391
        } else {
1392
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
1393
        };
1394
        ("failed", format!("{} failing; {new}", r.broken()))
1395
    } else {
1396
        ("failed", format!("{} failing", r.broken()))
1397
    };
1398
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
1399
    if let Ok(c) = db::hooks::upsert_check(
1400
        &app.db,
1401
        row.id,
1402
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
1403
    )
1404
    .await
1405
    {
1406
        let api = crate::api::check_of(&c);
1407
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1408
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1409
    }
1410
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
1411
}
1412
1413
/// Parse and keep a job's coverage report: what it says, or why not.
1414
async fn read_coverage(
1415
    app: &Shared,
1416
    repo: &RepoName,
1417
    row: &db::repos::RepoRow,
1418
    run: &fdb::Run,
1419
    job: &fdb::Job,
1420
    blob: &str,
1421
    bytes: axum::body::Bytes,
1422
) -> Result<String, String> {
1423
    if bytes.len() > crate::coverage::MAX_REPORT {
1424
        return Err(format!(
1425
            "coverage: the report is {} MiB; at most {} MiB is read",
1426
            bytes.len() >> 20,
1427
            crate::coverage::MAX_REPORT >> 20
1428
        ));
1429
    }
1430
    let text = String::from_utf8(bytes.to_vec())
1431
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
1432
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
1433
        .await
1434
        .map_err(|e| format!("coverage: {e}"))?
1435
        .map_err(|e| format!("coverage: {e}"))?;
1436
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
1437
    let in_state: std::collections::HashSet<String> = app
1438
        .repos
1439
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
1440
        .await
1441
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
1442
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
1443
    let origin = crate::coverage::Origin {
1444
        repo_id: row.id,
1445
        run_id: Some(run.id),
1446
        run_number: run.number,
1447
        job_id: Some(job.id),
1448
        uploader: "",
1449
        job: &job.name,
1450
        bookmark: &run.bookmark,
1451
        state: &run.state,
1452
        heads: &heads,
1453
        blob,
1454
        format,
1455
    };
1456
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
1457
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
1458
        return Err("coverage: the report was not kept".into());
1459
    };
1460
    // The `coverage` check on the run's state (#113): against the default
1461
    // bookmark, under the repository's rules.
1462
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
1463
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
1464
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
1465
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
1466
    if let Ok(c) = db::hooks::upsert_check(
1467
        &app.db,
1468
        row.id,
1469
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
1470
    )
1471
    .await
1472
    {
1473
        let api = crate::api::check_of(&c);
1474
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1475
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1476
    }
1477
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
1478
    let dropped =
1479
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
1480
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
1481
}
1482
1483
/// `POST /api/v1/flow/jobs/{id}/finish`.
1484
pub async fn finish(
1485
    State(app): State<Shared>,
1486
    headers: HeaderMap,
1487
    Path(id): Path<i64>,
1488
    Json(f): Json<Finish>,
1489
) -> Result<StatusCode, ApiError> {
1490
    let me = runner_auth(&app, &headers).await?;
1491
    let job = running_job(&app, id).await?;
1492
    let status = match (f.exit_code, &f.error) {
1493
        (Some(0), None) => "passed",
1494
        (Some(_), _) => "failed",
1495
        (None, _) => "error",
1496
    };
1497
    let (receipt, receipt_hash) = match &f.receipt {
1498
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
1499
            // A passing job's receipt names exactly the artifacts uploaded,
1500
            // in name order; a failing one's names none, though it may have
1501
            // kept `when "always"` artifacts (oxygenflow#37).
1502
            let kept: Vec<String> =
1503
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
1504
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
1505
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
1506
            }
1507
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
1508
        }
1509
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
1510
        None => (String::new(), String::new()),
1511
    };
1512
    let error = f.error.clone().unwrap_or_default();
1513
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
1514
    let changed = fdb::finish(
1515
        &app.db,
1516
        job.id,
1517
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
1518
    )
1519
    .await?;
1520
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1521
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1522
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
1523
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
1524
                    Ok((attest, signer)) => (attest, signer, String::new()),
1525
                    Err(e) => {
1526
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
1527
                        (String::new(), String::new(), e)
1528
                    }
1529
                };
1530
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
1531
            }
1532
        }
1533
    }
1534
    report_changed(&app, job.run_id, changed).await?;
1535
    Ok(StatusCode::NO_CONTENT)
1536
}
1537
1538
/// Report the checks of jobs whose status changed.
1539
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
1540
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
1541
        return Ok(());
1542
    };
1543
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
1544
        return Ok(());
1545
    };
1546
    for j in changed {
1547
        let summary = match j.status.as_str() {
1548
            "passed" => format!("passed in {}", duration(&j)),
1549
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
1550
            "skipped" => "skipped: a dependency did not pass".into(),
1551
            "cancelled" => j.error.clone(),
1552
            _ => format!("could not run: {}", j.error),
1553
        };
1554
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
1555
    }
1556
    // A passing run of an upstream's default bookmark: consumers following
1557
    // it when green are bumped (Diffusion, #148).
1558
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
1559
        if done.status == "passed" && done.bookmark == row.default_bookmark {
1560
            let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
1561
            let (app, row) = (app.clone(), row.clone());
1562
            tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await });
1563
        }
1564
    }
1565
    Ok(())
1566
}
1567
1568
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
1569
/// Settings, else from `hub.toml`.
1570
pub struct Limits {
1571
    /// For a job whose pipeline names none.
1572
    pub default_s: u64,
1573
    /// The most a pipeline may ask for.
1574
    pub max_s: u64,
1575
    /// Grace past a job's limit before the hub gives it up: a runner stops
1576
    /// a job at its limit and reports it within moments, so one still
1577
    /// running this long after is one the runner no longer reports (it was
1578
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
1579
    pub grace_ms: i64,
1580
}
1581
1582
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
1583
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
1584
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
1585
1586
pub async fn limits(app: &Shared) -> Limits {
1587
    let minutes = |key: &'static str| async move {
1588
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
1589
    };
1590
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
1591
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
1592
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
1593
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
1594
}
1595
1596
/// End every job still running past its time limit plus the grace:
1597
/// it is marked an error with the reason, the jobs that depended on it are
1598
/// skipped and its run settled, as when a runner reports an error.
1599
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
1600
    let limits = limits(app).await;
1601
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
1602
    let n = lost.len();
1603
    for job in lost {
1604
        let error = format!(
1605
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
1606
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
1607
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
1608
        );
1609
        let changed =
1610
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1611
                .await?;
1612
        tracing::warn!("flow: job {} {error}", job.id);
1613
        report_changed(app, job.run_id, changed).await?;
1614
    }
1615
    Ok(n)
1616
}
1617
1618
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
1619
/// it restarted since claiming them, or never received the claim. A
1620
/// runner claims one job at a time, so a job it holds is always listed.
1621
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
1622
    for job in fdb::running_on(&app.db, runner.id).await? {
1623
        if holding.contains(&job.id) {
1624
            continue;
1625
        }
1626
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
1627
        let changed =
1628
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1629
                .await?;
1630
        tracing::warn!("flow: job {} {error}", job.id);
1631
        report_changed(app, job.run_id, changed).await?;
1632
    }
1633
    Ok(())
1634
}
1635
1636
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
1637
/// repository that runs Flow, the jobs of its default bookmark's pipeline
1638
/// with a `schedule` line due then. Returns how many runs started.
1639
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
1640
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
1641
    let mut started = 0;
1642
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
1643
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
1644
        let b = row.default_bookmark.clone();
1645
        let found = app
1646
            .repos
1647
            .read(&repo, move |r, _| {
1648
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
1649
                Ok(Some((st, r.store().get_state(&st)?.heads)))
1650
            })
1651
            .await;
1652
        let Ok(Some((state, heads))) = found else { continue };
1653
        if heads.is_empty() {
1654
            continue;
1655
        }
1656
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
1657
            Ok(Some(_)) => started += 1,
1658
            Ok(None) => {}
1659
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
1660
        }
1661
    }
1662
    started
1663
}
1664
1665
/// The hub's clock as a [`When`](flow_core::pipeline::When).
1666
pub fn when_now() -> flow_core::pipeline::When {
1667
    let now = time::OffsetDateTime::now_utc();
1668
    flow_core::pipeline::When {
1669
        minute: now.minute() as u32,
1670
        hour: now.hour() as u32,
1671
        day: now.day() as u32,
1672
        month: u8::from(now.month()) as u32,
1673
        weekday: now.weekday().number_days_from_sunday() as u32,
1674
    }
1675
}
1676
1677
/// Start the scheduled runs due each minute, once a minute.
1678
pub async fn scheduler(app: Shared) {
1679
    let mut last = None;
1680
    loop {
1681
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1682
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
1683
        if last == Some(minute) {
1684
            continue;
1685
        }
1686
        last = Some(minute);
1687
        let n = run_due(&app, when_now()).await;
1688
        if n > 0 {
1689
            tracing::info!("flow: {n} scheduled run(s) started");
1690
        }
1691
    }
1692
}
1693
1694
/// Look for lost jobs once a minute.
1695
pub async fn reaper(app: Shared) {
1696
    loop {
1697
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1698
        if let Err(e) = reap_lost_jobs(&app).await {
1699
            tracing::warn!("flow: looking for lost jobs: {e}");
1700
        }
1701
    }
1702
}
1703
1704
/// Check a runner's signed receipt for a job of `run` and keep it in the
1705
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
1706
/// name, or why it was not kept. It must be the job's receipt, for the
1707
/// run's state and repository, signed by the key the runner's token first
1708
/// signed with.
1709
pub async fn keep_receipt(
1710
    app: &Shared,
1711
    runner: &db::runners::Runner,
1712
    row: &db::repos::RepoRow,
1713
    run: &fdb::Run,
1714
    receipt: &flow_core::Receipt,
1715
    signed_hex: &str,
1716
) -> Result<(String, String), String> {
1717
    use oxy_core::receipt::SignedReceipt;
1718
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
1719
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
1720
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
1721
    if sr.receipt.hash().0 != receipt.hash() {
1722
        return Err("it signs another receipt than the job's".into());
1723
    }
1724
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1725
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
1726
    if sr.state() != state {
1727
        return Err("it is for another state than the run's".into());
1728
    }
1729
    let key = sr.runner.hash().to_hex();
1730
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
1731
    if bound != key {
1732
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
1733
    }
1734
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
1735
    let signer = sr.runner.name.clone();
1736
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
1737
    Ok((attest.to_hex(), signer))
1738
}
1739
1740
/// A job's wall time, as the pages show it.
1741
pub fn duration(j: &fdb::Job) -> String {
1742
    match (j.started_ms, j.finished_ms) {
1743
        (Some(a), Some(b)) => human_ms(b - a),
1744
        (Some(a), None) => human_ms(db::now_ms() - a),
1745
        _ => "–".into(),
1746
    }
1747
}
1748
1749
pub fn human_ms(ms: i64) -> String {
1750
    let ms = ms.max(0);
1751
    if ms < 1000 {
1752
        format!("{ms} ms")
1753
    } else if ms < 60_000 {
1754
        format!("{:.1} s", ms as f64 / 1000.0)
1755
    } else {
1756
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
1757
    }
1758
}
1759
1760
/// Where a live run stands among the runners' work: what its viewers may
1761
/// know of it. Jobs of other repositories are named only when those are
1762
/// public; the rest are counted.
1763
#[derive(Clone, Debug, Default, PartialEq, Eq)]
1764
pub struct QueueInfo {
1765
    /// 1-based place of the run's first queued job among all queued jobs.
1766
    pub position: Option<usize>,
1767
    /// Jobs running now, and queued jobs ahead of the run's first one.
1768
    pub running: Vec<String>,
1769
    pub ahead: Vec<String>,
1770
    /// Of those, jobs in repositories the page may not name.
1771
    pub hidden: usize,
1772
}
1773
1774
impl QueueInfo {
1775
    /// "3rd in line" and the like.
1776
    pub fn place(&self) -> Option<String> {
1777
        self.position.map(|n| {
1778
            let suffix = match (n % 10, n % 100) {
1779
                (1, 11) | (2, 12) | (3, 13) => "th",
1780
                (1, _) => "st",
1781
                (2, _) => "nd",
1782
                (3, _) => "rd",
1783
                _ => "th",
1784
            };
1785
            format!("{n}{suffix} in line")
1786
        })
1787
    }
1788
}
1789
1790
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
1791
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
1792
    let mut info = QueueInfo::default();
1793
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
1794
    info.position = first.map(|i| i + 1);
1795
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
1796
    let mut queued_seen = 0;
1797
    for e in queue {
1798
        if e.run_id == run_id {
1799
            continue;
1800
        }
1801
        let ahead = match e.status.as_str() {
1802
            "running" => true,
1803
            _ => {
1804
                queued_seen += 1;
1805
                first.is_some_and(|f| queued_seen <= f)
1806
            }
1807
        };
1808
        if !ahead {
1809
            continue;
1810
        }
1811
        if e.repo_id != repo_id && e.visibility != "public" {
1812
            info.hidden += 1;
1813
        } else if e.status == "running" {
1814
            info.running.push(label(e));
1815
        } else {
1816
            info.ahead.push(label(e));
1817
        }
1818
    }
1819
    info
1820
}
1821
1822
#[cfg(test)]
1823
mod queue_tests {
1824
    use super::*;
1825
1826
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
1827
        fdb::QueueEntry {
1828
            job_id,
1829
            run_id,
1830
            repo_id,
1831
            owner: "o".into(),
1832
            name: format!("r{repo_id}"),
1833
            visibility: vis.into(),
1834
            run_number: run_id,
1835
            job: job.into(),
1836
            status: status.into(),
1837
            runner: String::new(),
1838
            spec: String::new(),
1839
            sandbox: String::new(),
1840
        }
1841
    }
1842
1843
    #[test]
1844
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
1845
        // Running first, then queued in claim order (as `fdb::queue` returns them).
1846
        let q = vec![
1847
            e(1, 10, 1, "public", "build", "running"),
1848
            e(2, 11, 2, "private", "test", "running"),
1849
            e(3, 10, 1, "public", "test", "queued"),
1850
            e(4, 12, 3, "private", "lint", "queued"),
1851
            e(5, 13, 4, "public", "build", "queued"),
1852
            e(6, 13, 4, "public", "test", "queued"),
1853
            e(7, 14, 5, "public", "late", "queued"),
1854
        ];
1855
        let info = queue_info(&q, 4, 13);
1856
        assert_eq!(info.position, Some(3));
1857
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
1858
        assert_eq!(info.running, ["o/r1 #10 build"]);
1859
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
1860
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
1861
        // A private repository's own jobs are named to its viewers.
1862
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
1863
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
1864
        // A run with nothing queued has no place.
1865
        assert_eq!(queue_info(&q, 1, 99).position, None);
1866
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
1867
        assert_eq!(
1868
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
1869
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
1870
        );
1871
    }
1872
}
1873
1874
#[cfg(test)]
1875
mod placement_tests {
1876
    use super::*;
1877
1878
    #[test]
1879
    fn a_waiting_job_says_what_each_runner_lacks() {
1880
        use crate::db::runners::Runner;
1881
        let rootfs = "ab".repeat(32);
1882
        let job = fdb::Job {
1883
            id: 7,
1884
            runtime: "syscall".into(),
1885
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
1886
            spec: serde_json::json!({ "name": "build" }).to_string(),
1887
            ..Default::default()
1888
        };
1889
        let now = db::now_ms();
1890
        let runner = |id: i64, name: &str| Runner {
1891
            id,
1892
            name: name.into(),
1893
            state: "active".into(),
1894
            last_seen_ms: Some(now),
1895
            arch: "x86_64".into(),
1896
            tiers: "microvm,syscall".into(),
1897
            images: serde_json::to_string(&[&rootfs]).unwrap(),
1898
            memory_mib: Some(16384),
1899
            cpus: Some(8),
1900
            ..Default::default()
1901
        };
1902
        let archs = std::collections::HashMap::new();
1903
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
1904
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
1905
        };
1906
        assert_eq!(why(&[], &[]), "no runner is online");
1907
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
1908
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
1909
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
1910
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
1911
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
1912
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
1913
        assert_eq!(
1914
            why(&[microvm_only, small, other, offline], &[]),
1915
            "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"
1916
        );
1917
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
1918
        assert_eq!(
1919
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
1920
            "no runner can take it: narvi is riscv64; the job needs x86_64"
1921
        );
1922
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
1923
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
1924
        let d = fdb::DeclineRow {
1925
            job_id: 7,
1926
            runner_id: 1,
1927
            runner: "earth-1".into(),
1928
            reason: "only 512 MiB of memory is free here".into(),
1929
            ms: now,
1930
        };
1931
        assert_eq!(
1932
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
1933
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
1934
        );
1935
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
1936
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
1937
    }
1938
}