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