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