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