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