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