OXYGENHUB
# b644e1757711 oxygenhub / crates / hub / src / flow.rs
104.9 KiB · Rust · last changed Environments: the token names the environment once approved; a Deployments page 21 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
    // An environment goes in its tokens only once the job was approved
829
    // (oxygenflow#59): the approval is the environment's protection.
830
    let environment = claim.job.environment.clone().filter(|_| job.approval == "approved");
831
    if let (Some(e), None, false) = (&claim.job.environment, &environment, claim.job.identities.is_empty()) {
832
        left_out.push(format!(
833
            "environment {e}: not in the job's identity tokens, because the job was not approved (give it an approval block)"
834
        ));
835
    }
836
    if !claim.job.identities.is_empty() {
837
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
838
        for i in &claim.job.identities {
839
            let token = issuer.mint(&crate::oidc::JobClaims {
840
                issuer: app.config.base_url(),
841
                audience: &i.audience,
842
                owner: &row.owner,
843
                name: &row.name,
844
                visibility: &row.visibility,
845
                bookmark: &run.bookmark,
846
                run_number: run.number,
847
                job: &job.name,
848
                state: &run.state,
849
                runner: &runner,
850
                actor: &run.actor,
851
                environment: environment.as_deref(),
852
                now_ms: db::now_ms(),
853
                ttl_s: timeout_s,
854
            });
855
            claim.secrets.push(flow_core::api::SecretInput {
856
                name: format!("identity:{}", i.audience),
857
                path: i.mount_path.clone(),
858
                value: token,
859
            });
860
        }
861
    }
862
    for s in &claim.job.secrets {
863
        match found.iter().find(|(n, _)| *n == s.name) {
864
            Some((name, value)) => {
865
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
866
            }
867
            None => left_out.push(format!(
868
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
869
                s.name, s.mount_path
870
            )),
871
        }
872
    }
873
    if !ids.is_empty() {
874
        fdb::set_also_read(&app.db, job.id, &ids).await?;
875
    }
876
    // Say at the top of the job's log what it will not have (oxygenhub#56),
877
    // rather than let it fail later on missing files.
878
    if !left_out.is_empty() {
879
        let records: Vec<flow_core::api::Record> =
880
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
881
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
882
    }
883
    claim.subrepos = inputs;
884
    // Artifacts of jobs it depends on (oxygenflow#58), with the blob each
885
    // producer's receipt names; the runner checks what it fetches against it.
886
    match artifact_inputs(&app, &job, &claim.job).await {
887
        Ok(found) => claim.inputs = found,
888
        Err(why) => {
889
            let error = format!("inputs: {why}");
890
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
891
                .await?;
892
            return Err(ApiError(StatusCode::CONFLICT, error));
893
        }
894
    }
895
    Ok(Json(claim).into_response())
896
}
897
898
/// The artifacts a job takes (oxygenflow#58), each from a job of the same
899
/// run that passed with a receipt; a passing job's receipt names exactly the
900
/// artifacts it uploaded (checked at finish), so their blobs are attested.
901
async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> {
902
    if spec.inputs.is_empty() {
903
        return Ok(vec![]);
904
    }
905
    let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?;
906
    let mut out = Vec::new();
907
    for i in &spec.inputs {
908
        let Some(producer) = jobs.iter().find(|j| j.name == i.from) else {
909
            return Err(format!("{:?} is not a job of this run", i.from));
910
        };
911
        if producer.status != "passed" || producer.receipt_hash.is_empty() {
912
            return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact));
913
        }
914
        let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?;
915
        let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else {
916
            return Err(format!("{} kept no artifact {}", i.from, i.artifact));
917
        };
918
        out.push(flow_core::api::ArtifactInput {
919
            artifact: i.artifact.clone(),
920
            from: i.from.clone(),
921
            path: i.path.clone(),
922
            blob: a.blob.clone(),
923
        });
924
    }
925
    Ok(out)
926
}
927
928
/// `GET /api/v1/flow/jobs/{id}/inputs/{index}`: the bytes of input `index`
929
/// of a running job (oxygenflow#58), for the runner holding it.
930
pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> {
931
    let runner = runner_auth(&app, &headers).await?;
932
    let job = running_job(&app, id).await?;
933
    // A registered runner reads only the inputs of a job it holds (a
934
    // configured token's runner names itself when it claims).
935
    if runner.source == "registered" && job.runner != runner.name {
936
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
937
    }
938
    let spec: flow_core::Job =
939
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
940
    let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?;
941
    let Some(i) = found.get(index) else {
942
        return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name)));
943
    };
944
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
945
    let row =
946
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
947
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
948
    let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob)
949
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?;
950
    let bytes = app
951
        .repos
952
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
953
        .await
954
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?;
955
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
956
}
957
958
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
959
/// labels and capacity, each list sorted.
960
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
961
    let sorted = |v: &[String]| {
962
        let mut v = v.to_vec();
963
        v.sort();
964
        v
965
    };
966
    let info = req.info.as_ref();
967
    let v = serde_json::json!({
968
        "arch": arch,
969
        "tiers": sorted(&req.runtimes),
970
        "images": req.images.as_deref().map(sorted),
971
        "labels": info.map(|i| sorted(&i.labels)),
972
        "memory_mib": info.and_then(|i| i.memory_mib),
973
        "cpus": info.and_then(|i| i.cpus),
974
    });
975
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
976
}
977
978
/// A job handed back by this many different runners is unplaceable.
979
pub const UNPLACEABLE_AFTER: usize = 3;
980
981
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
982
/// runner will not run a job it was handed. The job is queued again and
983
/// not offered to that runner while its profile stays the same (and for
984
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
985
/// different runners have declined it, it ends as unplaceable with their
986
/// reasons. A decline is never a failed check.
987
pub async fn decline(
988
    State(app): State<Shared>,
989
    headers: HeaderMap,
990
    Path(id): Path<i64>,
991
    Json(d): Json<flow_core::api::Decline>,
992
) -> Result<StatusCode, ApiError> {
993
    let me = runner_auth(&app, &headers).await?;
994
    db::runners::seen(&app.db, me.id, None, None).await?;
995
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
996
    let reason = match reason.trim() {
997
        "" => "no reason given".to_string(),
998
        r => r.to_string(),
999
    };
1000
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
1001
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1002
    };
