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