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