OXYGENHUB
# 417184c73f4e oxygenhub / crates / hub / src / flow.rs
104.4 KiB · Rust · last changed Approval gates: a job waits for a person with the role 20 days ago source blame history raw
1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        // `own`: on, run only by the repository's or its owner's runners (#225).
27
        "on" | "own" => true,
28
        "off" => false,
29
        _ => app.config.flow.allows(&row.owner, &row.name),
30
    }
31
}
32
33
/// A bookmark moved by a push: start a run when the repository is allowed
34
/// and the new state has a pipeline.
35
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
36
    if heads.is_empty() || !enabled(app, row) {
37
        return;
38
    }
39
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
40
    let started = match bookmark.strip_prefix("tags/") {
41
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
42
        None => {
43
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
44
            let started = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await;
45
            if let Ok(Some(n)) = started {
46
                supersede(app, row, bookmark, n).await;
47
            }
48
            started
49
        }
50
    };
51
    if let Err(e) = started {
52
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
53
    }
54
}
55
56
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
57
/// moving it. Returns the new run's number.
58
/// `jobs`, when not empty, are the only jobs to start (oxygenhub#130), with
59
/// the jobs they depend on.
60
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
61
    if !enabled(app, row) {
62
        return Err("OxygenFlow is not enabled for this repository".into());
63
    }
64
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
65
    let b = bookmark.to_string();
66
    let found = app
67
        .repos
68
        .read(&repo, move |r, _| {
69
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
70
            Ok(Some((st, r.store().get_state(&st)?.heads)))
71
        })
72
        .await
73
        .map_err(|e| e.to_string())?;
74
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
75
    if heads.is_empty() {
76
        return Err(format!("{bookmark} has no changes yet"));
77
    }
78
    start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs)
79
        .await?
80
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
81
}
82
83
/// "Run pipeline" on one committed change: the state whose only head is
84
/// `change`, whatever bookmarks hold it. Returns the new run's number.
85
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
86
    if !enabled(app, row) {
87
        return Err("OxygenFlow is not enabled for this repository".into());
88
    }
89
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
90
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
91
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
92
    if !known {
93
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
94
    }
95
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
96
    start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs)
97
        .await?
98
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
99
}
100
101
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
102
/// run whose checks replace the old ones. Returns the new run's number.
103
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
104
    if !enabled(app, row) {
105
        return Err("OxygenFlow is not enabled for this repository".into());
106
    }
107
    if run.live() {
108
        return Err(format!("run #{} is still {}", run.number, run.status));
109
    }
110
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
111
    if heads.is_empty() {
112
        return Err("the run names no changes".into());
113
    }
114
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
115
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
116
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
117
        .await?
118
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
119
}
120
121
/// Run one job of a finished run again (oxygenflow#31), with the jobs
122
/// that depend on it; the jobs it depends on keep their results, so a
123
/// flaky test reruns without the build before it. Its checks go back to
124
/// queued. Returns how many jobs were queued again.
125
pub async fn rerun_job(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, job: &str, actor: &str) -> Result<usize, String> {
126
    if !enabled(app, row) {
127
        return Err("OxygenFlow is not enabled for this repository".into());
128
    }
129
    if run.live() {
130
        return Err(format!("run #{} is still {}: wait for it, or cancel it, first", run.number, run.status));
131
    }
132
    let changed = fdb::rerun_job(&app.db, run.id, job).await.map_err(|e| {
133
        tracing::warn!("flow: rerunning {job} of run #{} of {}/{}: {e}", run.number, row.owner, row.name);
134
        "the job could not be queued again".to_string()
135
    })?;
136
    if changed.is_empty() {
137
        return Err(format!("run #{} has no job {job:?}", run.number));
138
    }
139
    for j in &changed {
140
        report_check(app, row, &run.state, run.number, &j.name, "queued", &format!("queued again by {actor}")).await;
141
    }
142
    Ok(changed.len())
143
}
144
145
/// Cancel a run still going (oxygenflow#32), its checks with it.
146
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
147
    if !run.live() {
148
        return Err(format!("run #{} is already {}", run.number, run.status));
149
    }
150
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
151
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
152
        "the run could not be cancelled".to_string()
153
    })
154
}
155
156
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
157
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
158
    report_changed(app, run.id, changed).await
159
}
160
161
/// Why job `job` of `run` cannot be decided on by `who` (oxygenflow#57),
162
/// or its approval: it must be waiting, the jobs it depends on done and
163
/// passed, `who` must have the role it names on the repository, and with
164
/// others-only must not be the person whose push started the run.
165
pub async fn may_decide(
166
    app: &Shared,
167
    row: &db::repos::RepoRow,
168
    run: &fdb::Run,
169
    job: &fdb::Job,
170
    who: &db::accounts::Account,
171
) -> Result<flow_core::Approval, String> {
172
    let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?;
173
    let Some(approval) = spec.approval else { return Err(format!("{} does not wait for approval", job.name)) };
174
    if job.status != "queued" || job.approval != "waiting" {
175
        return Err(format!("{} is not waiting for approval", job.name));
176
    }
177
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
178
    if let Some(d) = job.depends_on().iter().find(|d| !jobs.iter().any(|j| &j.name == *d && j.status == "passed")) {
179
        return Err(format!("{} waits for {d} first", job.name));
180
    }
181
    let need = crate::auth::roles::Role::parse(&approval.by).unwrap_or(crate::auth::roles::Role::Maintain);
182
    let role = crate::auth::roles::role_for(&app.db, Some(who), row).await.map_err(|e| e.to_string())?;
183
    if role.is_none_or(|r| r < need) {
184
        return Err(format!("approving {} takes the {} role on {}/{}", job.name, approval.by, row.owner, row.name));
185
    }
186
    if approval.others_only && who.username == run.actor {
187
        return Err(format!("{} must be approved by someone other than {}, whose push started the run", job.name, run.actor));
188
    }
189
    Ok(approval)
190
}
191
192
/// Approve or reject job `name` of `run` as `who` (oxygenflow#57): the
193
/// decision goes on the job, its log and the audit log; a rejected job ends
194
/// as cancelled and the jobs after it are skipped. What happened, or why not.
195
pub async fn decide(
196
    app: &Shared,
197
    row: &db::repos::RepoRow,
198
    run: &fdb::Run,
199
    name: &str,
200
    who: &db::accounts::Account,
201
    approve: bool,
202
) -> Result<String, String> {
203
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
204
    let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) };
205
    may_decide(app, row, run, &job, who).await?;
206
    let target = format!("{}/{}#{} {name}", row.owner, row.name, run.number);
207
    let said = if approve {
208
        if !fdb::approve_job(&app.db, job.id, &who.username).await.map_err(|e| e.to_string())? {
209
            return Err(format!("{name} was decided on meanwhile"));
210
        }
211
        db::accounts::audit(&app.db, Some(who), "flow.approved", &target, "").await.ok();
212
        format!("approved by {}", who.username)
213
    } else {
214
        fdb::note_rejection(&app.db, job.id, &who.username).await.map_err(|e| e.to_string())?;
215
        let why = format!("rejected by {}", who.username);
216
        let changed =
217
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
218
                .await
219
                .map_err(|e| e.to_string())?;
220
        report_changed(app, run.id, changed).await.ok();
221
        db::accounts::audit(&app.db, Some(who), "flow.rejected", &target, "").await.ok();
222
        why
223
    };
224
    let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said.clone() };
225
    fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
226
    Ok(format!("{name}: {said}"))
227
}
228
229
/// End the approvals that waited too long (oxygenflow#57): from when the
230
/// jobs a waiting job depends on finished (or its run began).
231
pub async fn expire_approvals(app: &Shared, now: i64) -> sqlx::Result<usize> {
232
    let mut ended = 0;
233
    for job in fdb::waiting_for_approval(&app.db).await? {
234
        let Ok(spec) = serde_json::from_str::<flow_core::Job>(&job.spec) else { continue };
235
        let Some(approval) = spec.approval else { continue };
236
        let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? else { continue };
237
        let jobs = fdb::jobs(&app.db, run.id).await?;
238
        let wanted = job.depends_on();
239
        let deps: Vec<&fdb::Job> = jobs.iter().filter(|j| wanted.contains(&j.name)).collect();
240
        if deps.iter().any(|d| d.status != "passed") {
241
            continue;
242
        }
243
        let since = deps.iter().filter_map(|d| d.finished_ms).max().unwrap_or(run.created_ms);
244
        if now - since < approval.expires_s as i64 * 1000 {
245
            continue;
246
        }
247
        let hours = approval.expires_s as f64 / 3600.0;
248
        let why =
249
            format!("no approval within {}", if hours >= 1.0 { format!("{hours:.0}h") } else { format!("{}m", approval.expires_s / 60) });
250
        let changed =
251
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
252
                .await?;
253
        report_changed(app, run.id, changed).await?;
254
        ended += 1;
255
    }
256
    Ok(ended)
257
}
258
259
/// A push makes the push runs still going on its bookmark moot: they are
260
/// cancelled, and the new run's scope reaches back past them.
261
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, number: i64) {
262
    let Ok(older) = fdb::live_push_runs_before(&app.db, row.id, bookmark, number).await else { return };
263
    for run in older {
264
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
265
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
266
        }
267
    }
268
}
269
270
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
271
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
272
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
273
    use flow_core::pipeline::Event;