1003
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
1004
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
1005
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
1006
    if runners.len() >= UNPLACEABLE_AFTER {
1007
        let error = format!(
1008
            "unplaceable: declined by {} runners: {}",
1009
            runners.len(),
1010
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
1011
        );
1012
        let changed =
1013
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1014
                .await?;
1015
        report_changed(&app, job.run_id, changed).await?;
1016
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1017
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1018
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
1019
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
1020
            fdb::set_waiting(&app.db, id, &summary).await?;
1021
        }
1022
    }
1023
    Ok(StatusCode::NO_CONTENT)
1024
}
1025
1026
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1027
/// now (spec §11.4), from what each runner offered at its last claim:
1028
/// empty when one could (it waits its turn or a free slot), else what
1029
/// each one lacks. `declines` are the job's.
1030
/// What placement knows of the fleet: images' architectures, the images
1031
/// the hub holds, and the time.
1032
pub struct Fleet<'a> {
1033
    pub image_archs: &'a std::collections::HashMap<String, String>,
1034
    pub hub_images: &'a std::collections::HashSet<String>,
1035
    pub now: i64,
1036
}
1037
1038
/// Why runner `r` cannot take `job` of a repository of `owner` (id
1039
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
1040
/// when it can. `declines` are the job's.
1041
pub fn runner_lacks(
1042
    job: &fdb::Job,
1043
    owner: &str,
1044
    repo_id: i64,
1045
    r: &db::runners::Runner,
1046
    fleet: &Fleet,
1047
    declines: &[fdb::DeclineRow],
1048
) -> Option<String> {
1049
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
1050
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
1051
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
1052
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
1053
    if r.state == "draining" {
1054
        return Some("is draining".into());
1055
    }
1056
    let scope = r.scope();
1057
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
1058
        return Some("serves other repositories".into());
1059
    }
1060
    let tiers = r.tier_list();
1061
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
1062
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
1063
    }
1064
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
1065
        return Some(format!("is {}; the job needs {w}", r.arch));
1066
    }
1067
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
1068
    if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
1069
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
1070
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
1071
    }
1072
    let labels = r.label_list();
1073
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
1074
        return Some(format!("lacks label {l}"));
1075
    }
1076
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
1077
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
1078
    }
1079
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
1080
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
1081
    }
1082
    // The disk it learned it needs (oxygenflow#41).
1083
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
1084
        if free < n {
1085
            return Some(format!(
1086
                "has {} free; the job needs ~{} (its recent runs took up to {})",
1087
                mib(free),
1088
                mib(n),
1089
                mib((n - 64) * 4 / 5)
1090
            ));
1091
        }
1092
    }
1093
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
1094
        return Some(format!("declined it: {}", d.reason));
1095
    }
1096
    None
1097
}
1098
1099
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1100
/// now (spec §11.4), from what each runner offered at its last claim:
1101
/// empty when one could (it waits its turn or a free slot), else what
1102
/// each one lacks. `declines` are the job's.
1103
#[allow(clippy::too_many_arguments)]
1104
pub fn why_waiting(
1105
    job: &fdb::Job,
1106
    owner: &str,
1107
    repo_id: i64,
1108
    runners: &[db::runners::Runner],
1109
    image_archs: &std::collections::HashMap<String, String>,
1110
    declines: &[fdb::DeclineRow],
1111
    now: i64,
1112
    hub_images: &std::collections::HashSet<String>,
1113
) -> String {
1114
    let fleet = Fleet { image_archs, hub_images, now };
1115
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
1116
    if online.is_empty() {
1117
        return "no runner is online".into();
1118
    }
1119
    let mut why = Vec::new();
1120
    for r in &online {
1121
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
1122
            None => return String::new(),
1123
            Some(w) => why.push(format!("{} {w}", r.name)),
1124
        }
1125
    }
1126
    let more = why.len().saturating_sub(4);
1127
    why.truncate(4);
1128
    let mut text = format!("no runner can take it: {}", why.join("; "));
1129
    if more > 0 {
1130
        text.push_str(&format!("; and {more} more"));
1131
    }
1132
    text
1133
}
1134
1135
/// How long a job is held for a better runner before any runner that fits
1136
/// takes it (oxygenflow#42).
1137
const BETTER_HOLD_MS: i64 = 15_000;
1138
/// A runner counts as there to take a job when it asked this recently.
1139
const POLLING_MS: i64 = 10_000;
1140
1141
/// When each job was first passed over for a better runner.
1142
/// Keyed by hub (tests run several in one process) and job.
1143
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
1144
1145
/// The build cache slots each runner said at its last claim it holds
1146
/// (oxygenflow#51). Keyed by hub (tests run several in one process) and
1147
/// runner; a restart forgets them until each runner's next claim.
1148
/// Each slot maps to when it last kept a disk (Unix ms, 0 unknown).
1149
type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashMap<String, i64>>;
1150
static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None);
1151
1152
/// When runner `runner` last kept a disk in build cache slot `slot` (0 when
1153
/// it did not say); `None` when it holds none there.
1154
fn cache_kept(hub: &str, runner: i64, slot: &str) -> Option<i64> {
1155
    CACHES
1156
        .lock()
1157
        .unwrap_or_else(|p| p.into_inner())
1158
        .as_ref()
1159
        .and_then(|c| c.get(&(hub.to_string(), runner)))
1160
        .and_then(|held| held.get(slot).copied())
1161
}
1162
1163
/// A copy of a build cache kept at least this much later is the fresher
1164
/// (oxygenflow#54): closer than that, the two are as good as each other.
1165
const FRESHER_MS: i64 = 60_000;
1166
1167
/// How well a runner fits a job (oxygenflow#42, #51, #54).
1168
struct Fit {
1169
    /// When it last kept the job's build cache, if it holds it.
1170
    cache: Option<i64>,
1171
    image: bool,
1172
    speed: i64,
1173
}
1174
1175
impl Fit {
1176
    /// Whether this fit is better than `other`: holding the job's build
1177
    /// cache counts most (a warm build beats any image fetch), and of two
1178
    /// that hold it the fresher copy; then holding the image, then speed.
1179
    fn beats(&self, other: &Fit) -> bool {
1180
        match (self.cache, other.cache) {
1181
            (Some(_), None) => return true,
1182
            (None, Some(_)) => return false,
1183
            (Some(a), Some(b)) if a > 0 && b > 0 && (a - b).abs() >= FRESHER_MS => return a > b,
1184
            _ => {}
1185
        }
1186
        (self.image, self.speed) > (other.image, other.speed)
1187
    }
1188
}
1189
1190
/// How runner `r` fits a job on image `rootfs` whose build cache slot is
1191
/// `slot`.
1192
fn fit(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> Fit {
1193
    Fit {
1194
        cache: cache_kept(hub, r.id, slot),
1195
        image: r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)),
1196
        speed: r.speed_class.unwrap_or(0),
1197
    }
