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