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