OXYGENHUB
# d0fbea2eb46e oxygenhub / crates / hub / src / flow.rs
49.2 KiB · Rust · last changed A job's identity: the hub is an OIDC issuer 1 month ago source blame history raw
1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        "on" => true,
27
        "off" => false,
28
        _ => app.config.flow.allows(&row.owner, &row.name),
29
    }
30
}
31
32
/// A bookmark moved by a push: start a run when the repository is allowed
33
/// and the new state has a pipeline.
34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
35
    if heads.is_empty() || !enabled(app, row) {
36
        return;
37
    }
38
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
39
    let started = match bookmark.strip_prefix("tags/") {
40
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
41
        None => {
42
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
43
            let started = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await;
44
            if let Ok(Some(n)) = started {
45
                supersede(app, row, bookmark, n).await;
46
            }
47
            started
48
        }
49
    };
50
    if let Err(e) = started {
51
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
52
    }
53
}
54
55
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
56
/// moving it. Returns the new run's number.
57
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
58
    if !enabled(app, row) {
59
        return Err("OxygenFlow is not enabled for this repository".into());
60
    }
61
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
62
    let b = bookmark.to_string();
63
    let found = app
64
        .repos
65
        .read(&repo, move |r, _| {
66
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
67
            Ok(Some((st, r.store().get_state(&st)?.heads)))
68
        })
69
        .await
70
        .map_err(|e| e.to_string())?;
71
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
72
    if heads.is_empty() {
73
        return Err(format!("{bookmark} has no changes yet"));
74
    }
75
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
76
        .await?
77
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
78
}
79
80
/// "Run pipeline" on one committed change: the state whose only head is
81
/// `change`, whatever bookmarks hold it. Returns the new run's number.
82
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
83
    if !enabled(app, row) {
84
        return Err("OxygenFlow is not enabled for this repository".into());
85
    }
86
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
87
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
88
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
89
    if !known {
90
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
91
    }
92
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
93
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
94
        .await?
95
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
96
}
97
98
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
99
/// run whose checks replace the old ones. Returns the new run's number.
100
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
101
    if !enabled(app, row) {
102
        return Err("OxygenFlow is not enabled for this repository".into());
103
    }
104
    if run.live() {
105
        return Err(format!("run #{} is still {}", run.number, run.status));
106
    }
107
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
108
    if heads.is_empty() {
109
        return Err("the run names no changes".into());
110
    }
111
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
112
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
113
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
114
        .await?
115
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
116
}
117
118
/// Cancel a run still going (oxygenflow#32), its checks with it.
119
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
120
    if !run.live() {
121
        return Err(format!("run #{} is already {}", run.number, run.status));
122
    }
123
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
124
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
125
        "the run could not be cancelled".to_string()
126
    })
127
}
128
129
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
130
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
131
    report_changed(app, run.id, changed).await
132
}
133
134
/// A push makes the push runs still going on its bookmark moot: they are
135
/// cancelled, and the new run's scope reaches back past them.
136
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, number: i64) {
137
    let Ok(older) = fdb::live_push_runs_before(&app.db, row.id, bookmark, number).await else { return };
138
    for run in older {
139
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
140
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
141
        }
142
    }
143
}
144
145
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
146
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
147
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
148
    use flow_core::pipeline::Event;
149
    let t = match trigger.strip_prefix("rerun:") {
150
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
151
        None => trigger,
152
    };
153
    if let Some(tag) = t.strip_prefix("tag:") {
154
        Event::Tag(tag)
155
    } else if t == "push" {
156
        Event::Push(bookmark)
157
    } else if t == "schedule" {
158
        Event::Schedule
159
    } else {
160
        Event::Manual
161
    }
162
}
163
164
/// The event to repeat when rerunning `run`.
165
fn original_event(run: &fdb::Run) -> String {
166
    let t = run.trigger.as_str();
167
    match t.strip_prefix("rerun:") {
168
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
169
        None => t.to_string(),
170
    }
171
}
172
173
/// The paths the changes a push brought touch: the changes in `heads`'
174
/// state that the bookmark's previous run did not have, and the files their
175
/// edits and tree operations name, as the new state places them. `None`
176
/// (run every job) for the bookmark's first run, or when it cannot be told.
177
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
178
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
179
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
180
    if old.is_empty() {
181
        return None;
182
    }