274
    let t = match trigger.strip_prefix("rerun:") {
275
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
276
        None => trigger,
277
    };
278
    if let Some(tag) = t.strip_prefix("tag:") {
279
        Event::Tag(tag)
280
    } else if t == "push" {
281
        Event::Push(bookmark)
282
    } else if t == "schedule" {
283
        Event::Schedule
284
    } else {
285
        Event::Manual
286
    }
287
}
288
289
/// The event to repeat when rerunning `run`.
290
fn original_event(run: &fdb::Run) -> String {
291
    let t = run.trigger.as_str();
292
    match t.strip_prefix("rerun:") {
293
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
294
        None => t.to_string(),
295
    }
296
}
297
298
/// The paths the changes a push brought touch: the changes in `heads`'
299
/// state that the bookmark's previous run did not have, and the files their
300
/// edits and tree operations name, as the new state places them. `None`
301
/// (run every job) for the bookmark's first run, or when it cannot be told.
302
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
303
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
304
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
305
    if old.is_empty() {
306
        return None;
307
    }
308
    let repo = RepoName::new(&row.owner, &row.name)?;
309
    let new = heads.to_vec();
310
    app.repos
311
        .read(&repo, move |r, cache| {
312
            let fresh = r.negotiate(&old, &new)?;
313
            let loaded = cache.load(r, &new)?;
314
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
315
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
316
            let mut paths = std::collections::BTreeSet::new();
317
            for h in fresh {
318
                let (c, _) = r.store().get_change_lazy(&h)?;
319
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
320
                for n in nodes {
321
                    if let Some(p) = by_node.get(&n) {
322
                        paths.insert((*p).clone());
323
                    }
324
                }
325
            }
326
            Ok(paths.into_iter().collect::<Vec<String>>())
327
        })
328
        .await
329
        .ok()
330
}
331
332
/// Whether a job runs for a push that touched `touched`: always without a
333
/// scope or without knowing what was touched, else when a path matches.
334
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
335
    let Some(touched) = touched else { return true };
336
    if scope.is_empty() {
337
        return true;
338
    }
339
    let mut b = globset::GlobSetBuilder::new();
340
    for g in scope {
341
        match globset::Glob::new(g) {
342
            Ok(g) => {
343
                b.add(g);
344
            }
345
            Err(_) => return true,
346
        }
347
    }
348
    let Ok(set) = b.build() else { return true };
349
    touched.iter().any(|p| set.is_match(p))
350
}
351
352
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
353
/// left out of the run, unless a job that runs depends on them.
354
#[allow(clippy::too_many_arguments)]
355
pub async fn start_run_scoped(
356
    app: &Shared,
357
    row: &db::repos::RepoRow,
358
    bookmark: &str,
359
    state: &str,
360
    heads: &[ChangeHash],
361
    actor: &str,
362
    trigger: &str,
363
    touched: Option<&[String]>,
364
) -> Result<Option<i64>, String> {
365
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
366
}
367
368
/// Start a run of the pipeline in the state `heads` make (whose state
369
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
370
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
371
/// whose access the run's subrepo reads are checked against. Returns the
372
/// run's number, or `None` when the state has no pipeline. The caller
373
/// checks that Flow is enabled.
374
pub async fn start_run(
375
    app: &Shared,
376
    row: &db::repos::RepoRow,
377
    bookmark: &str,
378
    state: &str,
379
    heads: &[ChangeHash],
380
    actor: &str,
381
    trigger: &str,
382
) -> Result<Option<i64>, String> {
383
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
384
}
385
386
#[allow(clippy::too_many_arguments)]
387
async fn start_run_with(
388
    app: &Shared,
389
    row: &db::repos::RepoRow,
390
    bookmark: &str,
391
    state: &str,
392
    heads: &[ChangeHash],
393
    actor: &str,
394
    trigger: &str,
395
    touched: Option<&[String]>,
396
) -> Result<Option<i64>, String> {
397
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await
398
}
399
400
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock;
401
/// `only`, when not empty, the jobs a manual run starts (oxygenhub#130).
402
#[allow(clippy::too_many_arguments)]
403
async fn start_run_at(
404
    app: &Shared,
405
    row: &db::repos::RepoRow,
406
    bookmark: &str,
407
    state: &str,
408
    heads: &[ChangeHash],
409
    actor: &str,
410
    trigger: &str,
411
    touched: Option<&[String]>,
412
    at: Option<flow_core::pipeline::When>,
413
    only: &[String],
414
) -> Result<Option<i64>, String> {
415
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
416
    let h = heads.to_vec();
417
    let text = app
418
        .repos
419
        .read(&repo, move |r, cache| {
420
            let loaded = cache.load(r, &h)?;
421
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
422
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
423
                _ => None,
424
            })
425
        })
426
        .await
427
        .map_err(|e| format!("reading the pipeline: {e}"))?;
428
    let Some(text) = text else { return Ok(None) };
429
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
430
    hex.sort();
431
    let heads_s = hex.join("+");
432
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
433
    let id = match Pipeline::parse(&text) {
434
        Err(problems) => {
435
            let msg = problems.to_string();
436
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
437
        }
438
        Ok(p) => {
439
            let depths = p.depths();
440
            // Jobs out of the push's scope stay out, unless a kept job
441
            // needs them.
442
            // The jobs this run's event starts (`on`), out of those its
443
            // push touched (`scope`); then what they depend on.
444
            let event = match at {
445
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
446
                None => event_of(trigger, bookmark),
447
            };
448
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
449
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
450
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
451
            } else {
452
                // Named jobs (oxygenhub#130): each one this event starts.
453
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
454
                for name in only {
455
                    if !startable.contains(&name.as_str()) {
456
                        let what = if p.jobs.contains_key(name) {
457
                            "is not started by hand (no `on { manual }`)"
458
                        } else {
459
                            "is not in the pipeline"
460
                        };
461
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
462
                    }
463
                }
464
                only.iter().cloned().collect()
465
            };
466
            loop {
467
                let needed: Vec<String> = p
468
                    .order()
469
                    .into_iter()
470
                    .filter(|j| keep.contains(&j.name))
471
                    .flat_map(|j| j.depends_on.iter().cloned())
472
                    .filter(|d| !keep.contains(d))
473
                    .collect();
474
                if needed.is_empty() {
475
                    break;
476
                }
477
                keep.extend(needed);
478
            }
479
            let mut jobs: Vec<fdb::NewJob> = p
480
                .order()
481
                .into_iter()
482
                .filter(|j| keep.contains(&j.name))
483
                .map(|j| {
484
                    let sb = &p.sandboxes[&j.sandbox];
485
                    fdb::NewJob {
486
                        name: j.name.clone(),
487
                        depth: depths[j.name.as_str()] as i64,
488
                        depends: j.depends_on.clone(),
489
                        spec: serde_json::to_string(j).unwrap_or_default(),
490
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
491
                        runtime: sb.runtime.as_str().into(),
492
                        need_disk_mib: None,
493
                    }
494
                })
495
                .collect();
496
            // What each job needs, learned from its past runs (oxygenflow#41).
497
            for j in &mut jobs {
498
                j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?;
499
            }
500
            if jobs.is_empty() {
501
                // Nothing this event starts (or this push touched).
502
                return Ok(None);
503
            }
504
            let hash = hex::encode(p.hash());
505
            let id =
506
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
507
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
508
                for j in &jobs {
509
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
510
                }
511
                images_check(app, row, state, run.number, &jobs).await;
512
            }
513
            id
514
        }
515
    };
516
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
517
    Ok(Some(run.number))
518
}
519
520
/// The `pipeline/images` check (#137): set failing when an image the run's
521
/// jobs pin is held by neither the hub nor a runner, so a proposal pinning
522
/// one says so before it lands rather than its runs queueing for good;
523
/// passed again by a later run of the state once every image is held.
524
async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) {
525
    let mut pinned: Vec<String> = jobs
526
        .iter()
527
        .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok())
528
        .filter_map(|v| v["rootfs"].as_str().map(String::from))
529
        .filter(|r| !r.is_empty())
530
        .collect();
531
    pinned.sort();
532
    pinned.dedup();
533
    if pinned.is_empty() {
534
        return;
535
    }
536
    let mut held = crate::images::held(&app.db).await.unwrap_or_default();
537
    // Nobody can be said to lack an image unless some runner is known and
538
    // every one reports what it holds.
539
    let runners: Vec<db::runners::Runner> =
540
        db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect();
541
    if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) {
542
        return;
543
    }
544
    for r in &runners {
545
        held.extend(r.image_list().unwrap_or_default());
546
    }
547
    let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect();
548
    // Only a missing image makes the check; one that was failing on this
549
    // state turns to passed once its images are held.
550
    let failing_before = db::hooks::checks_for_state(&app.db, row.id, state)
551
        .await
552
        .unwrap_or_default()
553
        .iter()
554
        .any(|c| c.name == "pipeline/images" && c.status != "passed");
555
    if missing.is_empty() && !failing_before {
556
        return;
557
    }
558
    let (status, summary) = if missing.is_empty() {
559
        (
560
            "passed",
561
            format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }),
562
        )
563
    } else {
564
        let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect();
565
        ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", ")))
566
    };