1198
}
1199
1200
/// A span as people say it: 40 s, 12 min, 3 h, 2 days.
1201
fn ago(ms: i64) -> String {
1202
    let s = ms.max(0) / 1000;
1203
    match s {
1204
        0..=59 => format!("{s} s"),
1205
        60..=3599 => format!("{} min", s / 60),
1206
        3600..=172_799 => format!("{} h", s / 3600),
1207
        _ => format!("{} days", s / 86_400),
1208
    }
1209
}
1210
1211
fn rootfs_of(job: &fdb::Job) -> String {
1212
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
1213
}
1214
1215
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
1216
fn mib(m: i64) -> String {
1217
    if m >= 1024 {
1218
        format!("{:.1} GiB", m as f64 / 1024.0)
1219
    } else {
1220
        format!("{m} MiB")
1221
    }
1222
}
1223
1224
/// One placement pass: say why each ready queued job waits.
1225
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
1226
    let ready = fdb::ready_queued(&app.db).await?;
1227
    if ready.is_empty() {
1228
        return Ok(());
1229
    }
1230
    let runners = db::runners::list(&app.db).await?;
1231
    let archs = fdb::image_archs(&app.db).await?;
1232
    let held = crate::images::held(&app.db).await?;
1233
    let now = db::now_ms();
1234
    for (job, owner, repo_id) in ready {
1235
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
1236
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1237
        if why == job.waiting {
1238
            continue;
1239
        }
1240
        fdb::set_waiting(&app.db, job.id, &why).await?;
1241
        // The job's check says it too, so a proposal shows why it is stuck
1242
        // (oxygenhub#28).
1243
        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?) {
1244
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
1245
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
1246
        }
1247
    }
1248
    Ok(())
1249
}
1250
1251
/// Run a placement pass every 15 seconds.
1252
pub async fn placement(app: Shared) {
1253
    loop {
1254
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1255
        if let Err(e) = explain_waits(&app).await {
1256
            tracing::warn!("flow: placement pass: {e}");
1257
        }
1258
    }
1259
}
1260
1261
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
1262
pub(crate) async fn pins_in(
1263
    app: &Shared,
1264
    name: crate::repos::RepoName,
1265
    heads: Vec<oxy_core::hash::ChangeHash>,
1266
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
1267
    app.repos
1268
        .read(&name, move |repo, cache| {
1269
            let loaded = cache.load(repo, &heads)?;
1270
            let mut out = Vec::new();
1271
            for (path, f) in &loaded.snapshot.files {
1272
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
1273
                    continue;
1274
                }
1275
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
1276
                    if let Some(b) = blobs.first() {
1277
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
1278
                            .read_blob(b)
1279
                            .map_err(crate::repos::RepoError::Oxy)
1280
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
1281
                        {
1282
                            out.push((path.clone(), id, heads));
1283
                        }
1284
                    }
1285
                }
1286
            }
1287
            Ok(out)
1288
        })
1289
        .await
1290
        .unwrap_or_default()
1291
}
1292
1293
/// How deep subrepos of subrepos are followed.
1294
const MAX_SUBREPO_DEPTH: usize = 3;
1295
1296
/// The Oxygen subrepos the run's state pins, and those their states pin in
1297
/// turn (parents first, paths joined), that live on this hub and that the
1298
/// run's pusher may read: what the job's workspace needs beside its own
1299
/// tree. A pin the hub cannot serve is left out, with what it pins, and
1300
/// the third list says why, for the job's log.
1301
async fn subrepo_inputs(
1302
    app: &Shared,
1303
    row: &db::repos::RepoRow,
1304
    run: &fdb::Run,
1305
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
1306
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
1307
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1308
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1309
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
1310
    let mut inputs = Vec::new();
1311
    let mut ids = Vec::new();
1312
    let mut left_out = Vec::new();
1313
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
1314
    // (path prefix, repository, heads, depth), parents before children.
1315
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
1316
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
1317
        if depth == MAX_SUBREPO_DEPTH {
1318
            continue;
1319
        }
1320
        for (path, id, pinned) in pins_in(app, name, heads).await {
1321
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
1322
            let mut hosted = false;
1323
            for other in &rows {
1324
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
1325
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
1326
                if !matches {
1327
                    continue;
1328
                }
1329
                hosted = true;
1330
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
1331
                // A Diffusion bump's run reads the upstream it bumps: the one
1332
                // the consumer pins (someone who may write it committed the
1333
                // pin), and no other (#150).
1334
                let diffused = run.actor == crate::diffusion::ACCOUNT
1335
                    && run.bookmark.starts_with("diffusion/")
1336
                    && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ? AND upstream_repo_id = ?")
1337
                        .bind(run.repo_id)
1338
                        .bind(other.id)
1339
                        .fetch_one(&app.db)
1340
                        .await
1341
                        .unwrap_or(0)
1342
                        > 0;
1343
                if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) {
1344
                    inputs.push(flow_core::api::SubrepoInput {
1345
                        path: path.clone(),
1346
                        repo: format!("{}/{}", other.owner, other.name),
1347
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
1348
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
1349
                    });
1350
                    if !ids.contains(&other.id) {
1351
                        ids.push(other.id);
1352
                    }
1353
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
1354
                } else {
1355
                    left_out.push(format!(
1356
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
1357
                        other.owner, other.name
1358
                    ));
1359
                }
1360
                break;
1361
            }
1362
            if !hosted {
1363
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
1364
            }
1365
        }