183
    let repo = RepoName::new(&row.owner, &row.name)?;
184
    let new = heads.to_vec();
185
    app.repos
186
        .read(&repo, move |r, cache| {
187
            let fresh = r.negotiate(&old, &new)?;
188
            let loaded = cache.load(r, &new)?;
189
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
190
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
191
            let mut paths = std::collections::BTreeSet::new();
192
            for h in fresh {
193
                let (c, _) = r.store().get_change_lazy(&h)?;
194
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
195
                for n in nodes {
196
                    if let Some(p) = by_node.get(&n) {
197
                        paths.insert((*p).clone());
198
                    }
199
                }
200
            }
201
            Ok(paths.into_iter().collect::<Vec<String>>())
202
        })
203
        .await
204
        .ok()
205
}
206
207
/// Whether a job runs for a push that touched `touched`: always without a
208
/// scope or without knowing what was touched, else when a path matches.
209
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
210
    let Some(touched) = touched else { return true };
211
    if scope.is_empty() {
212
        return true;
213
    }
214
    let mut b = globset::GlobSetBuilder::new();
215
    for g in scope {
216
        match globset::Glob::new(g) {
217
            Ok(g) => {
218
                b.add(g);
219
            }
220
            Err(_) => return true,
221
        }
222
    }
223
    let Ok(set) = b.build() else { return true };
224
    touched.iter().any(|p| set.is_match(p))
225
}
226
227
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
228
/// left out of the run, unless a job that runs depends on them.
229
#[allow(clippy::too_many_arguments)]
230
pub async fn start_run_scoped(
231
    app: &Shared,
232
    row: &db::repos::RepoRow,
233
    bookmark: &str,
234
    state: &str,
235
    heads: &[ChangeHash],
236
    actor: &str,
237
    trigger: &str,
238
    touched: Option<&[String]>,
239
) -> Result<Option<i64>, String> {
240
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
241
}
242
243
/// Start a run of the pipeline in the state `heads` make (whose state
244
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
245
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
246
/// whose access the run's subrepo reads are checked against. Returns the
247
/// run's number, or `None` when the state has no pipeline. The caller
248
/// checks that Flow is enabled.
249
pub async fn start_run(
250
    app: &Shared,
251
    row: &db::repos::RepoRow,
252
    bookmark: &str,
253
    state: &str,
254
    heads: &[ChangeHash],
255
    actor: &str,
256
    trigger: &str,
257
) -> Result<Option<i64>, String> {
258
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
259
}
260
261
#[allow(clippy::too_many_arguments)]
262
async fn start_run_with(
263
    app: &Shared,
264
    row: &db::repos::RepoRow,
265
    bookmark: &str,
266
    state: &str,
267
    heads: &[ChangeHash],
268
    actor: &str,
269
    trigger: &str,
270
    touched: Option<&[String]>,
271
) -> Result<Option<i64>, String> {
272
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None).await
273
}
274
275
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock.
276
#[allow(clippy::too_many_arguments)]
277
async fn start_run_at(
278
    app: &Shared,
279
    row: &db::repos::RepoRow,
280
    bookmark: &str,
281
    state: &str,
282
    heads: &[ChangeHash],
283
    actor: &str,
284
    trigger: &str,
285
    touched: Option<&[String]>,
286
    at: Option<flow_core::pipeline::When>,
287
) -> Result<Option<i64>, String> {
288
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
289
    let h = heads.to_vec();
290
    let text = app
291
        .repos
292
        .read(&repo, move |r, cache| {
293
            let loaded = cache.load(r, &h)?;
294
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
295
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
296
                _ => None,
297
            })
298
        })
299
        .await
300
        .map_err(|e| format!("reading the pipeline: {e}"))?;
301
    let Some(text) = text else { return Ok(None) };
302
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
303
    hex.sort();
304
    let heads_s = hex.join("+");