567
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
568
    if let Ok(c) = db::hooks::upsert_check(
569
        &app.db,
570
        row.id,
571
        db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" },
572
    )
573
    .await
574
    {
575
        let api = crate::api::check_of(&c);
576
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
577
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
578
    }
579
}
580
581
/// Record a job's check; it links to the run's page by the run's number.
582
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
583
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
584
    let name = format!("flow/{job}");
585
    let c = db::hooks::upsert_check(
586
        &app.db,
587
        row.id,
588
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
589
    )
590
    .await;
591
    if let Ok(c) = c {
592
        let api = crate::api::check_of(&c);
593
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
594
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
595
    }
596
}
597
598
/// The check status a job's status shows as.
599
fn check_status(job_status: &str) -> &'static str {
600
    match job_status {
601
        "queued" => "queued",
602
        "running" => "running",
603
        "passed" => "passed",
604
        "skipped" => "skipped",
605
        "cancelled" => "cancelled",
606
        _ => "failed",
607
    }
608
}
609
610
/// The runner a bearer token belongs to: a registered runner, or one
611
/// configured with a hub.toml token (tracked from its first request). A
612
/// disabled runner is refused.
613
pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
614
    let Some(t) = oxy_server::bearer_token(headers) else {
615
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
616
    };
617
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
618
    let runner = match db::runners::by_token(&app.db, &hash).await? {
619
        Some(r) => r,
620
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
621
            db::runners::configured(&app.db, &hash, "runner").await?
622
        }
623
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
624
    };
625
    if runner.state == "disabled" {
626
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
627
    }
628
    Ok(runner)
629
}
630
631
/// `POST /api/v1/flow/runners/register`: a one-time registration token
632
/// from Admin → Runners buys a runner credential, binding the runner's
633
/// signing identity (hub spec §13.2).
634
pub async fn register(
635
    State(app): State<Shared>,
636
    Json(req): Json<flow_core::api::Register>,
637
) -> Result<Json<flow_core::api::Registered>, ApiError> {
638
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
639
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
640
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
641
    if name.is_empty() {
642
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
643
    }
644
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
645
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
646
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
647
    let runner =
648
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
649
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
650
        })?;
651
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
652
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
653
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
654
}
655
656
/// `POST /api/v1/flow/claim`.
657
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
658
    let me = runner_auth(&app, &headers).await?;
659
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
660
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
661
    if let Some(holding) = &req.holding {
662
        end_jobs_not_held(&app, &me, holding).await?;
663
    }
664
    // A draining runner finishes what it has and takes nothing new.
665
    if me.state == "draining" {
666
        return Ok(StatusCode::NO_CONTENT.into_response());
667
    }
668
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
669
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
670
    // What it holds and what it is (OxygenFlow spec §11): the images'
671
    // architectures it reports are remembered for every runner's claims.
672
    if let Some(archs) = &req.image_archs {
673
        fdb::record_image_archs(&app.db, archs).await?;
674
    }
675
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
676
    // What it offers, kept for the placement pass; a job it declined is
677
    // not offered to it again while this stays the same (spec §11.5).
678
    let profile = profile_of(&req, &arch);
679
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
680
    if let Some(f) = &req.facts {
681
        db::runners::set_facts(&app.db, me.id, f).await?;
682
    }
683
    app.swarm.claimed(me.id, req.swarm.as_ref());
684
    // The build caches it holds (oxygenflow#51), and when each last kept a
685
    // disk (oxygenflow#54; 0 from an older runner), for placing jobs by.
686
    if let Some(c) = &req.caches {
687
        let kept = |slot: &str| req.cache_times.as_ref().and_then(|t| t.get(slot)).map_or(0, |ms| *ms as i64);
688
        CACHES
689
            .lock()
690
            .unwrap_or_else(|p| p.into_inner())
691
            .get_or_insert_with(Default::default)
692
            .insert((app.repos.root().display().to_string(), me.id), c.iter().map(|s| (s.clone(), kept(s))).collect());
693
    }
694
    let room = fdb::Room {
695
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
696
        memory_mib: req.free_memory_mib,
697
        cpus: req.free_cpus,
698
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
699
        arch,
700
        image_archs: fdb::image_archs(&app.db).await?,
701
        fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() },
702
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
703
    };
704
    // The labels it reports now, or those it registered with, and those
705
    // an administrator gave it (#98).
706
    let mut labels: Vec<String> = match &req.info {
707
        Some(i) => i.labels.clone(),
708
        None => me.reported_label_list().into_iter().map(String::from).collect(),
709
    };
710
    for l in me.admin_label_list() {
711
        if !labels.iter().any(|x| x == l) {
712
            labels.push(l.to_string());
713
        }
714
    }
715
    // Ranking (oxygenflow#42): a job another free runner would do better
716
    // (it holds the image, or is faster) is held for it briefly.
717
    let runners = db::runners::list(&app.db).await?;
718
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
719
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
720
    let queue = fdb::queue(&app.db).await?;
721
    let owners: std::collections::HashMap<i64, (String, i64)> = queue.iter().map(|q| (q.job_id, (q.owner.clone(), q.repo_id))).collect();
722
    // Each job's build cache slot (oxygenflow#51), as runners report theirs.
723
    let slots: std::collections::HashMap<i64, String> =
724
        queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect();
725
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
726
    let now = db::now_ms();
727
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
728
    let hub_key = app.repos.root().display().to_string();
729
    let better = |j: &fdb::Job| -> bool {
730
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
731
        let rootfs = rootfs_of(j);
732
        let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner());
733
        let holds = holds.get_or_insert_with(Default::default);
734
        holds.retain(|_, t| now - *t < 600_000);
735
        if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
736
            return false;
737
        }
738
        let slot = slots.get(&j.id).map(String::as_str).unwrap_or("");
739
        let mine = fit(&hub_key, &me_now, &rootfs, slot);
740
        let other = runners.iter().any(|r| {
741
            r.id != me.id
742
                && r.state == "active"
743
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
744
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
745
                && fit(&hub_key, r, &rootfs, slot).beats(&mine)
746
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
747
        });
748
        if other {
749
            holds.entry((hub_key.clone(), j.id)).or_insert(now);
750
        }
751
        other
752
    };
753
    let Some(job) =
754
        fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await?
755
    else {
756
        return Ok(StatusCode::NO_CONTENT.into_response());
757
    };
758
    // Why here (oxygenflow#42).
759
    let waited =
760
        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);
761
    let rootfs = rootfs_of(&job);
762
    let mut why = vec![if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
763
        "holds the image".to_string()
764
    } else {
765
        "fetches the image from the hub".to_string()
766
    }];
767
    if let Some(kept) = slots.get(&job.id).and_then(|slot| cache_kept(&hub_key, me.id, slot)) {
768
        why.insert(
769
            0,
770
            if kept > 0 { format!("holds its build cache (kept {} ago)", ago(now - kept)) } else { "holds its build cache".into() },
771
        );
772
    }
773
    match running.get(&me.id).copied().unwrap_or(0) {
774
        0 => why.push("idle".into()),
775
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
776
    }
777
    if let Some(sp) = me_now.speed {
778
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
779
    }
780
    if let Some(w) = waited {
781
        why.push(format!("after {} s held for a better runner", w / 1000));
782
    }
783
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
784
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
785
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
786
    let row =
787
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
788
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
789
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
790
    // The job's limit: its own timeout, else its sandbox's, else the site
791
    // default, never past the site maximum; recorded for the reaper.
792
    let limits = limits(&app).await;
793
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
794
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
795
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
796
    // ...and never past the runner's own limit, when it has one (#98).
797
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
798
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
799
    }
800
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
801
    let claim = Claim {
802
        job_id: job.id,
803
        run_id: run.id,
804
        repo: format!("{}/{}", row.owner, row.name),
805
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
806
        token: secret,
807
        heads: run.head_list().into_iter().map(String::from).collect(),
808
        pipeline_hash: run.pipeline_hash.clone(),
809
        job: spec,
810
        sandbox,
811
        timeout_s,
812
        subrepos: vec![],
813
        state: run.state.clone(),
814
        bookmark: run.bookmark.clone(),
815
        run_number: run.number,
816
        // The site's, the owner's and the repository's variables (#102).
817
        env: crate::variables::env_for_repo(&app.db, &row).await?,
818
        secrets: vec![],
819
        inputs: vec![],
820
    };
821
    let mut claim = claim;
822
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
823
    // The secrets the job declares, as files (#107); one not set is said
824
    // at the top of the log and left out, so the job fails on the file.
825
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
826
    let found = crate::secrets::for_job(&app, &row, &names).await?;
827
    // The identities it asks for (#103): a token each, as a file.
828
    if !claim.job.identities.is_empty() {
829
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
830
        for i in &claim.job.identities {
831
            let token = issuer.mint(&crate::oidc::JobClaims {
832
                issuer: app.config.base_url(),
833
                audience: &i.audience,
834
                owner: &row.owner,
835
                name: &row.name,
836
                visibility: &row.visibility,
837
                bookmark: &run.bookmark,
838
                run_number: run.number,
839
                job: &job.name,
840
                state: &run.state,
841
                runner: &runner,
842
                actor: &run.actor,
843
                now_ms: db::now_ms(),
844
                ttl_s: timeout_s,
845
            });
846
            claim.secrets.push(flow_core::api::SecretInput {
847
                name: format!("identity:{}", i.audience),
848
                path: i.mount_path.clone(),
849
                value: token,
850
            });
851
        }
852
    }