1366
    }
1367
    (inputs, ids, left_out)
1368
}
1369
1370
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
1371
    match fdb::job(&app.db, id).await? {
1372
        Some(j) if j.status == "running" => Ok(j),
1373
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
1374
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
1375
    }
1376
}
1377
1378
/// `POST /api/v1/flow/jobs/{id}/log`.
1379
pub async fn log(
1380
    State(app): State<Shared>,
1381
    headers: HeaderMap,
1382
    Path(id): Path<i64>,
1383
    Json(mut batch): Json<LogBatch>,
1384
) -> Result<StatusCode, ApiError> {
1385
    runner_auth(&app, &headers).await?;
1386
    let job = running_job(&app, id).await?;
1387
    // An empty batch asks only whether the job still runs: a cancelled
1388
    // one answers 409 and its runner stops it (oxygenflow#32).
1389
    if batch.records.is_empty() {
1390
        return Ok(StatusCode::NO_CONTENT);
1391
    }
1392
    // A secret's value never reaches the stored log (#107).
1393
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
1394
        Ok(spec) if !spec.secrets.is_empty() => {
1395
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
1396
            let row = match run {
1397
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
1398
                None => None,
1399
            };
1400
            match row {
1401
                Some(row) => {
1402
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
1403
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
1404
                }
1405
                None => vec![],
1406
            }
1407
        }
1408
        _ => vec![],
1409
    };
1410
    let kid = app.oidc.get().map(|i| i.kid.clone());
1411
    for r in &mut batch.records {
1412
        if !values.is_empty() {
1413
            r.text = crate::secrets::mask(&r.text, &values);
1414
        }
1415
        if let Some(kid) = &kid {
1416
            r.text = crate::oidc::mask(&r.text, kid);
1417
        }
1418
        if r.text.len() > MAX_RECORD_TEXT {
1419
            let mut cut = MAX_RECORD_TEXT;
1420
            while !r.text.is_char_boundary(cut) {
1421
                cut -= 1;
1422
            }
1423
            r.text.truncate(cut);
1424
        }
1425
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
1426
            r.stream = "console".into();
1427
        }
1428
    }
1429
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
1430
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
1431
        tracing::info!("flow: job {} reached the log limit", job.id);
1432
    }
1433
    Ok(StatusCode::NO_CONTENT)
1434
}
1435
1436
/// Largest artifact the hub keeps; the runner refuses larger ones first.
1437
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
1438
1439
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
1440
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
1441
/// repository. Answers `{"blob"}`.
1442
pub async fn artifact(
1443
    State(app): State<Shared>,
1444
    headers: HeaderMap,
1445
    Path((id, name)): Path<(i64, String)>,
1446
    body: axum::body::Bytes,
1447
) -> Result<Response, ApiError> {
1448
    runner_auth(&app, &headers).await?;
1449
    let job = running_job(&app, id).await?;
1450
    let spec: flow_core::Job =
1451
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1452
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
1453
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
1454
    };
1455
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1456
    let row =
1457
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1458
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1459
    // A report's artifact is its manifest (#127): checked, and its files
1460
    // found in the store, before it is kept.
1461
    let report = match out.kind {
1462
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
1463
        flow_core::OutputKind::Artifact => None,
1464
    };
1465
    let content = blake3::hash(&body).to_hex().to_string();
1466
    let size = body.len() as i64;
1467
    let artifact = report.is_none();
1468
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
1469
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
1470
    let blob = app
1471
        .repos
1472
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1473
        .await
1474
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
1475
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
1476
    fdb::add_artifact(&app.db, &a).await?;
1477
    if let Some((manifest, declaration)) = report {
1478
        let origin = crate::reports::Origin {
1479
            repo_id: row.id,
1480
            run_id: run.id,
1481
            run_number: run.number,
1482
            job_id: job.id,
1483
            job: &job.name,
1484
            bookmark: &run.bookmark,
1485
            state: &run.state,
1486
        };
1487
        let said = match declaration {
1488
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
1489
                Ok(id) => {
1490
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
1491
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
1492
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
1493
                }
1494
                Err(e) => format!("report {}: not indexed: {e}", a.name),
1495
            },
1496
            Err(why) => {
1497
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
1498
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
1499
                format!("report {}: report.toml: {why}", a.name)
1500
            }
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 coverage report (#113) is read now; what it says, or why it could
1506
    // not be read, goes in the job's log. It never fails the upload.
1507
    if let Some(bytes) = report_bytes {
1508
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
1509
            Ok(text) | Err(text) => text,
1510
        };
1511
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1512
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1513
    }
1514
    // A test report (#114) likewise.
1515
    if let Some(bytes) = junit_bytes {
1516
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
1517
            Ok(text) | Err(text) => text,
1518
        };
1519
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1520
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1521
    }
1522
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1523
}
1524
1525
/// A check per metric with a threshold (#127, docs/reports.md §5), named
1526
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
1527
/// `fail_above` or `fail_below`, which a proposal may require.
1528
async fn report_threshold_checks(
1529
    app: &Shared,
1530
    row: &db::repos::RepoRow,
1531
    run: &fdb::Run,
1532
    name: &str,
1533
    id: i64,
1534
    metrics: &[crate::reports::Metric],
1535
) {
1536
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
1537
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
1538
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
1539
        let above = m.fail_above.filter(|a| m.value > *a);
1540
        let below = m.fail_below.filter(|b| m.value < *b);
1541
        let (status, summary) = match (above, below) {
1542
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
1543
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
1544
            _ => {
1545
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
1546
                    .into_iter()
1547
                    .flatten()
1548
                    .collect();
1549
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
1550
            }
1551
        };
1552
        let check = format!("report/{name}/{}", m.key);
1553
        if let Ok(c) = db::hooks::upsert_check(
1554
            &app.db,
1555
            row.id,
1556
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
1557
        )
1558
        .await
1559
        {
1560
            let api = crate::api::check_of(&c);
1561
            app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1562
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1563
        }
1564
    }
