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