853
    for s in &claim.job.secrets {
854
        match found.iter().find(|(n, _)| *n == s.name) {
855
            Some((name, value)) => {
856
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
857
            }
858
            None => left_out.push(format!(
859
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
860
                s.name, s.mount_path
861
            )),
862
        }
863
    }
864
    if !ids.is_empty() {
865
        fdb::set_also_read(&app.db, job.id, &ids).await?;
866
    }
867
    // Say at the top of the job's log what it will not have (oxygenhub#56),
868
    // rather than let it fail later on missing files.
869
    if !left_out.is_empty() {
870
        let records: Vec<flow_core::api::Record> =
871
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
872
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
873
    }
874
    claim.subrepos = inputs;
875
    // Artifacts of jobs it depends on (oxygenflow#58), with the blob each
876
    // producer's receipt names; the runner checks what it fetches against it.
877
    match artifact_inputs(&app, &job, &claim.job).await {
878
        Ok(found) => claim.inputs = found,
879
        Err(why) => {
880
            let error = format!("inputs: {why}");
881
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
882
                .await?;
883
            return Err(ApiError(StatusCode::CONFLICT, error));
884
        }
885
    }
886
    Ok(Json(claim).into_response())
887
}
888
889
/// The artifacts a job takes (oxygenflow#58), each from a job of the same
890
/// run that passed with a receipt; a passing job's receipt names exactly the
891
/// artifacts it uploaded (checked at finish), so their blobs are attested.
892
async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> {
893
    if spec.inputs.is_empty() {
894
        return Ok(vec![]);
895
    }
896
    let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?;
897
    let mut out = Vec::new();
898
    for i in &spec.inputs {
899
        let Some(producer) = jobs.iter().find(|j| j.name == i.from) else {
900
            return Err(format!("{:?} is not a job of this run", i.from));
901
        };
902
        if producer.status != "passed" || producer.receipt_hash.is_empty() {
903
            return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact));
904
        }
905
        let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?;
906
        let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else {
907
            return Err(format!("{} kept no artifact {}", i.from, i.artifact));
908
        };
909
        out.push(flow_core::api::ArtifactInput {
910
            artifact: i.artifact.clone(),
911
            from: i.from.clone(),
912
            path: i.path.clone(),
913
            blob: a.blob.clone(),
914
        });
915
    }
916
    Ok(out)
917
}
918
919
/// `GET /api/v1/flow/jobs/{id}/inputs/{index}`: the bytes of input `index`
920
/// of a running job (oxygenflow#58), for the runner holding it.
921
pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> {
922
    let runner = runner_auth(&app, &headers).await?;
923
    let job = running_job(&app, id).await?;
924
    // A registered runner reads only the inputs of a job it holds (a
925
    // configured token's runner names itself when it claims).
926
    if runner.source == "registered" && job.runner != runner.name {
927
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
928
    }
929
    let spec: flow_core::Job =
930
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
931
    let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?;
932
    let Some(i) = found.get(index) else {
933
        return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name)));
934
    };
935
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
936
    let row =
937
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
938
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
939
    let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob)
940
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?;
941
    let bytes = app
942
        .repos
943
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
944
        .await
945
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?;
946
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
947
}
948
949
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
950
/// labels and capacity, each list sorted.
951
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
952
    let sorted = |v: &[String]| {
953
        let mut v = v.to_vec();
954
        v.sort();
955
        v
956
    };
957
    let info = req.info.as_ref();
958
    let v = serde_json::json!({
959
        "arch": arch,
960
        "tiers": sorted(&req.runtimes),
961
        "images": req.images.as_deref().map(sorted),
962
        "labels": info.map(|i| sorted(&i.labels)),
963
        "memory_mib": info.and_then(|i| i.memory_mib),
964
        "cpus": info.and_then(|i| i.cpus),
965
    });
966
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
967
}
968
969
/// A job handed back by this many different runners is unplaceable.
970
pub const UNPLACEABLE_AFTER: usize = 3;
971
972
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
973
/// runner will not run a job it was handed. The job is queued again and
974
/// not offered to that runner while its profile stays the same (and for
975
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
976
/// different runners have declined it, it ends as unplaceable with their
977
/// reasons. A decline is never a failed check.
978
pub async fn decline(
979
    State(app): State<Shared>,
980
    headers: HeaderMap,
981
    Path(id): Path<i64>,
982
    Json(d): Json<flow_core::api::Decline>,
983
) -> Result<StatusCode, ApiError> {
984
    let me = runner_auth(&app, &headers).await?;
985
    db::runners::seen(&app.db, me.id, None, None).await?;
986
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
987
    let reason = match reason.trim() {
988
        "" => "no reason given".to_string(),
989
        r => r.to_string(),
990
    };
991
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
992
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
993
    };
994
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
995
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
996
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
997
    if runners.len() >= UNPLACEABLE_AFTER {
998
        let error = format!(
999
            "unplaceable: declined by {} runners: {}",
1000
            runners.len(),
1001
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
1002
        );
1003
        let changed =
1004
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1005
                .await?;
1006
        report_changed(&app, job.run_id, changed).await?;
1007
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1008
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1009
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
1010
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
1011
            fdb::set_waiting(&app.db, id, &summary).await?;
1012
        }
1013
    }
1014
    Ok(StatusCode::NO_CONTENT)
1015
}
1016
1017
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1018
/// now (spec §11.4), from what each runner offered at its last claim:
1019
/// empty when one could (it waits its turn or a free slot), else what
1020
/// each one lacks. `declines` are the job's.
1021
/// What placement knows of the fleet: images' architectures, the images
1022
/// the hub holds, and the time.
1023
pub struct Fleet<'a> {
1024
    pub image_archs: &'a std::collections::HashMap<String, String>,
1025
    pub hub_images: &'a std::collections::HashSet<String>,
1026
    pub now: i64,
1027
}
1028
1029
/// Why runner `r` cannot take `job` of a repository of `owner` (id
1030
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
1031
/// when it can. `declines` are the job's.
1032
pub fn runner_lacks(
1033
    job: &fdb::Job,
1034
    owner: &str,
1035
    repo_id: i64,
1036
    r: &db::runners::Runner,
1037
    fleet: &Fleet,
1038
    declines: &[fdb::DeclineRow],
1039
) -> Option<String> {
1040
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
1041
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
1042
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
1043
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
1044
    if r.state == "draining" {
1045
        return Some("is draining".into());
1046
    }
1047
    let scope = r.scope();
1048
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
1049
        return Some("serves other repositories".into());
1050
    }
1051
    let tiers = r.tier_list();
1052
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
1053
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
1054
    }
1055
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
1056
        return Some(format!("is {}; the job needs {w}", r.arch));
1057
    }
1058
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
1059
    if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
1060
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
1061
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
1062
    }
1063
    let labels = r.label_list();
1064
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
1065
        return Some(format!("lacks label {l}"));
1066
    }
1067
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
1068
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
1069
    }
1070
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
1071
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
1072
    }
1073
    // The disk it learned it needs (oxygenflow#41).
1074
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
1075
        if free < n {
1076
            return Some(format!(
1077
                "has {} free; the job needs ~{} (its recent runs took up to {})",
1078
                mib(free),
1079
                mib(n),
1080
                mib((n - 64) * 4 / 5)
1081
            ));
1082
        }
1083
    }
1084
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
1085
        return Some(format!("declined it: {}", d.reason));
1086
    }
1087
    None
1088
}
1089
1090
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1091
/// now (spec §11.4), from what each runner offered at its last claim:
1092
/// empty when one could (it waits its turn or a free slot), else what
1093
/// each one lacks. `declines` are the job's.
1094
#[allow(clippy::too_many_arguments)]
1095
pub fn why_waiting(
1096
    job: &fdb::Job,
1097
    owner: &str,
1098
    repo_id: i64,
1099
    runners: &[db::runners::Runner],
1100
    image_archs: &std::collections::HashMap<String, String>,
1101
    declines: &[fdb::DeclineRow],
1102
    now: i64,
1103
    hub_images: &std::collections::HashSet<String>,
1104
) -> String {
1105
    let fleet = Fleet { image_archs, hub_images, now };
1106
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
1107
    if online.is_empty() {
1108
        return "no runner is online".into();
1109
    }
1110
    let mut why = Vec::new();
1111
    for r in &online {
1112
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
1113
            None => return String::new(),
1114
            Some(w) => why.push(format!("{} {w}", r.name)),
1115
        }
1116
    }
1117
    let more = why.len().saturating_sub(4);
1118
    why.truncate(4);
1119
    let mut text = format!("no runner can take it: {}", why.join("; "));
1120
    if more > 0 {
1121
        text.push_str(&format!("; and {more} more"));
1122
    }
1123
    text