1565
}
1566
1567
/// Check report manifest `body` (#127) and that the repository holds every
1568
/// file it names; answers it with the report's declaration, or why that
1569
/// could not be read.
1570
async fn report_manifest(
1571
    app: &Shared,
1572
    repo: &RepoName,
1573
    name: &str,
1574
    body: &[u8],
1575
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
1576
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
1577
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
1578
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
1579
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
1580
    let found = app
1581
        .repos
1582
        .read(repo, move |r, _| {
1583
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
1584
                return Ok(Err(path.clone()));
1585
            }
1586
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
1587
        })
1588
        .await
1589
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
1590
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
1591
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
1592
    Ok((manifest, declaration))
1593
}
1594
1595
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
1596
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
1597
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
1598
/// the files follows as the report's artifact.
1599
pub async fn report_file(
1600
    State(app): State<Shared>,
1601
    headers: HeaderMap,
1602
    Path((id, name)): Path<(i64, String)>,
1603
    body: axum::body::Bytes,
1604
) -> Result<Response, ApiError> {
1605
    runner_auth(&app, &headers).await?;
1606
    let job = running_job(&app, id).await?;
1607
    let spec: flow_core::Job =
1608
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1609
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
1610
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
1611
    }
1612
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
1613
    if !crate::reports::path_ok(path) {
1614
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
1615
    }
1616
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1617
    let row =
1618
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1619
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1620
    let blob = app
1621
        .repos
1622
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1623
        .await
1624
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
1625
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
1626
}
1627
1628
/// Parse and keep a job's JUnit report: what it says, or why not.
1629
async fn read_junit(
1630
    app: &Shared,
1631
    row: &db::repos::RepoRow,
1632
    run: &fdb::Run,
1633
    job: &fdb::Job,
1634
    blob: &str,
1635
    bytes: axum::body::Bytes,
1636
) -> Result<String, String> {
1637
    if bytes.len() > crate::junit::MAX_REPORT {
1638
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
1639
    }
1640
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
1641
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
1642
        .await
1643
        .map_err(|e| format!("tests: {e}"))?
1644
        .map_err(|e| format!("tests: {e}"))?;
1645
    let origin = crate::junit::Origin {
1646
        repo_id: row.id,
1647
        run_id: run.id,
1648
        run_number: run.number,
1649
        job_id: job.id,
1650
        job: &job.name,
1651
        bookmark: &run.bookmark,
1652
        state: &run.state,
1653
        blob,
1654
    };
1655
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
1656
    let r = crate::junit::for_run(&app.db, run.id)
1657
        .await
1658
        .map_err(|e| format!("tests: {e}"))?
1659
        .into_iter()
1660
        .find(|r| r.job_id == job.id)
1661
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
1662
    // The `tests` check on the run's state: what fails, and which of those
1663
    // passed in the same job's latest report on the default bookmark, so a
1664
    // proposal names what it broke.
1665
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
1666
    let newly = match &base {
1667
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
1668
        _ => vec![],
1669
    };
1670
    let (status, summary) = if r.broken() == 0 {
1671
        ("passed", format!("{} passed", r.passed))
1672
    } else if let Some(b) = &base {
1673
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
1674
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
1675
        let new = if newly.is_empty() {
1676
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
1677
        } else {
1678
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
1679
        };
1680
        ("failed", format!("{} failing; {new}", r.broken()))
1681
    } else {
1682
        ("failed", format!("{} failing", r.broken()))
1683
    };
1684
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
1685
    if let Ok(c) = db::hooks::upsert_check(
1686
        &app.db,
1687
        row.id,
1688
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
1689
    )
1690
    .await
1691
    {
1692
        let api = crate::api::check_of(&c);
1693
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1694
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1695
    }
1696
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
1697
}
1698
1699
/// Parse and keep a job's coverage report: what it says, or why not.
1700
async fn read_coverage(
1701
    app: &Shared,
1702
    repo: &RepoName,
1703
    row: &db::repos::RepoRow,
1704
    run: &fdb::Run,
1705
    job: &fdb::Job,
1706
    blob: &str,
1707
    bytes: axum::body::Bytes,
1708
) -> Result<String, String> {
1709
    if bytes.len() > crate::coverage::MAX_REPORT {
1710
        return Err(format!(
1711
            "coverage: the report is {} MiB; at most {} MiB is read",
1712
            bytes.len() >> 20,
1713
            crate::coverage::MAX_REPORT >> 20
1714
        ));
1715
    }
1716
    let text = String::from_utf8(bytes.to_vec())
1717
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
1718
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
1719
        .await
1720
        .map_err(|e| format!("coverage: {e}"))?
1721
        .map_err(|e| format!("coverage: {e}"))?;
1722
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
1723
    let in_state: std::collections::HashSet<String> = app
1724
        .repos
1725
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
1726
        .await
1727
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
1728
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
1729
    let origin = crate::coverage::Origin {
1730
        repo_id: row.id,
1731
        run_id: Some(run.id),
1732
        run_number: run.number,
1733
        job_id: Some(job.id),
1734
        uploader: "",
1735
        job: &job.name,
1736
        bookmark: &run.bookmark,
1737
        state: &run.state,
1738
        heads: &heads,
1739
        blob,
1740
        format,
1741
    };
1742
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
1743
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
1744
        return Err("coverage: the report was not kept".into());
1745
    };
1746
    // The `coverage` check on the run's state (#113): against the default
1747
    // bookmark, under the repository's rules.
1748
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
1749
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
1750
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
1751
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
1752
    if let Ok(c) = db::hooks::upsert_check(
1753
        &app.db,
1754
        row.id,
1755
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
1756
    )
