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