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