1124
}
1125
1126
/// How long a job is held for a better runner before any runner that fits
1127
/// takes it (oxygenflow#42).
1128
const BETTER_HOLD_MS: i64 = 15_000;
1129
/// A runner counts as there to take a job when it asked this recently.
1130
const POLLING_MS: i64 = 10_000;
1131
1132
/// When each job was first passed over for a better runner.
1133
/// Keyed by hub (tests run several in one process) and job.
1134
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
1135
1136
/// The build cache slots each runner said at its last claim it holds
1137
/// (oxygenflow#51). Keyed by hub (tests run several in one process) and
1138
/// runner; a restart forgets them until each runner's next claim.
1139
/// Each slot maps to when it last kept a disk (Unix ms, 0 unknown).
1140
type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashMap<String, i64>>;
1141
static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None);
1142
1143
/// When runner `runner` last kept a disk in build cache slot `slot` (0 when
1144
/// it did not say); `None` when it holds none there.
1145
fn cache_kept(hub: &str, runner: i64, slot: &str) -> Option<i64> {
1146
    CACHES
1147
        .lock()
1148
        .unwrap_or_else(|p| p.into_inner())
1149
        .as_ref()
1150
        .and_then(|c| c.get(&(hub.to_string(), runner)))
1151
        .and_then(|held| held.get(slot).copied())
1152
}
1153
1154
/// A copy of a build cache kept at least this much later is the fresher
1155
/// (oxygenflow#54): closer than that, the two are as good as each other.
1156
const FRESHER_MS: i64 = 60_000;
1157
1158
/// How well a runner fits a job (oxygenflow#42, #51, #54).
1159
struct Fit {
1160
    /// When it last kept the job's build cache, if it holds it.
1161
    cache: Option<i64>,
1162
    image: bool,
1163
    speed: i64,
1164
}
1165
1166
impl Fit {
1167
    /// Whether this fit is better than `other`: holding the job's build
1168
    /// cache counts most (a warm build beats any image fetch), and of two
1169
    /// that hold it the fresher copy; then holding the image, then speed.
1170
    fn beats(&self, other: &Fit) -> bool {
1171
        match (self.cache, other.cache) {
1172
            (Some(_), None) => return true,
1173
            (None, Some(_)) => return false,
1174
            (Some(a), Some(b)) if a > 0 && b > 0 && (a - b).abs() >= FRESHER_MS => return a > b,
1175
            _ => {}
1176
        }
1177
        (self.image, self.speed) > (other.image, other.speed)
1178
    }
1179
}
1180
1181
/// How runner `r` fits a job on image `rootfs` whose build cache slot is
1182
/// `slot`.
1183
fn fit(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> Fit {
1184
    Fit {
1185
        cache: cache_kept(hub, r.id, slot),
1186
        image: r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)),
1187
        speed: r.speed_class.unwrap_or(0),
1188
    }
1189
}
1190
1191
/// A span as people say it: 40 s, 12 min, 3 h, 2 days.
1192
fn ago(ms: i64) -> String {
1193
    let s = ms.max(0) / 1000;
1194
    match s {
1195
        0..=59 => format!("{s} s"),
1196
        60..=3599 => format!("{} min", s / 60),
1197
        3600..=172_799 => format!("{} h", s / 3600),
1198
        _ => format!("{} days", s / 86_400),
1199
    }
1200
}
1201
1202
fn rootfs_of(job: &fdb::Job) -> String {
1203
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
1204
}
1205
1206
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
1207
fn mib(m: i64) -> String {
1208
    if m >= 1024 {
1209
        format!("{:.1} GiB", m as f64 / 1024.0)
1210
    } else {
1211
        format!("{m} MiB")
1212
    }
1213
}
1214
1215
/// One placement pass: say why each ready queued job waits.
1216
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
1217
    let ready = fdb::ready_queued(&app.db).await?;
1218
    if ready.is_empty() {
1219
        return Ok(());
1220
    }
1221
    let runners = db::runners::list(&app.db).await?;
1222
    let archs = fdb::image_archs(&app.db).await?;
1223
    let held = crate::images::held(&app.db).await?;
1224
    let now = db::now_ms();
1225
    for (job, owner, repo_id) in ready {
1226
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
1227
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1228
        if why == job.waiting {
1229
            continue;
1230
        }
1231
        fdb::set_waiting(&app.db, job.id, &why).await?;
1232
        // The job's check says it too, so a proposal shows why it is stuck
1233
        // (oxygenhub#28).
1234
        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?) {
1235
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
1236
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
1237
        }
1238
    }
1239
    Ok(())
1240
}
1241
1242
/// Run a placement pass every 15 seconds.
1243
pub async fn placement(app: Shared) {
1244
    loop {
1245
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1246
        if let Err(e) = explain_waits(&app).await {
1247
            tracing::warn!("flow: placement pass: {e}");
1248
        }
1249
    }
1250
}
1251
1252
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
1253
pub(crate) async fn pins_in(
1254
    app: &Shared,
1255
    name: crate::repos::RepoName,
1256
    heads: Vec<oxy_core::hash::ChangeHash>,
1257
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
1258
    app.repos
1259
        .read(&name, move |repo, cache| {
1260
            let loaded = cache.load(repo, &heads)?;
1261
            let mut out = Vec::new();
1262
            for (path, f) in &loaded.snapshot.files {
1263
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
1264
                    continue;
1265
                }
1266
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
1267
                    if let Some(b) = blobs.first() {
1268
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
1269
                            .read_blob(b)
1270
                            .map_err(crate::repos::RepoError::Oxy)
1271
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
1272
                        {
1273
                            out.push((path.clone(), id, heads));
1274
                        }
1275
                    }
1276
                }
1277
            }
1278
            Ok(out)
1279
        })
1280
        .await
1281
        .unwrap_or_default()
1282
}
1283
1284
/// How deep subrepos of subrepos are followed.
1285
const MAX_SUBREPO_DEPTH: usize = 3;
1286
1287
/// The Oxygen subrepos the run's state pins, and those their states pin in
1288
/// turn (parents first, paths joined), that live on this hub and that the
1289
/// run's pusher may read: what the job's workspace needs beside its own
1290
/// tree. A pin the hub cannot serve is left out, with what it pins, and
1291
/// the third list says why, for the job's log.
1292
async fn subrepo_inputs(
1293
    app: &Shared,
1294
    row: &db::repos::RepoRow,
1295
    run: &fdb::Run,
1296
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
1297
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
1298
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1299
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1300
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
1301
    let mut inputs = Vec::new();
1302
    let mut ids = Vec::new();
1303
    let mut left_out = Vec::new();
1304
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
1305
    // (path prefix, repository, heads, depth), parents before children.
1306
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
1307
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
1308
        if depth == MAX_SUBREPO_DEPTH {
1309
            continue;
1310
        }
1311
        for (path, id, pinned) in pins_in(app, name, heads).await {
1312
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
1313
            let mut hosted = false;
1314
            for other in &rows {
1315
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
1316
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
1317
                if !matches {
1318
                    continue;
1319
                }
1320
                hosted = true;
1321
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
1322
                // A Diffusion bump's run reads the upstream it bumps: the one
1323
                // the consumer pins (someone who may write it committed the
1324
                // pin), and no other (#150).
1325
                let diffused = run.actor == crate::diffusion::ACCOUNT
1326
                    && run.bookmark.starts_with("diffusion/")
1327
                    && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ? AND upstream_repo_id = ?")
1328
                        .bind(run.repo_id)
1329
                        .bind(other.id)
1330
                        .fetch_one(&app.db)
1331
                        .await
1332
                        .unwrap_or(0)
1333
                        > 0;
1334
                if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) {
1335
                    inputs.push(flow_core::api::SubrepoInput {
1336
                        path: path.clone(),
1337
                        repo: format!("{}/{}", other.owner, other.name),
1338
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
1339
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
1340
                    });
1341
                    if !ids.contains(&other.id) {
1342
                        ids.push(other.id);
1343
                    }
1344
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
1345
                } else {
1346
                    left_out.push(format!(
1347
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
1348
                        other.owner, other.name
1349
                    ));
1350
                }
1351
                break;
1352
            }
1353
            if !hosted {
1354
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
1355
            }
1356
        }
1357
    }
1358
    (inputs, ids, left_out)
1359
}
1360
1361
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
1362
    match fdb::job(&app.db, id).await? {
1363
        Some(j) if j.status == "running" => Ok(j),
1364
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
1365
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
1366
    }
1367
}
1368
1369
/// `POST /api/v1/flow/jobs/{id}/log`.
1370
pub async fn log(
1371
    State(app): State<Shared>,
1372
    headers: HeaderMap,
1373
    Path(id): Path<i64>,
1374
    Json(mut batch): Json<LogBatch>,
1375
) -> Result<StatusCode, ApiError> {
1376
    runner_auth(&app, &headers).await?;
1377
    let job = running_job(&app, id).await?;
1378
    // An empty batch asks only whether the job still runs: a cancelled
1379
    // one answers 409 and its runner stops it (oxygenflow#32).
1380
    if batch.records.is_empty() {
1381
        return Ok(StatusCode::NO_CONTENT);
1382
    }
1383
    // A secret's value never reaches the stored log (#107).
1384
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
1385
        Ok(spec) if !spec.secrets.is_empty() => {
1386
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
1387
            let row = match run {
1388
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
1389
                None => None,
1390
            };
1391
            match row {
1392
                Some(row) => {
1393
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
1394
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
1395
                }
1396
                None => vec![],
1397
            }
1398
        }
1399
        _ => vec![],
1400
    };
1401
    let kid = app.oidc.get().map(|i| i.kid.clone());