305
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
306
    let id = match Pipeline::parse(&text) {
307
        Err(problems) => {
308
            let msg = problems.to_string();
309
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
310
        }
311
        Ok(p) => {
312
            let depths = p.depths();
313
            // Jobs out of the push's scope stay out, unless a kept job
314
            // needs them.
315
            // The jobs this run's event starts (`on`), out of those its
316
            // push touched (`scope`); then what they depend on.
317
            let event = match at {
318
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
319
                None => event_of(trigger, bookmark),
320
            };
321
            let mut keep: std::collections::HashSet<String> = p
322
                .order()
323
                .into_iter()
324
                .filter(|j| p.on_of(j).matches(event) && in_scope(&j.scope, touched))
325
                .map(|j| j.name.clone())
326
                .collect();
327
            loop {
328
                let needed: Vec<String> = p
329
                    .order()
330
                    .into_iter()
331
                    .filter(|j| keep.contains(&j.name))
332
                    .flat_map(|j| j.depends_on.iter().cloned())
333
                    .filter(|d| !keep.contains(d))
334
                    .collect();
335
                if needed.is_empty() {
336
                    break;
337
                }
338
                keep.extend(needed);
339
            }
340
            let jobs: Vec<fdb::NewJob> = p
341
                .order()
342
                .into_iter()
343
                .filter(|j| keep.contains(&j.name))
344
                .map(|j| {
345
                    let sb = &p.sandboxes[&j.sandbox];
346
                    fdb::NewJob {
347
                        name: j.name.clone(),
348
                        depth: depths[j.name.as_str()] as i64,
349
                        depends: j.depends_on.clone(),
350
                        spec: serde_json::to_string(j).unwrap_or_default(),
351
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
352
                        runtime: sb.runtime.as_str().into(),
353
                    }
354
                })
355
                .collect();
356
            if jobs.is_empty() {
357
                // Nothing this event starts (or this push touched).
358
                return Ok(None);
359
            }
360
            let hash = hex::encode(p.hash());
361
            let id =
362
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
363
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
364
                for j in &jobs {
365
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
366
                }
367
            }
368
            id
369
        }
370
    };
371
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
372
    Ok(Some(run.number))
373
}
374
375
/// Record a job's check; it links to the run's page by the run's number.
376
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
377
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
378
    let name = format!("flow/{job}");
379
    let c = db::hooks::upsert_check(
380
        &app.db,
381
        row.id,
382
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
383
    )
384
    .await;
385
    if let Ok(c) = c {
386
        let api = crate::api::check_of(&c);
387
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
388
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
389
    }
390
}
391
392
/// The check status a job's status shows as.
393
fn check_status(job_status: &str) -> &'static str {
394
    match job_status {
395
        "queued" => "queued",
396
        "running" => "running",
397
        "passed" => "passed",
398
        "skipped" => "skipped",
399
        "cancelled" => "cancelled",
400
        _ => "failed",
401
    }
402
}
403
404
/// The runner a bearer token belongs to: a registered runner, or one
405
/// configured with a hub.toml token (tracked from its first request). A
406
/// disabled runner is refused.
407
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
408
    let Some(t) = oxy_server::bearer_token(headers) else {
409
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
410
    };
411
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
412
    let runner = match db::runners::by_token(&app.db, &hash).await? {
413
        Some(r) => r,
414
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
415
            db::runners::configured(&app.db, &hash, "runner").await?
416
        }
417
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
418
    };
419
    if runner.state == "disabled" {
420
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
421
    }
422
    Ok(runner)
423
}
424
425
/// `POST /api/v1/flow/runners/register`: a one-time registration token
426
/// from Admin → Runners buys a runner credential, binding the runner's
427
/// signing identity (hub spec §13.2).
428
pub async fn register(
429
    State(app): State<Shared>,
430
    Json(req): Json<flow_core::api::Register>,
431
) -> Result<Json<flow_core::api::Registered>, ApiError> {
432
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
433
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
434
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
435
    if name.is_empty() {
436
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
437
    }
438
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
439
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
440
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
441
    let runner =
442
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
443
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
444
        })?;
445
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
446
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
447
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
448
}
449
450
/// `POST /api/v1/flow/claim`.
451
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
452
    let me = runner_auth(&app, &headers).await?;
453
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
454
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
455
    if let Some(holding) = &req.holding {
456
        end_jobs_not_held(&app, &me, holding).await?;
457
    }
