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