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