458
    // A draining runner finishes what it has and takes nothing new.
459
    if me.state == "draining" {
460
        return Ok(StatusCode::NO_CONTENT.into_response());
461
    }
462
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
463
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
464
    let room = fdb::Room {
465
        memory_mib: req.free_memory_mib,
466
        cpus: req.free_cpus,
467
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
468
    };
469
    // The labels it reports now, or those it registered with.
470
    let labels: Vec<String> = match &req.info {
471
        Some(i) => i.labels.clone(),
472
        None => me.label_list().into_iter().map(String::from).collect(),
473
    };
474
    let Some(job) =
475
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
476
    else {
477
        return Ok(StatusCode::NO_CONTENT.into_response());
478
    };
479
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
480
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
481
    let row =
482
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
483
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
484
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
485
    // The job's limit: its own timeout, else its sandbox's, else the site
486
    // default, never past the site maximum; recorded for the reaper.
487
    let limits = limits(&app).await;
488
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
489
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
490
    let timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
491
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
492
    let claim = Claim {
493
        job_id: job.id,
494
        run_id: run.id,
495
        repo: format!("{}/{}", row.owner, row.name),
496
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
497
        token: secret,
498
        heads: run.head_list().into_iter().map(String::from).collect(),
499
        pipeline_hash: run.pipeline_hash.clone(),
500
        job: spec,
501
        sandbox,
502
        timeout_s,
503
        subrepos: vec![],
504
        state: run.state.clone(),
505
        bookmark: run.bookmark.clone(),
506
        run_number: run.number,
507
        // The site's, the owner's and the repository's variables (#102).
508
        env: crate::variables::env_for_repo(&app.db, &row).await?,
509
        secrets: vec![],
510
    };
511
    let mut claim = claim;
512
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
513
    // The secrets the job declares, as files (#107); one not set is said
514
    // at the top of the log and left out, so the job fails on the file.
515
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
516
    let found = crate::secrets::for_job(&app, &row, &names).await?;
517
    // The identities it asks for (#103): a token each, as a file.
518
    if !claim.job.identities.is_empty() {
519
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
520
        for i in &claim.job.identities {
521
            let token = issuer.mint(&crate::oidc::JobClaims {
522
                issuer: app.config.base_url(),
523
                audience: &i.audience,
524
                owner: &row.owner,
525
                name: &row.name,
526
                visibility: &row.visibility,
527
                bookmark: &run.bookmark,
528
                run_number: run.number,
529
                job: &job.name,
530
                state: &run.state,
531
                runner: &runner,
532
                actor: &run.actor,
533
                now_ms: db::now_ms(),
534
                ttl_s: timeout_s,
535
            });
536
            claim.secrets.push(flow_core::api::SecretInput {
537
                name: format!("identity:{}", i.audience),
538
                path: i.mount_path.clone(),
539
                value: token,
540
            });
541
        }
542
    }
543
    for s in &claim.job.secrets {
544
        match found.iter().find(|(n, _)| *n == s.name) {
545
            Some((name, value)) => {
546
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
547
            }
548
            None => left_out.push(format!(
549
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
550
                s.name, s.mount_path
551
            )),
552
        }
553
    }
554
    if !ids.is_empty() {
555
        fdb::set_also_read(&app.db, job.id, &ids).await?;
556
    }
557
    // Say at the top of the job's log what it will not have (oxygenhub#56),
558
    // rather than let it fail later on missing files.
559
    if !left_out.is_empty() {
560
        let records: Vec<flow_core::api::Record> =
561
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
562
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
563
    }
564
    claim.subrepos = inputs;
565
    Ok(Json(claim).into_response())
566
}
567
568
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
569
async fn pins_in(
570
    app: &Shared,
571
    name: crate::repos::RepoName,
572
    heads: Vec<oxy_core::hash::ChangeHash>,
573
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
574
    app.repos
575
        .read(&name, move |repo, cache| {
576
            let loaded = cache.load(repo, &heads)?;
577
            let mut out = Vec::new();
578
            for (path, f) in &loaded.snapshot.files {
579
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
580
                    continue;
581
                }
582
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
583
                    if let Some(b) = blobs.first() {
584
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
585
                            .read_blob(b)
586
                            .map_err(crate::repos::RepoError::Oxy)
587
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
588
                        {
589
                            out.push((path.clone(), id, heads));
590
                        }
591
                    }
592
                }
593
            }
594
            Ok(out)
595
        })