1402
    for r in &mut batch.records {
1403
        if !values.is_empty() {
1404
            r.text = crate::secrets::mask(&r.text, &values);
1405
        }
1406
        if let Some(kid) = &kid {
1407
            r.text = crate::oidc::mask(&r.text, kid);
1408
        }
1409
        if r.text.len() > MAX_RECORD_TEXT {
1410
            let mut cut = MAX_RECORD_TEXT;
1411
            while !r.text.is_char_boundary(cut) {
1412
                cut -= 1;
1413
            }
1414
            r.text.truncate(cut);
1415
        }
1416
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
1417
            r.stream = "console".into();
1418
        }
1419
    }
1420
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
1421
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
1422
        tracing::info!("flow: job {} reached the log limit", job.id);
1423
    }
1424
    Ok(StatusCode::NO_CONTENT)
1425
}
1426
1427
/// Largest artifact the hub keeps; the runner refuses larger ones first.
1428
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
1429
1430
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
1431
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
1432
/// repository. Answers `{"blob"}`.
1433
pub async fn artifact(
1434
    State(app): State<Shared>,
1435
    headers: HeaderMap,
1436
    Path((id, name)): Path<(i64, String)>,
1437
    body: axum::body::Bytes,
1438
) -> Result<Response, ApiError> {
1439
    runner_auth(&app, &headers).await?;
1440
    let job = running_job(&app, id).await?;
1441
    let spec: flow_core::Job =
1442
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1443
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
1444
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
1445
    };
1446
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1447
    let row =
1448
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1449
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1450
    // A report's artifact is its manifest (#127): checked, and its files
1451
    // found in the store, before it is kept.
1452
    let report = match out.kind {
1453
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
1454
        flow_core::OutputKind::Artifact => None,
1455
    };
1456
    let content = blake3::hash(&body).to_hex().to_string();
1457
    let size = body.len() as i64;
1458
    let artifact = report.is_none();
1459
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
1460
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
1461
    let blob = app
1462
        .repos
1463
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1464
        .await
1465
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
1466
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
1467
    fdb::add_artifact(&app.db, &a).await?;
1468
    if let Some((manifest, declaration)) = report {
1469
        let origin = crate::reports::Origin {
1470
            repo_id: row.id,
1471
            run_id: run.id,
1472
            run_number: run.number,
1473
            job_id: job.id,
1474
            job: &job.name,
1475
            bookmark: &run.bookmark,
1476
            state: &run.state,
1477
        };
1478
        let said = match declaration {
1479
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
1480
                Ok(id) => {
1481
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
1482
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
1483
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
1484
                }
1485
                Err(e) => format!("report {}: not indexed: {e}", a.name),
1486
            },
1487
            Err(why) => {
1488
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
1489
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
1490
                format!("report {}: report.toml: {why}", a.name)
1491
            }
1492
        };
1493
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1494
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1495
    }
1496
    // A coverage report (#113) is read now; what it says, or why it could
1497
    // not be read, goes in the job's log. It never fails the upload.
1498
    if let Some(bytes) = report_bytes {
1499
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
1500
            Ok(text) | Err(text) => text,
1501
        };
1502
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1503
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1504
    }
1505
    // A test report (#114) likewise.
1506
    if let Some(bytes) = junit_bytes {
1507
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
1508
            Ok(text) | Err(text) => text,
1509
        };
1510
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1511
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1512
    }
1513
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1514
}
1515
1516
/// A check per metric with a threshold (#127, docs/reports.md §5), named
1517
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
1518
/// `fail_above` or `fail_below`, which a proposal may require.
1519
async fn report_threshold_checks(
1520
    app: &Shared,
1521
    row: &db::repos::RepoRow,
1522
    run: &fdb::Run,
1523
    name: &str,
1524
    id: i64,
1525
    metrics: &[crate::reports::Metric],
1526
) {
1527
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
1528
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
1529
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
1530
        let above = m.fail_above.filter(|a| m.value > *a);
1531
        let below = m.fail_below.filter(|b| m.value < *b);
1532
        let (status, summary) = match (above, below) {
1533
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
1534
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
1535
            _ => {
1536
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
1537
                    .into_iter()
1538
                    .flatten()
1539
                    .collect();
1540
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
1541
            }
1542
        };
1543
        let check = format!("report/{name}/{}", m.key);
1544
        if let Ok(c) = db::hooks::upsert_check(
1545
            &app.db,
1546
            row.id,
1547
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
1548
        )
1549
        .await
1550
        {
1551
            let api = crate::api::check_of(&c);
1552
            app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1553
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1554
        }
1555
    }
1556
}
1557
1558
/// Check report manifest `body` (#127) and that the repository holds every
1559
/// file it names; answers it with the report's declaration, or why that
1560
/// could not be read.
1561
async fn report_manifest(
1562
    app: &Shared,
1563
    repo: &RepoName,
1564
    name: &str,
1565
    body: &[u8],
1566
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
1567
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
1568
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
1569
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
1570
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
1571
    let found = app
1572
        .repos
1573
        .read(repo, move |r, _| {
1574
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
1575
                return Ok(Err(path.clone()));
1576
            }
1577
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
1578
        })
1579
        .await
1580
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
1581
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
1582
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
1583
    Ok((manifest, declaration))
1584
}
1585
1586
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
1587
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
1588
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
1589
/// the files follows as the report's artifact.
1590
pub async fn report_file(
1591
    State(app): State<Shared>,
1592
    headers: HeaderMap,
1593
    Path((id, name)): Path<(i64, String)>,
1594
    body: axum::body::Bytes,
1595
) -> Result<Response, ApiError> {
1596
    runner_auth(&app, &headers).await?;
1597
    let job = running_job(&app, id).await?;
1598
    let spec: flow_core::Job =
1599
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1600
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
1601
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
1602
    }
1603
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
1604
    if !crate::reports::path_ok(path) {
1605
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
1606
    }
1607
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1608
    let row =
1609
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1610
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1611
    let blob = app
1612
        .repos
1613
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1614
        .await
1615
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
1616
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
1617
}
1618
1619
/// Parse and keep a job's JUnit report: what it says, or why not.
1620
async fn read_junit(
1621
    app: &Shared,
1622
    row: &db::repos::RepoRow,
1623
    run: &fdb::Run,
1624
    job: &fdb::Job,
1625
    blob: &str,
1626
    bytes: axum::body::Bytes,
1627
) -> Result<String, String> {
1628
    if bytes.len() > crate::junit::MAX_REPORT {
1629
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
1630
    }
1631
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
1632
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
1633
        .await
1634
        .map_err(|e| format!("tests: {e}"))?
1635
        .map_err(|e| format!("tests: {e}"))?;
1636
    let origin = crate::junit::Origin {
1637
        repo_id: row.id,
1638
        run_id: run.id,
1639
        run_number: run.number,
1640
        job_id: job.id,
1641
        job: &job.name,
1642
        bookmark: &run.bookmark,
1643
        state: &run.state,
1644
        blob,
1645
    };
1646
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
1647
    let r = crate::junit::for_run(&app.db, run.id)
1648
        .await
1649
        .map_err(|e| format!("tests: {e}"))?
1650
        .into_iter()
1651
        .find(|r| r.job_id == job.id)
1652
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
1653
    // The `tests` check on the run's state: what fails, and which of those
1654
    // passed in the same job's latest report on the default bookmark, so a
1655
    // proposal names what it broke.
1656
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
1657
    let newly = match &base {
1658
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
1659
        _ => vec![],
1660
    };
1661
    let (status, summary) = if r.broken() == 0 {
1662
        ("passed", format!("{} passed", r.passed))
1663
    } else if let Some(b) = &base {
1664
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
1665
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
1666
        let new = if newly.is_empty() {
1667
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
1668
        } else {
1669
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
1670
        };
1671
        ("failed", format!("{} failing; {new}", r.broken()))
1672
    } else {
1673
        ("failed", format!("{} failing", r.broken()))
1674
    };
1675
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
1676
    if let Ok(c) = db::hooks::upsert_check(
1677
        &app.db,
1678
        row.id,
1679
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
1680
    )
1681
    .await
1682
    {
1683
        let api = crate::api::check_of(&c);
1684
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1685
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1686
    }
1687
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
1688
}
1689
1690
/// Parse and keep a job's coverage report: what it says, or why not.
1691
async fn read_coverage(
1692
    app: &Shared,
1693
    repo: &RepoName,
1694
    row: &db::repos::RepoRow,
1695
    run: &fdb::Run,
1696
    job: &fdb::Job,
1697
    blob: &str,
1698
    bytes: axum::body::Bytes,
1699
) -> Result<String, String> {
1700
    if bytes.len() > crate::coverage::MAX_REPORT {
1701
        return Err(format!(
1702
            "coverage: the report is {} MiB; at most {} MiB is read",
1703
            bytes.len() >> 20,
1704
            crate::coverage::MAX_REPORT >> 20
1705
        ));
1706
    }
1707
    let text = String::from_utf8(bytes.to_vec())
1708
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
1709
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
1710
        .await
1711
        .map_err(|e| format!("coverage: {e}"))?
1712
        .map_err(|e| format!("coverage: {e}"))?;
1713
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
1714
    let in_state: std::collections::HashSet<String> = app
1715
        .repos
1716
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
1717
        .await
1718
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
1719
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
1720
    let origin = crate::coverage::Origin {
1721
        repo_id: row.id,
1722
        run_id: Some(run.id),
1723
        run_number: run.number,
1724
        job_id: Some(job.id),
1725
        uploader: "",
1726
        job: &job.name,
1727
        bookmark: &run.bookmark,
1728
        state: &run.state,
1729
        heads: &heads,
1730
        blob,
1731
        format,
1732
    };
1733
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
1734
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
1735
        return Err("coverage: the report was not kept".into());
1736
    };
