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