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