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