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