1757
    .await
1758
    {
1759
        let api = crate::api::check_of(&c);
1760
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1761
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1762
    }
1763
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
1764
    let dropped =
1765
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
1766
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
1767
}
1768
1769
/// `POST /api/v1/flow/jobs/{id}/finish`.
1770
pub async fn finish(
1771
    State(app): State<Shared>,
1772
    headers: HeaderMap,
1773
    Path(id): Path<i64>,
1774
    Json(f): Json<Finish>,
1775
) -> Result<StatusCode, ApiError> {
1776
    let me = runner_auth(&app, &headers).await?;
1777
    let job = running_job(&app, id).await?;
1778
    let status = match (f.exit_code, &f.error) {
1779
        (Some(0), None) => "passed",
1780
        (Some(_), _) => "failed",
1781
        (None, _) => "error",
1782
    };
1783
    let (receipt, receipt_hash) = match &f.receipt {
1784
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
1785
            // A passing job's receipt names exactly the artifacts uploaded,
1786
            // in name order; a failing one's names none, though it may have
1787
            // kept `when "always"` artifacts (oxygenflow#37).
1788
            let kept: Vec<String> =
1789
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
1790
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
1791
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
1792
            }
1793
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
1794
        }
1795
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
1796
        None => (String::new(), String::new()),
1797
    };
1798
    let error = f.error.clone().unwrap_or_default();
1799
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
1800
    let changed = fdb::finish(
1801
        &app.db,
1802
        job.id,
1803
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
1804
    )
1805
    .await?;
1806
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1807
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1808
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
1809
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
1810
                    Ok((attest, signer)) => (attest, signer, String::new()),
1811
                    Err(e) => {
1812
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
1813
                        (String::new(), String::new(), e)
1814
                    }
1815
                };
1816
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
1817
            }
1818
        }
1819
    }
1820
    report_changed(&app, job.run_id, changed).await?;
1821
    Ok(StatusCode::NO_CONTENT)
1822
}
1823
1824
/// Report the checks of jobs whose status changed.
1825
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
1826
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
1827
        return Ok(());
1828
    };
1829
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
1830
        return Ok(());
1831
    };
1832
    let mut errored_on: Vec<i64> = Vec::new();
1833
    for j in changed {
1834
        let summary = match j.status.as_str() {
1835
            "passed" => format!("passed in {}", duration(&j)),
1836
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
1837
            "skipped" => "skipped: a dependency did not pass".into(),
1838
            "cancelled" => j.error.clone(),
1839
            _ => format!("could not run: {}", j.error),
1840
        };
1841
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
1842
        if j.status == "error" {
1843
            if let Some(r) = runner_of(app, j.id).await {
1844
                errored_on.push(r);
1845
            }
1846
        }
1847
    }
1848
    errored_on.dedup();
1849
    for r in errored_on {
1850
        drain_if_erroring(app, r).await;
1851
    }
1852
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
1853
        if done.status == "failed" && !done.live() {
1854
            tell_failed(app, &row, &done).await;
1855
        }
1856
    }
1857
    // A passing run of an upstream's default bookmark: consumers following
1858
    // it when green are bumped (Diffusion, #148).
1859
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
1860
        if done.status == "passed" && done.bookmark == row.default_bookmark {
1861
            let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
1862
            let (app, row) = (app.clone(), row.clone());
1863
            tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await });
1864
        }
1865
        // A bump's run passed: a consumer that lands its bumps itself may
1866
        // land it now (docs/diffusion.md §4.2).
1867
        if let Some(path) = done.bookmark.strip_prefix("diffusion/").filter(|_| done.status == "passed") {
1868
            tokio::spawn(crate::diffusion::try_land(app.clone(), row.clone(), path.to_string()));
1869
        }
1870
    }
1871
    Ok(())
1872
}
1873
1874
/// The account OxygenFlow's notices come from, made on first use with no
1875
/// way to sign in.
1876
pub const ACCOUNT: &str = "oxygenflow";
1877
1878
async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> {
1879
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
1880
        return Some(a);
1881
    }
1882
    db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok()
1883
}
1884
1885
async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> {
1886
    sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = ?").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten()
1887
}
1888
1889
/// A failed run (oxygenflow#30): its pusher and the repository's watchers
1890
/// are told, in their inboxes and by email as they chose, naming the jobs
1891
/// that did not pass.
1892
async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
1893
    let Some(me) = flow_account(&app.db).await else { return };
1894
    let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default();
1895
    let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect();
1896
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1897
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
1898
    crate::notify::deliver(
1899
        app,
1900
        crate::notify::Event {
1901
            kind: "flow.failed",
1902
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
1903
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
1904
            actor: &me,
1905
            repo_id: row.id,
1906
            thread: None,
1907
            mention_text: "",
1908
            direct: pusher.into_iter().map(|a| a.id).collect(),
1909
        },
1910
    )
1911
    .await;
1912
}
1913
1914
/// A runner whose last jobs all ended in infrastructure errors is drained,
1915
/// so it stops taking jobs it fails before running them, and the
1916
/// administrators are told (oxygenflow#30).
1917
async fn drain_if_erroring(app: &Shared, runner_id: i64) {
1918
    let n = app.config.flow.drain_after_errors;
1919
    let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return };
1920
    let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return };
1921
    if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() {
1922
        return;
1923
    }
1924
    tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name);
1925
    let Some(me) = flow_account(&app.db).await else { return };
1926
    let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = 1").fetch_all(&app.db).await.unwrap_or_default();
1927
    crate::notify::deliver(
1928
        app,
1929
        crate::notify::Event {
1930
            kind: "flow.runner_drained",
1931
            title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name),
1932
            url: format!("/admin/runners/{runner_id}"),
1933
            actor: &me,
1934
            repo_id: 0,
1935
            thread: None,
1936
            mention_text: "",
1937
            direct: admins,
1938
        },
1939
    )
1940
    .await;
