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