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