596
        .await
597
        .unwrap_or_default()
598
}
599
600
/// How deep subrepos of subrepos are followed.
601
const MAX_SUBREPO_DEPTH: usize = 3;
602
603
/// The Oxygen subrepos the run's state pins, and those their states pin in
604
/// turn (parents first, paths joined), that live on this hub and that the
605
/// run's pusher may read: what the job's workspace needs beside its own
606
/// tree. A pin the hub cannot serve is left out, with what it pins, and
607
/// the third list says why, for the job's log.
608
async fn subrepo_inputs(
609
    app: &Shared,
610
    row: &db::repos::RepoRow,
611
    run: &fdb::Run,
612
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
613
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
614
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
615
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
616
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
617
    let mut inputs = Vec::new();
618
    let mut ids = Vec::new();
619
    let mut left_out = Vec::new();
620
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
621
    // (path prefix, repository, heads, depth), parents before children.
622
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
623
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
624
        if depth == MAX_SUBREPO_DEPTH {
625
            continue;
626
        }
627
        for (path, id, pinned) in pins_in(app, name, heads).await {
628
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
629
            let mut hosted = false;
630
            for other in &rows {
631
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
632
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
633
                if !matches {
634
                    continue;
635
                }
636
                hosted = true;
637
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
638
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
639
                    inputs.push(flow_core::api::SubrepoInput {
640
                        path: path.clone(),
641
                        repo: format!("{}/{}", other.owner, other.name),
642
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
643
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
644
                    });
645
                    if !ids.contains(&other.id) {
646
                        ids.push(other.id);
647
                    }
648
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
649
                } else {
650
                    left_out.push(format!(
651
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
652
                        other.owner, other.name
653
                    ));
654
                }
655
                break;
656
            }
657
            if !hosted {
658
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
659
            }
660
        }
661
    }
662
    (inputs, ids, left_out)
663
}
664
665
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
666
    match fdb::job(&app.db, id).await? {
667
        Some(j) if j.status == "running" => Ok(j),
668
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
669
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
670
    }
671
}
672
673
/// `POST /api/v1/flow/jobs/{id}/log`.
674
pub async fn log(
675
    State(app): State<Shared>,
676
    headers: HeaderMap,
677
    Path(id): Path<i64>,
678
    Json(mut batch): Json<LogBatch>,
679
) -> Result<StatusCode, ApiError> {
680
    runner_auth(&app, &headers).await?;
681
    let job = running_job(&app, id).await?;
682
    // An empty batch asks only whether the job still runs: a cancelled
683
    // one answers 409 and its runner stops it (oxygenflow#32).
684
    if batch.records.is_empty() {
685
        return Ok(StatusCode::NO_CONTENT);
686
    }
687
    // A secret's value never reaches the stored log (#107).
688
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
689
        Ok(spec) if !spec.secrets.is_empty() => {
690
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
691
            let row = match run {
692
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
693
                None => None,
694
            };
695
            match row {
696
                Some(row) => {
697
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
698
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
699
                }
700
                None => vec![],
701
            }
702
        }
703
        _ => vec![],
704
    };
705
    let kid = app.oidc.get().map(|i| i.kid.clone());
706
    for r in &mut batch.records {
707
        if !values.is_empty() {
708
            r.text = crate::secrets::mask(&r.text, &values);
709
        }
710
        if let Some(kid) = &kid {
711
            r.text = crate::oidc::mask(&r.text, kid);
712
        }
713
        if r.text.len() > MAX_RECORD_TEXT {
714
            let mut cut = MAX_RECORD_TEXT;
715
            while !r.text.is_char_boundary(cut) {
716
                cut -= 1;
717
            }
718
            r.text.truncate(cut);
719
        }
720
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
721
            r.stream = "console".into();
722
        }
723
    }
724
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
725
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
726
        tracing::info!("flow: job {} reached the log limit", job.id);
727
    }
