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