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