728
    Ok(StatusCode::NO_CONTENT)
729
}
730
731
/// Largest artifact the hub keeps; the runner refuses larger ones first.
732
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
733
734
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
735
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
736
/// repository. Answers `{"blob"}`.
737
pub async fn artifact(
738
    State(app): State<Shared>,
739
    headers: HeaderMap,
740
    Path((id, name)): Path<(i64, String)>,
741
    body: axum::body::Bytes,
742
) -> Result<Response, ApiError> {
743
    runner_auth(&app, &headers).await?;
744
    let job = running_job(&app, id).await?;
745
    let spec: flow_core::Job =
746
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
747
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
748
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
749
    };
750
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
751
    let row =
752
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
753
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
754
    let content = blake3::hash(&body).to_hex().to_string();
755
    let size = body.len() as i64;
756
    let blob = app
757
        .repos
758
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
759
        .await
760
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
761
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
762
    fdb::add_artifact(&app.db, &a).await?;
763
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
764
}
765
766
/// `POST /api/v1/flow/jobs/{id}/finish`.
767
pub async fn finish(
768
    State(app): State<Shared>,
769
    headers: HeaderMap,
770
    Path(id): Path<i64>,
771
    Json(f): Json<Finish>,
772
) -> Result<StatusCode, ApiError> {
773
    let me = runner_auth(&app, &headers).await?;
774
    let job = running_job(&app, id).await?;
775
    let status = match (f.exit_code, &f.error) {
776
        (Some(0), None) => "passed",
777
        (Some(_), _) => "failed",
778
        (None, _) => "error",
779
    };
780
    let (receipt, receipt_hash) = match &f.receipt {
781
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
782
            // The receipt names exactly the artifacts uploaded, in name order.
783
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
784
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
785
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
786
            }
787
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
788
        }
789
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
790
        None => (String::new(), String::new()),
791
    };
792
    let error = f.error.clone().unwrap_or_default();
793
    let changed = fdb::finish(
794
        &app.db,
795
        job.id,
796
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
797
    )
798
    .await?;
799
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
800
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
801
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
802
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
803
                    Ok((attest, signer)) => (attest, signer, String::new()),
804
                    Err(e) => {
805
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
806
                        (String::new(), String::new(), e)
807
                    }
808
                };
809
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
810
            }
811
        }
812
    }
813
    report_changed(&app, job.run_id, changed).await?;
814
    Ok(StatusCode::NO_CONTENT)
815
}
816
817
/// Report the checks of jobs whose status changed.
818
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
819
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
820
        return Ok(());
821
    };
822
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
823
        return Ok(());
824
    };
825
    for j in changed {
826
        let summary = match j.status.as_str() {
827
            "passed" => format!("passed in {}", duration(&j)),
828
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
829
            "skipped" => "skipped: a dependency did not pass".into(),
830
            "cancelled" => j.error.clone(),
831
            _ => format!("could not run: {}", j.error),
832
        };
833
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
834
    }
835
    Ok(())
836
}
837
838
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
839
/// Settings, else from `hub.toml`.
840
pub struct Limits {
841
    /// For a job whose pipeline names none.
842
    pub default_s: u64,
843
    /// The most a pipeline may ask for.
844
    pub max_s: u64,
845
    /// Grace past a job's limit before the hub gives it up: a runner stops
846
    /// a job at its limit and reports it within moments, so one still
847
    /// running this long after is one the runner no longer reports (it was
848
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
849
    pub grace_ms: i64,
850
}
851
852
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
853
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
854
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
855
856
pub async fn limits(app: &Shared) -> Limits {
857
    let minutes = |key: &'static str| async move {
858
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
859
    };
860
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
861
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
862
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
863
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
864
}
865
866
/// End every job still running past its time limit plus the grace:
867
/// it is marked an error with the reason, the jobs that depended on it are
868
/// skipped and its run settled, as when a runner reports an error.
869
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
870
    let limits = limits(app).await;
871
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
872
    let n = lost.len();
873
    for job in lost {
874
        let error = format!(
875
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
876
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
877
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
878
        );
879
        let changed =
880
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
881
                .await?;
882
        tracing::warn!("flow: job {} {error}", job.id);
883
        report_changed(app, job.run_id, changed).await?;
884
    }