1737
    // The `coverage` check on the run's state (#113): against the default
1738
    // bookmark, under the repository's rules.
1739
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
1740
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
1741
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
1742
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
1743
    if let Ok(c) = db::hooks::upsert_check(
1744
        &app.db,
1745
        row.id,
1746
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
1747
    )
1748
    .await
1749
    {
1750
        let api = crate::api::check_of(&c);
1751
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1752
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1753
    }
1754
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
1755
    let dropped =
1756
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
1757
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
1758
}
1759
1760
/// `POST /api/v1/flow/jobs/{id}/finish`.
1761
pub async fn finish(
1762
    State(app): State<Shared>,
1763
    headers: HeaderMap,
1764
    Path(id): Path<i64>,
1765
    Json(f): Json<Finish>,
1766
) -> Result<StatusCode, ApiError> {
1767
    let me = runner_auth(&app, &headers).await?;
1768
    let job = running_job(&app, id).await?;
1769
    let status = match (f.exit_code, &f.error) {
1770
        (Some(0), None) => "passed",
1771
        (Some(_), _) => "failed",
1772
        (None, _) => "error",
1773
    };
1774
    let (receipt, receipt_hash) = match &f.receipt {
1775
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
1776
            // A passing job's receipt names exactly the artifacts uploaded,
1777
            // in name order; a failing one's names none, though it may have
1778
            // kept `when "always"` artifacts (oxygenflow#37).
1779
            let kept: Vec<String> =
1780
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
1781
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
1782
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
1783
            }
1784
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
1785
        }
1786
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
1787
        None => (String::new(), String::new()),
1788
    };
1789
    let error = f.error.clone().unwrap_or_default();
1790
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
1791
    let changed = fdb::finish(
1792
        &app.db,
1793
        job.id,
1794
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
1795
    )
1796
    .await?;
1797
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1798
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1799
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
1800
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
1801
                    Ok((attest, signer)) => (attest, signer, String::new()),
1802
                    Err(e) => {
1803
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
1804
                        (String::new(), String::new(), e)
1805
                    }
1806
                };
1807
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
1808
            }
1809
        }
1810
    }
1811
    report_changed(&app, job.run_id, changed).await?;
1812
    Ok(StatusCode::NO_CONTENT)
1813
}
1814
1815
/// Report the checks of jobs whose status changed.
1816
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
1817
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
1818
        return Ok(());
1819
    };
1820
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
1821
        return Ok(());
1822
    };
1823
    let mut errored_on: Vec<i64> = Vec::new();
1824
    for j in changed {
1825
        let summary = match j.status.as_str() {
1826
            "passed" => format!("passed in {}", duration(&j)),
1827
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
1828
            "skipped" => "skipped: a dependency did not pass".into(),
1829
            "cancelled" => j.error.clone(),
1830
            _ => format!("could not run: {}", j.error),
1831
        };
1832
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
1833
        if j.status == "error" {
1834
            if let Some(r) = runner_of(app, j.id).await {
1835
                errored_on.push(r);
1836
            }
1837
        }
1838
    }
1839
    errored_on.dedup();
1840
    for r in errored_on {
1841
        drain_if_erroring(app, r).await;
1842
    }
1843
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
1844
        if done.status == "failed" && !done.live() {
1845
            tell_failed(app, &row, &done).await;
1846
        }
1847
    }
1848
    // A passing run of an upstream's default bookmark: consumers following
1849
    // it when green are bumped (Diffusion, #148).
1850
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
1851
        if done.status == "passed" && done.bookmark == row.default_bookmark {
1852
            let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
1853
            let (app, row) = (app.clone(), row.clone());
1854
            tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await });
1855
        }
1856
        // A bump's run passed: a consumer that lands its bumps itself may
1857
        // land it now (docs/diffusion.md §4.2).
1858
        if let Some(path) = done.bookmark.strip_prefix("diffusion/").filter(|_| done.status == "passed") {
1859
            tokio::spawn(crate::diffusion::try_land(app.clone(), row.clone(), path.to_string()));
1860
        }
1861
    }
1862
    Ok(())
1863
}
1864
1865
/// The account OxygenFlow's notices come from, made on first use with no
1866
/// way to sign in.
1867
pub const ACCOUNT: &str = "oxygenflow";
1868
1869
async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> {
1870
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
1871
        return Some(a);
1872
    }
1873
    db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok()
1874
}
1875
1876
async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> {
1877
    sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = ?").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten()
1878
}
1879
1880
/// A failed run (oxygenflow#30): its pusher and the repository's watchers
1881
/// are told, in their inboxes and by email as they chose, naming the jobs
1882
/// that did not pass.
1883
async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
1884
    let Some(me) = flow_account(&app.db).await else { return };
1885
    let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default();
1886
    let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect();
1887
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1888
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
1889
    crate::notify::deliver(
1890
        app,
1891
        crate::notify::Event {
1892
            kind: "flow.failed",
1893
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
1894
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
1895
            actor: &me,
1896
            repo_id: row.id,
1897
            thread: None,
1898
            mention_text: "",
1899
            direct: pusher.into_iter().map(|a| a.id).collect(),
1900
        },
1901
    )
1902
    .await;
1903
}
1904
1905
/// A runner whose last jobs all ended in infrastructure errors is drained,
1906
/// so it stops taking jobs it fails before running them, and the
1907
/// administrators are told (oxygenflow#30).
1908
async fn drain_if_erroring(app: &Shared, runner_id: i64) {
1909
    let n = app.config.flow.drain_after_errors;
1910
    let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return };
1911
    let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return };
1912
    if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() {
1913
        return;
1914
    }
1915
    tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name);
1916
    let Some(me) = flow_account(&app.db).await else { return };
1917
    let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = 1").fetch_all(&app.db).await.unwrap_or_default();
1918
    crate::notify::deliver(
1919
        app,
1920
        crate::notify::Event {
1921
            kind: "flow.runner_drained",
1922
            title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name),
1923
            url: format!("/admin/runners/{runner_id}"),
1924
            actor: &me,
1925
            repo_id: 0,
1926
            thread: None,
1927
            mention_text: "",
1928
            direct: admins,
1929
        },
1930
    )
1931
    .await;
1932
}
1933
1934
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
1935
/// Settings, else from `hub.toml`.
1936
pub struct Limits {
1937
    /// For a job whose pipeline names none.
1938
    pub default_s: u64,
1939
    /// The most a pipeline may ask for.
1940
    pub max_s: u64,
1941
    /// Grace past a job's limit before the hub gives it up: a runner stops
1942
    /// a job at its limit and reports it within moments, so one still
1943
    /// running this long after is one the runner no longer reports (it was
1944
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
1945
    pub grace_ms: i64,
1946
}
1947
1948
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
1949
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
1950
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
1951
1952
pub async fn limits(app: &Shared) -> Limits {
1953
    let minutes = |key: &'static str| async move {
1954
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
1955
    };
1956
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
1957
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
1958
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
1959
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
1960
}
1961
1962
/// End every job still running past its time limit plus the grace:
1963
/// it is marked an error with the reason, the jobs that depended on it are
1964
/// skipped and its run settled, as when a runner reports an error.
1965
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
1966
    let limits = limits(app).await;
1967
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
1968
    let n = lost.len();
1969
    for job in lost {
1970
        let error = format!(
1971
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
1972
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
1973
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
1974
        );
1975
        let changed =
1976
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1977
                .await?;
1978
        tracing::warn!("flow: job {} {error}", job.id);
1979
        report_changed(app, job.run_id, changed).await?;
1980
    }
1981
    Ok(n)
1982
}
1983
1984
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
1985
/// it restarted since claiming them, or never received the claim. A
1986
/// runner claims one job at a time, so a job it holds is always listed.
1987
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
1988
    for job in fdb::running_on(&app.db, runner.id).await? {
1989
        if holding.contains(&job.id) {
1990
            continue;
1991
        }
1992
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
1993
        let changed =
1994
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1995
                .await?;
1996
        tracing::warn!("flow: job {} {error}", job.id);
1997
        report_changed(app, job.run_id, changed).await?;
1998
    }
1999
    Ok(())
2000
}
2001
2002
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
2003
/// repository that runs Flow, the jobs of its default bookmark's pipeline
2004
/// with a `schedule` line due then. Returns how many runs started.
2005
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
2006
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
2007
    let mut started = 0;
2008
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
2009
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
2010
        let b = row.default_bookmark.clone();
2011
        let found = app
2012
            .repos
2013
            .read(&repo, move |r, _| {
2014
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
2015
                Ok(Some((st, r.store().get_state(&st)?.heads)))
2016
            })
2017
            .await;
2018
        let Ok(Some((state, heads))) = found else { continue };
2019
        if heads.is_empty() {
2020
            continue;
2021
        }
2022
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
2023
            Ok(Some(_)) => started += 1,
2024
            Ok(None) => {}
2025
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
2026
        }
2027
    }
2028
    started