1941
}
1942
1943
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
1944
/// Settings, else from `hub.toml`.
1945
pub struct Limits {
1946
    /// For a job whose pipeline names none.
1947
    pub default_s: u64,
1948
    /// The most a pipeline may ask for.
1949
    pub max_s: u64,
1950
    /// Grace past a job's limit before the hub gives it up: a runner stops
1951
    /// a job at its limit and reports it within moments, so one still
1952
    /// running this long after is one the runner no longer reports (it was
1953
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
1954
    pub grace_ms: i64,
1955
}
1956
1957
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
1958
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
1959
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
1960
1961
pub async fn limits(app: &Shared) -> Limits {
1962
    let minutes = |key: &'static str| async move {
1963
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
1964
    };
1965
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
1966
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
1967
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
1968
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
1969
}
1970
1971
/// End every job still running past its time limit plus the grace:
1972
/// it is marked an error with the reason, the jobs that depended on it are
1973
/// skipped and its run settled, as when a runner reports an error.
1974
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
1975
    let limits = limits(app).await;
1976
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
1977
    let n = lost.len();
1978
    for job in lost {
1979
        let error = format!(
1980
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
1981
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
1982
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
1983
        );
1984
        let changed =
1985
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1986
                .await?;
1987
        tracing::warn!("flow: job {} {error}", job.id);
1988
        report_changed(app, job.run_id, changed).await?;
1989
    }
1990
    Ok(n)
1991
}
1992
1993
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
1994
/// it restarted since claiming them, or never received the claim. A
1995
/// runner claims one job at a time, so a job it holds is always listed.
1996
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
1997
    for job in fdb::running_on(&app.db, runner.id).await? {
1998
        if holding.contains(&job.id) {
1999
            continue;
2000
        }
2001
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
2002
        let changed =
2003
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2004
                .await?;
2005
        tracing::warn!("flow: job {} {error}", job.id);
2006
        report_changed(app, job.run_id, changed).await?;
2007
    }
2008
    Ok(())
2009
}
2010
2011
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
2012
/// repository that runs Flow, the jobs of its default bookmark's pipeline
2013
/// with a `schedule` line due then. Returns how many runs started.
2014
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
2015
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
2016
    let mut started = 0;
2017
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
2018
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
2019
        let b = row.default_bookmark.clone();
2020
        let found = app
2021
            .repos
2022
            .read(&repo, move |r, _| {
2023
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
2024
                Ok(Some((st, r.store().get_state(&st)?.heads)))
2025
            })
2026
            .await;
2027
        let Ok(Some((state, heads))) = found else { continue };
2028
        if heads.is_empty() {
2029
            continue;
2030
        }
2031
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
2032
            Ok(Some(_)) => started += 1,
2033
            Ok(None) => {}
2034
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
2035
        }
2036
    }
2037
    started
2038
}
2039
2040
/// The hub's clock as a [`When`](flow_core::pipeline::When).
2041
pub fn when_now() -> flow_core::pipeline::When {
2042
    let now = time::OffsetDateTime::now_utc();
2043
    flow_core::pipeline::When {
2044
        minute: now.minute() as u32,
2045
        hour: now.hour() as u32,
2046
        day: now.day() as u32,
2047
        month: u8::from(now.month()) as u32,
2048
        weekday: now.weekday().number_days_from_sunday() as u32,
2049
    }
2050
}
2051
2052
/// Start the scheduled runs due each minute, once a minute.
2053
pub async fn scheduler(app: Shared) {
2054
    let mut last = None;
2055
    loop {
2056
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
2057
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
2058
        if last == Some(minute) {
2059
            continue;
2060
        }
2061
        last = Some(minute);
2062
        let n = run_due(&app, when_now()).await;
2063
        if n > 0 {
2064
            tracing::info!("flow: {n} scheduled run(s) started");
2065
        }
2066
    }
2067
}
2068
2069
/// Look for lost jobs once a minute.
2070
pub async fn reaper(app: Shared) {
2071
    loop {
2072
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2073
        if let Err(e) = reap_lost_jobs(&app).await {
2074
            tracing::warn!("flow: looking for lost jobs: {e}");
2075
        }
2076
        match expire_approvals(&app, db::now_ms()).await {
2077
            Ok(n) if n > 0 => tracing::info!("flow: {n} approvals expired"),
2078
            Ok(_) => {}
2079
            Err(e) => tracing::warn!("flow: expiring approvals: {e}"),
2080
        }
2081
    }
2082
}
2083
2084
/// Check a runner's signed receipt for a job of `run` and keep it in the
2085
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
2086
/// name, or why it was not kept. It must be the job's receipt, for the
2087
/// run's state and repository, signed by the key the runner's token first
2088
/// signed with.
2089
pub async fn keep_receipt(
2090
    app: &Shared,
2091
    runner: &db::runners::Runner,
2092
    row: &db::repos::RepoRow,
2093
    run: &fdb::Run,
2094
    receipt: &flow_core::Receipt,
2095
    signed_hex: &str,
2096
) -> Result<(String, String), String> {
2097
    use oxy_core::receipt::SignedReceipt;
2098
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
2099
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
2100
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
2101
    if sr.receipt.hash().0 != receipt.hash() {
2102
        return Err("it signs another receipt than the job's".into());
2103
    }
2104
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
2105
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
2106
    if sr.state() != state {
2107
        return Err("it is for another state than the run's".into());
2108
    }
2109
    let key = sr.runner.hash().to_hex();
2110
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
2111
    if bound != key {
2112
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
2113
    }
2114
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
2115
    let signer = sr.runner.name.clone();
2116
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
2117
    Ok((attest.to_hex(), signer))
2118
}
2119
2120
/// A job's wall time, as the pages show it.
2121
pub fn duration(j: &fdb::Job) -> String {
2122
    match (j.started_ms, j.finished_ms) {
2123
        (Some(a), Some(b)) => human_ms(b - a),
2124
        (Some(a), None) => human_ms(db::now_ms() - a),
2125
        _ => "–".into(),
2126
    }
2127
}
2128
2129
pub fn human_ms(ms: i64) -> String {
2130
    let ms = ms.max(0);
2131
    if ms < 1000 {
2132
        format!("{ms} ms")
2133
    } else if ms < 60_000 {
2134
        format!("{:.1} s", ms as f64 / 1000.0)
2135
    } else {
2136
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
2137
    }
2138
}
2139
2140
/// Where a live run stands among the runners' work: what its viewers may
2141
/// know of it. Jobs of other repositories are named only when those are
2142
/// public; the rest are counted.
2143
#[derive(Clone, Debug, Default, PartialEq, Eq)]
2144
pub struct QueueInfo {
2145
    /// 1-based place of the run's first queued job among all queued jobs.
2146
    pub position: Option<usize>,
2147
    /// Jobs running now, and queued jobs ahead of the run's first one.
2148
    pub running: Vec<String>,
2149
    pub ahead: Vec<String>,
2150
    /// Of those, jobs in repositories the page may not name.
2151
    pub hidden: usize,
2152
}
2153
2154
impl QueueInfo {
2155
    /// "3rd in line" and the like.
2156
    pub fn place(&self) -> Option<String> {
2157
        self.position.map(|n| {
2158
            let suffix = match (n % 10, n % 100) {
2159
                (1, 11) | (2, 12) | (3, 13) => "th",
2160
                (1, _) => "st",
2161
                (2, _) => "nd",
2162
                (3, _) => "rd",
2163
                _ => "th",
2164
            };
2165
            format!("{n}{suffix} in line")
2166
        })
2167
    }
2168
}
2169
2170
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
2171
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
2172
    let mut info = QueueInfo::default();
