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