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