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