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