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