2029
}
2030
2031
/// The hub's clock as a [`When`](flow_core::pipeline::When).
2032
pub fn when_now() -> flow_core::pipeline::When {
2033
    let now = time::OffsetDateTime::now_utc();
2034
    flow_core::pipeline::When {
2035
        minute: now.minute() as u32,
2036
        hour: now.hour() as u32,
2037
        day: now.day() as u32,
2038
        month: u8::from(now.month()) as u32,
2039
        weekday: now.weekday().number_days_from_sunday() as u32,
2040
    }
2041
}
2042
2043
/// Start the scheduled runs due each minute, once a minute.
2044
pub async fn scheduler(app: Shared) {
2045
    let mut last = None;
2046
    loop {
2047
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
2048
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
2049
        if last == Some(minute) {
2050
            continue;
2051
        }
2052
        last = Some(minute);
2053
        let n = run_due(&app, when_now()).await;
2054
        if n > 0 {
2055
            tracing::info!("flow: {n} scheduled run(s) started");
2056
        }
2057
    }
2058
}
2059
2060
/// Look for lost jobs once a minute.
2061
pub async fn reaper(app: Shared) {
2062
    loop {
2063
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2064
        if let Err(e) = reap_lost_jobs(&app).await {
2065
            tracing::warn!("flow: looking for lost jobs: {e}");
2066
        }
2067
        match expire_approvals(&app, db::now_ms()).await {
2068
            Ok(n) if n > 0 => tracing::info!("flow: {n} approvals expired"),
2069
            Ok(_) => {}
2070
            Err(e) => tracing::warn!("flow: expiring approvals: {e}"),
2071
        }
2072
    }
2073
}
2074
2075
/// Check a runner's signed receipt for a job of `run` and keep it in the
2076
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
2077
/// name, or why it was not kept. It must be the job's receipt, for the
2078
/// run's state and repository, signed by the key the runner's token first
2079
/// signed with.
2080
pub async fn keep_receipt(
2081
    app: &Shared,
2082
    runner: &db::runners::Runner,
2083
    row: &db::repos::RepoRow,
2084
    run: &fdb::Run,
2085
    receipt: &flow_core::Receipt,
2086
    signed_hex: &str,
2087
) -> Result<(String, String), String> {
2088
    use oxy_core::receipt::SignedReceipt;
2089
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
2090
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
2091
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
2092
    if sr.receipt.hash().0 != receipt.hash() {
2093
        return Err("it signs another receipt than the job's".into());
2094
    }
2095
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
2096
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
2097
    if sr.state() != state {
2098
        return Err("it is for another state than the run's".into());
2099
    }
2100
    let key = sr.runner.hash().to_hex();
2101
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
2102
    if bound != key {
2103
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
2104
    }
2105
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
2106
    let signer = sr.runner.name.clone();
2107
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
2108
    Ok((attest.to_hex(), signer))
2109
}
2110
2111
/// A job's wall time, as the pages show it.
2112
pub fn duration(j: &fdb::Job) -> String {
2113
    match (j.started_ms, j.finished_ms) {
2114
        (Some(a), Some(b)) => human_ms(b - a),
2115
        (Some(a), None) => human_ms(db::now_ms() - a),
2116
        _ => "–".into(),
2117
    }
2118
}
2119
2120
pub fn human_ms(ms: i64) -> String {
2121
    let ms = ms.max(0);
2122
    if ms < 1000 {
2123
        format!("{ms} ms")
2124
    } else if ms < 60_000 {
2125
        format!("{:.1} s", ms as f64 / 1000.0)
2126
    } else {
2127
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
2128
    }
2129
}
2130
2131
/// Where a live run stands among the runners' work: what its viewers may
2132
/// know of it. Jobs of other repositories are named only when those are
2133
/// public; the rest are counted.
2134
#[derive(Clone, Debug, Default, PartialEq, Eq)]
2135
pub struct QueueInfo {
2136
    /// 1-based place of the run's first queued job among all queued jobs.
2137
    pub position: Option<usize>,
2138
    /// Jobs running now, and queued jobs ahead of the run's first one.
2139
    pub running: Vec<String>,
2140
    pub ahead: Vec<String>,
2141
    /// Of those, jobs in repositories the page may not name.
2142
    pub hidden: usize,
2143
}
2144
2145
impl QueueInfo {
2146
    /// "3rd in line" and the like.
2147
    pub fn place(&self) -> Option<String> {
2148
        self.position.map(|n| {
2149
            let suffix = match (n % 10, n % 100) {
2150
                (1, 11) | (2, 12) | (3, 13) => "th",
2151
                (1, _) => "st",
2152
                (2, _) => "nd",
2153
                (3, _) => "rd",
2154
                _ => "th",
2155
            };
2156
            format!("{n}{suffix} in line")
2157
        })
2158
    }
2159
}
2160
2161
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
2162
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
2163
    let mut info = QueueInfo::default();
2164
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
2165
    info.position = first.map(|i| i + 1);
2166
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
2167
    let mut queued_seen = 0;
2168
    for e in queue {
2169
        if e.run_id == run_id {
2170
            continue;
2171
        }
2172
        let ahead = match e.status.as_str() {
2173
            "running" => true,
2174
            _ => {
2175
                queued_seen += 1;
2176
                first.is_some_and(|f| queued_seen <= f)
2177
            }
2178
        };
2179
        if !ahead {
2180
            continue;
2181
        }
2182
        if e.repo_id != repo_id && e.visibility != "public" {
2183
            info.hidden += 1;
2184
        } else if e.status == "running" {
2185
            info.running.push(label(e));
2186
        } else {
2187
            info.ahead.push(label(e));
2188
        }
2189
    }
2190
    info
2191
}
2192
2193
#[cfg(test)]
2194
mod queue_tests {
2195
    use super::*;
2196
2197
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
2198
        fdb::QueueEntry {
2199
            job_id,
2200
            run_id,
2201
            repo_id,
2202
            owner: "o".into(),
2203
            name: format!("r{repo_id}"),
2204
            visibility: vis.into(),
2205
            run_number: run_id,
2206
            job: job.into(),
2207
            status: status.into(),
2208
            runner: String::new(),
2209
            spec: String::new(),
2210
            sandbox: String::new(),
2211
        }
2212
    }
2213
2214
    #[test]
2215
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
2216
        // Running first, then queued in claim order (as `fdb::queue` returns them).
2217
        let q = vec![
2218
            e(1, 10, 1, "public", "build", "running"),
2219
            e(2, 11, 2, "private", "test", "running"),
2220
            e(3, 10, 1, "public", "test", "queued"),
2221
            e(4, 12, 3, "private", "lint", "queued"),
2222
            e(5, 13, 4, "public", "build", "queued"),
2223
            e(6, 13, 4, "public", "test", "queued"),
2224
            e(7, 14, 5, "public", "late", "queued"),
2225
        ];
2226
        let info = queue_info(&q, 4, 13);
2227
        assert_eq!(info.position, Some(3));
2228
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
2229
        assert_eq!(info.running, ["o/r1 #10 build"]);
2230
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
2231
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
2232
        // A private repository's own jobs are named to its viewers.
2233
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
2234
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
2235
        // A run with nothing queued has no place.
2236
        assert_eq!(queue_info(&q, 1, 99).position, None);
2237
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
2238
        assert_eq!(
2239
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
2240
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
2241
        );
2242
    }
2243
}
2244
2245
#[cfg(test)]
2246
mod placement_tests {
2247
    use super::*;
2248
2249
    #[test]
2250
    fn a_waiting_job_says_what_each_runner_lacks() {
2251
        use crate::db::runners::Runner;
2252
        let rootfs = "ab".repeat(32);
2253
        let job = fdb::Job {
2254
            id: 7,
2255
            runtime: "syscall".into(),
2256
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
2257
            spec: serde_json::json!({ "name": "build" }).to_string(),
2258
            ..Default::default()
2259
        };
2260
        let now = db::now_ms();
2261
        let runner = |id: i64, name: &str| Runner {
2262
            id,
2263
            name: name.into(),
2264
            state: "active".into(),
2265
            last_seen_ms: Some(now),
2266
            arch: "x86_64".into(),
2267
            tiers: "microvm,syscall".into(),
2268
            images: serde_json::to_string(&[&rootfs]).unwrap(),
2269
            memory_mib: Some(16384),
2270
            cpus: Some(8),
2271
            ..Default::default()
2272
        };
2273
        let archs = std::collections::HashMap::new();
2274
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
2275
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
2276
        };
2277
        assert_eq!(why(&[], &[]), "no runner is online");
2278
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
2279
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
2280
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
2281
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
2282
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
2283
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
2284
        assert_eq!(
2285
            why(&[microvm_only, small, other, offline], &[]),
2286
            "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"
2287
        );
2288
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
2289
        assert_eq!(
2290
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
2291
            "no runner can take it: narvi is riscv64; the job needs x86_64"
2292
        );
2293
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
2294
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
2295
        let d = fdb::DeclineRow {
2296
            job_id: 7,
2297
            runner_id: 1,
2298
            runner: "earth-1".into(),
2299
            reason: "only 512 MiB of memory is free here".into(),
2300
            ms: now,
2301
        };
2302
        assert_eq!(
2303
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
2304
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
2305
        );
2306
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
2307
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
2308
    }
2309
}