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