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