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