885
    Ok(n)
886
}
887
888
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
889
/// it restarted since claiming them, or never received the claim. A
890
/// runner claims one job at a time, so a job it holds is always listed.
891
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
892
    for job in fdb::running_on(&app.db, runner.id).await? {
893
        if holding.contains(&job.id) {
894
            continue;
895
        }
896
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
897
        let changed =
898
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
899
                .await?;
900
        tracing::warn!("flow: job {} {error}", job.id);
901
        report_changed(app, job.run_id, changed).await?;
902
    }
903
    Ok(())
904
}
905
906
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
907
/// repository that runs Flow, the jobs of its default bookmark's pipeline
908
/// with a `schedule` line due then. Returns how many runs started.
909
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
910
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
911
    let mut started = 0;
912
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
913
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
914
        let b = row.default_bookmark.clone();
915
        let found = app
916
            .repos
917
            .read(&repo, move |r, _| {
918
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
919
                Ok(Some((st, r.store().get_state(&st)?.heads)))
920
            })
921
            .await;
922
        let Ok(Some((state, heads))) = found else { continue };
923
        if heads.is_empty() {
924
            continue;
925
        }
926
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when)).await {
927
            Ok(Some(_)) => started += 1,
928
            Ok(None) => {}
929
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
930
        }
931
    }
932
    started
933
}
934
935
/// The hub's clock as a [`When`](flow_core::pipeline::When).
936
pub fn when_now() -> flow_core::pipeline::When {
937
    let now = time::OffsetDateTime::now_utc();
938
    flow_core::pipeline::When {
939
        minute: now.minute() as u32,
940
        hour: now.hour() as u32,
941
        day: now.day() as u32,
942
        month: u8::from(now.month()) as u32,
943
        weekday: now.weekday().number_days_from_sunday() as u32,
944
    }
945
}
946
947
/// Start the scheduled runs due each minute, once a minute.
948
pub async fn scheduler(app: Shared) {
949
    let mut last = None;
950
    loop {
951
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
952
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
953
        if last == Some(minute) {
954
            continue;
955
        }
956
        last = Some(minute);
957
        let n = run_due(&app, when_now()).await;
958
        if n > 0 {
959
            tracing::info!("flow: {n} scheduled run(s) started");
960
        }
961
    }
962
}
963
964
/// Look for lost jobs once a minute.
965
pub async fn reaper(app: Shared) {
966
    loop {
967
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
968
        if let Err(e) = reap_lost_jobs(&app).await {
969
            tracing::warn!("flow: looking for lost jobs: {e}");
970
        }
971
    }
972
}
973
974
/// Check a runner's signed receipt for a job of `run` and keep it in the
975
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
976
/// name, or why it was not kept. It must be the job's receipt, for the
977
/// run's state and repository, signed by the key the runner's token first
978
/// signed with.
979
pub async fn keep_receipt(
980
    app: &Shared,
981
    runner: &db::runners::Runner,
982
    row: &db::repos::RepoRow,
983
    run: &fdb::Run,
984
    receipt: &flow_core::Receipt,
985
    signed_hex: &str,
986
) -> Result<(String, String), String> {
987
    use oxy_core::receipt::SignedReceipt;
988
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
989
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
990
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
991
    if sr.receipt.hash().0 != receipt.hash() {
992
        return Err("it signs another receipt than the job's".into());
993
    }
994
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
995
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
996
    if sr.state() != state {
997
        return Err("it is for another state than the run's".into());
998
    }
999
    let key = sr.runner.hash().to_hex();
1000
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
1001
    if bound != key {
1002
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
1003
    }
1004
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
1005
    let signer = sr.runner.name.clone();
1006
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
1007
    Ok((attest.to_hex(), signer))
1008
}
1009
1010
/// A job's wall time, as the pages show it.
1011
pub fn duration(j: &fdb::Job) -> String {
1012
    match (j.started_ms, j.finished_ms) {
1013
        (Some(a), Some(b)) => human_ms(b - a),
1014
        (Some(a), None) => human_ms(db::now_ms() - a),
1015
        _ => "–".into(),
1016
    }
1017
}
1018
1019
pub fn human_ms(ms: i64) -> String {
1020
    let ms = ms.max(0);
1021
    if ms < 1000 {
1022
        format!("{ms} ms")
1023
    } else if ms < 60_000 {
1024
        format!("{:.1} s", ms as f64 / 1000.0)
1025
    } else {
1026
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
1027
    }
1028
}
1029
1030
/// Where a live run stands among the runners' work: what its viewers may
1031
/// know of it. Jobs of other repositories are named only when those are
1032
/// public; the rest are counted.
1033
#[derive(Clone, Debug, Default, PartialEq, Eq)]
1034
pub struct QueueInfo {
1035
    /// 1-based place of the run's first queued job among all queued jobs.
1036
    pub position: Option<usize>,
1037
    /// Jobs running now, and queued jobs ahead of the run's first one.
1038
    pub running: Vec<String>,
1039
    pub ahead: Vec<String>,
1040
    /// Of those, jobs in repositories the page may not name.
1041
    pub hidden: usize,
1042
}
1043
1044
impl QueueInfo {
1045
    /// "3rd in line" and the like.
1046
    pub fn place(&self) -> Option<String> {
1047
        self.position.map(|n| {
1048
            let suffix = match (n % 10, n % 100) {
1049
                (1, 11) | (2, 12) | (3, 13) => "th",
1050
                (1, _) => "st",
1051
                (2, _) => "nd",
1052
                (3, _) => "rd",
1053
                _ => "th",
1054
            };
1055
            format!("{n}{suffix} in line")
1056
        })
1057
    }
1058
}
1059
1060
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
1061
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
1062
    let mut info = QueueInfo::default();