2173
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
2174
    info.position = first.map(|i| i + 1);
2175
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
2176
    let mut queued_seen = 0;
2177
    for e in queue {
2178
        if e.run_id == run_id {
2179
            continue;
2180
        }
2181
        let ahead = match e.status.as_str() {
2182
            "running" => true,
2183
            _ => {
2184
                queued_seen += 1;
2185
                first.is_some_and(|f| queued_seen <= f)
2186
            }
2187
        };
2188
        if !ahead {
2189
            continue;
2190
        }
2191
        if e.repo_id != repo_id && e.visibility != "public" {
2192
            info.hidden += 1;
2193
        } else if e.status == "running" {
2194
            info.running.push(label(e));
2195
        } else {
2196
            info.ahead.push(label(e));
2197
        }
2198
    }
2199
    info
2200
}
2201
2202
#[cfg(test)]
2203
mod queue_tests {
2204
    use super::*;
2205
2206
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
2207
        fdb::QueueEntry {
2208
            job_id,
2209
            run_id,
2210
            repo_id,
2211
            owner: "o".into(),
2212
            name: format!("r{repo_id}"),
2213
            visibility: vis.into(),
2214
            run_number: run_id,
2215
            job: job.into(),
2216
            status: status.into(),
2217
            runner: String::new(),
2218
            spec: String::new(),
2219
            sandbox: String::new(),
2220
        }
2221
    }
2222
2223
    #[test]
2224
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
2225
        // Running first, then queued in claim order (as `fdb::queue` returns them).
2226
        let q = vec![
2227
            e(1, 10, 1, "public", "build", "running"),
2228
            e(2, 11, 2, "private", "test", "running"),
2229
            e(3, 10, 1, "public", "test", "queued"),
2230
            e(4, 12, 3, "private", "lint", "queued"),
2231
            e(5, 13, 4, "public", "build", "queued"),
2232
            e(6, 13, 4, "public", "test", "queued"),
2233
            e(7, 14, 5, "public", "late", "queued"),
2234
        ];
2235
        let info = queue_info(&q, 4, 13);
2236
        assert_eq!(info.position, Some(3));
2237
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
2238
        assert_eq!(info.running, ["o/r1 #10 build"]);
2239
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
2240
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
2241
        // A private repository's own jobs are named to its viewers.
2242
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
2243
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
2244
        // A run with nothing queued has no place.
2245
        assert_eq!(queue_info(&q, 1, 99).position, None);
2246
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
2247
        assert_eq!(
2248
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
2249
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
2250
        );
2251
    }
2252
}
2253
2254
#[cfg(test)]
2255
mod placement_tests {
2256
    use super::*;
2257
2258
    #[test]
2259
    fn a_waiting_job_says_what_each_runner_lacks() {
2260
        use crate::db::runners::Runner;
2261
        let rootfs = "ab".repeat(32);
2262
        let job = fdb::Job {
2263
            id: 7,
2264
            runtime: "syscall".into(),
2265
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
2266
            spec: serde_json::json!({ "name": "build" }).to_string(),
2267
            ..Default::default()
2268
        };
2269
        let now = db::now_ms();
2270
        let runner = |id: i64, name: &str| Runner {
2271
            id,
2272
            name: name.into(),
2273
            state: "active".into(),
2274
            last_seen_ms: Some(now),
2275
            arch: "x86_64".into(),
2276
            tiers: "microvm,syscall".into(),
2277
            images: serde_json::to_string(&[&rootfs]).unwrap(),
2278
            memory_mib: Some(16384),
2279
            cpus: Some(8),
2280
            ..Default::default()
2281
        };
2282
        let archs = std::collections::HashMap::new();
2283
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
2284
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
2285
        };
2286
        assert_eq!(why(&[], &[]), "no runner is online");
2287
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
2288
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
2289
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
2290
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
2291
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
2292
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
2293
        assert_eq!(
2294
            why(&[microvm_only, small, other, offline], &[]),
2295
            "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"
2296
        );
2297
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
2298
        assert_eq!(
2299
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
2300
            "no runner can take it: narvi is riscv64; the job needs x86_64"
2301
        );
2302
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
2303
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
2304
        let d = fdb::DeclineRow {
2305
            job_id: 7,
2306
            runner_id: 1,
2307
            runner: "earth-1".into(),
2308
            reason: "only 512 MiB of memory is free here".into(),
2309
            ms: now,
2310
        };
2311
        assert_eq!(
2312
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
2313
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
2314
        );
2315
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
2316
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
2317
    }
2318
}