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