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