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