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