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