1063
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
1064
    info.position = first.map(|i| i + 1);
1065
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
1066
    let mut queued_seen = 0;
1067
    for e in queue {
1068
        if e.run_id == run_id {
1069
            continue;
1070
        }
1071
        let ahead = match e.status.as_str() {
1072
            "running" => true,
1073
            _ => {
1074
                queued_seen += 1;
1075
                first.is_some_and(|f| queued_seen <= f)
1076
            }
1077
        };
1078
        if !ahead {
1079
            continue;
1080
        }
1081
        if e.repo_id != repo_id && e.visibility != "public" {
1082
            info.hidden += 1;
1083
        } else if e.status == "running" {
1084
            info.running.push(label(e));
1085
        } else {
1086
            info.ahead.push(label(e));
1087
        }
1088
    }
1089
    info
1090
}
1091
1092
#[cfg(test)]
1093
mod queue_tests {
1094
    use super::*;
1095
1096
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
1097
        fdb::QueueEntry {
1098
            job_id,
1099
            run_id,
1100
            repo_id,
1101
            owner: "o".into(),
1102
            name: format!("r{repo_id}"),
1103
            visibility: vis.into(),
1104
            run_number: run_id,
1105
            job: job.into(),
1106
            status: status.into(),
1107
            runner: String::new(),
1108
            spec: String::new(),
1109
            sandbox: String::new(),
1110
        }
1111
    }
1112
1113
    #[test]
1114
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
1115
        // Running first, then queued in claim order (as `fdb::queue` returns them).
1116
        let q = vec![
1117
            e(1, 10, 1, "public", "build", "running"),
1118
            e(2, 11, 2, "private", "test", "running"),
1119
            e(3, 10, 1, "public", "test", "queued"),
1120
            e(4, 12, 3, "private", "lint", "queued"),
1121
            e(5, 13, 4, "public", "build", "queued"),
1122
            e(6, 13, 4, "public", "test", "queued"),
1123
            e(7, 14, 5, "public", "late", "queued"),
1124
        ];
1125
        let info = queue_info(&q, 4, 13);
1126
        assert_eq!(info.position, Some(3));
1127
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
1128
        assert_eq!(info.running, ["o/r1 #10 build"]);
1129
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
1130
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
1131
        // A private repository's own jobs are named to its viewers.
1132
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
1133
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
1134
        // A run with nothing queued has no place.
1135
        assert_eq!(queue_info(&q, 1, 99).position, None);
1136
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
1137
        assert_eq!(
1138
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
1139
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
1140
        );
1141
    }
1142
}