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