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