OXYGENHUB
# 4f6925337848 oxygenhub / crates / hub / src / flow.rs
25.4 KiB · Rust · last changed Run pipeline from bookmarks and changes, and say why it is unavailable 1 month ago source blame history raw
42fa9554 Andy McCall ↶ 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
9008bf6c Andy McCall ↶ 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
42fa9554 Andy McCall ↶ 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) {
9008bf6c Andy McCall ↶ 35
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
42fa9554 Andy McCall ↶ 36
        return;
37
    }
1edac4b6 Andy McCall ↶ 38
    if let Err(e) = start_run(app, row, bookmark, state, heads, actor, "push").await {
39
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
40
    }
41
}
42
43
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
44
/// moving it. Returns the new run's number.
45
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
46
    if !enabled(app, row) {
47
        return Err("OxygenFlow is not enabled for this repository".into());
48
    }
49
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
50
    let b = bookmark.to_string();
51
    let found = app
52
        .repos
53
        .read(&repo, move |r, _| {
54
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
55
            Ok(Some((st, r.store().get_state(&st)?.heads)))
56
        })
57
        .await
58
        .map_err(|e| e.to_string())?;
59
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
60
    if heads.is_empty() {
61
        return Err(format!("{bookmark} has no changes yet"));
62
    }
63
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
64
        .await?
65
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
66
}
67
4f692533 Andy McCall ↶ 68
/// "Run pipeline" on one committed change: the state whose only head is
69
/// `change`, whatever bookmarks hold it. Returns the new run's number.
70
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
71
    if !enabled(app, row) {
72
        return Err("OxygenFlow is not enabled for this repository".into());
73
    }
74
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
75
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
76
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change(&h).is_ok())).await.map_err(|e| e.to_string())?;
77
    if !known {
78
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
79
    }
80
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
81
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
82
        .await?
83
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
84
}
85
1edac4b6 Andy McCall ↶ 86
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
87
/// run whose checks replace the old ones. Returns the new run's number.
88
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
89
    if !enabled(app, row) {
90
        return Err("OxygenFlow is not enabled for this repository".into());
91
    }
92
    if run.live() {
93
        return Err(format!("run #{} is still {}", run.number, run.status));
94
    }
95
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
96
    if heads.is_empty() {
97
        return Err("the run names no changes".into());
98
    }
99
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &format!("rerun:{}", run.number))
100
        .await?
101
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
102
}
103
104
/// Start a run of the pipeline in the state `heads` make (whose state
105
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
106
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
107
/// whose access the run's subrepo reads are checked against. Returns the
108
/// run's number, or `None` when the state has no pipeline. The caller
109
/// checks that Flow is enabled.
110
pub async fn start_run(
111
    app: &Shared,
112
    row: &db::repos::RepoRow,
113
    bookmark: &str,
114
    state: &str,
115
    heads: &[ChangeHash],
116
    actor: &str,
117
    trigger: &str,
118
) -> Result<Option<i64>, String> {
119
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
42fa9554 Andy McCall ↶ 120
    let h = heads.to_vec();
121
    let text = app
122
        .repos
123
        .read(&repo, move |r, cache| {
124
            let loaded = cache.load(r, &h)?;
125
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
126
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
127
                _ => None,
128
            })
129
        })
1edac4b6 Andy McCall ↶ 130
        .await
131
        .map_err(|e| format!("reading the pipeline: {e}"))?;
132
    let Some(text) = text else { return Ok(None) };
42fa9554 Andy McCall ↶ 133
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
134
    hex.sort();
135
    let heads_s = hex.join("+");
1edac4b6 Andy McCall ↶ 136
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
137
    let id = match Pipeline::parse(&text) {
42fa9554 Andy McCall ↶ 138
        Err(problems) => {
139
            let msg = problems.to_string();
1edac4b6 Andy McCall ↶ 140
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
42fa9554 Andy McCall ↶ 141
        }
142
        Ok(p) => {
143
            let depths = p.depths();
144
            let jobs: Vec<fdb::NewJob> = p
145
                .order()
146
                .into_iter()
147
                .map(|j| {
148
                    let sb = &p.sandboxes[&j.sandbox];
149
                    fdb::NewJob {
150
                        name: j.name.clone(),
151
                        depth: depths[j.name.as_str()] as i64,
152
                        depends: j.depends_on.clone(),
153
                        spec: serde_json::to_string(j).unwrap_or_default(),
154
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
155
                        runtime: sb.runtime.as_str().into(),
156
                    }
157
                })
158
                .collect();
159
            let hash = hex::encode(p.hash());
1edac4b6 Andy McCall ↶ 160
            let id =
161
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
162
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
42fa9554 Andy McCall ↶ 163
                for j in &jobs {
a0d4bbfb Andy McCall ↶ 164
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
42fa9554 Andy McCall ↶ 165
                }
166
            }
167
            id
168
        }
169
    };
1edac4b6 Andy McCall ↶ 170
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
171
    Ok(Some(run.number))
42fa9554 Andy McCall ↶ 172
}
173
a0d4bbfb Andy McCall ↶ 174
/// Record a job's check; it links to the run's page by the run's number.
175
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
176
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
42fa9554 Andy McCall ↶ 177
    let name = format!("flow/{job}");
178
    let c = db::hooks::upsert_check(
179
        &app.db,
180
        row.id,
181
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
182
    )
183
    .await;
184
    if let Ok(c) = c {
185
        let api = crate::api::check_of(&c);
186
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
187
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
188
    }
189
}
190
191
/// The check status a job's status shows as.
192
fn check_status(job_status: &str) -> &'static str {
193
    match job_status {
194
        "queued" => "queued",
195
        "running" => "running",
196
        "passed" => "passed",
197
        "skipped" => "skipped",
198
        _ => "failed",
199
    }
200
}
201
cc19697f Andy McCall ↶ 202
/// The runner's token, checked, as the BLAKE3 hex that names it.
203
fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<String, ApiError> {
42fa9554 Andy McCall ↶ 204
    let Some(t) = oxy_server::bearer_token(headers) else {
205
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
206
    };
207
    if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
cc19697f Andy McCall ↶ 208
        Ok(blake3::hash(t.as_bytes()).to_hex().to_string())
42fa9554 Andy McCall ↶ 209
    } else {
210
        Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into()))
211
    }
212
}
213
214
/// `POST /api/v1/flow/claim`.
215
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
216
    runner_auth(&app, &headers)?;
217
    let runner: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
218
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
76bc5a05 Andy McCall ↶ 219
    let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus };
220
    let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room).await? else {
42fa9554 Andy McCall ↶ 221
        return Ok(StatusCode::NO_CONTENT.into_response());
222
    };
223
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
224
    let row =
225
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
a0d4bbfb Andy McCall ↶ 226
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
42fa9554 Andy McCall ↶ 227
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
228
    let claim = Claim {
229
        job_id: job.id,
230
        run_id: run.id,
231
        repo: format!("{}/{}", row.owner, row.name),
232
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
233
        token: secret,
234
        heads: run.head_list().into_iter().map(String::from).collect(),
235
        pipeline_hash: run.pipeline_hash.clone(),
236
        job: serde_json::from_str(&job.spec).map_err(bad)?,
237
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
238
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
fd3db644 Andy McCall ↶ 239
        subrepos: vec![],
9a065bc0 Andy McCall ↶ 240
        state: run.state.clone(),
241
        bookmark: run.bookmark.clone(),
242
        run_number: run.number,
42fa9554 Andy McCall ↶ 243
    };
fd3db644 Andy McCall ↶ 244
    let mut claim = claim;
245
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
246
    if !ids.is_empty() {
247
        fdb::set_also_read(&app.db, job.id, &ids).await?;
248
    }
249
    claim.subrepos = inputs;
42fa9554 Andy McCall ↶ 250
    Ok(Json(claim).into_response())
fd3db644 Andy McCall ↶ 251
}
252
25091e5c Andy McCall ↶ 253
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
254
async fn pins_in(
255
    app: &Shared,
256
    name: crate::repos::RepoName,
257
    heads: Vec<oxy_core::hash::ChangeHash>,
258
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
259
    app.repos
fd3db644 Andy McCall ↶ 260
        .read(&name, move |repo, cache| {
261
            let loaded = cache.load(repo, &heads)?;
262
            let mut out = Vec::new();
263
            for (path, f) in &loaded.snapshot.files {
264
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
265
                    continue;
266
                }
267
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
268
                    if let Some(b) = blobs.first() {
269
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
270
                            .read_blob(b)
271
                            .map_err(crate::repos::RepoError::Oxy)
272
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
273
                        {
274
                            out.push((path.clone(), id, heads));
275
                        }
276
                    }
277
                }
278
            }
279
            Ok(out)
280
        })
281
        .await
25091e5c Andy McCall ↶ 282
        .unwrap_or_default()
283
}
284
285
/// How deep subrepos of subrepos are followed.
286
const MAX_SUBREPO_DEPTH: usize = 3;
287
288
/// The Oxygen subrepos the run's state pins, and those their states pin in
289
/// turn (parents first, paths joined), that live on this hub and that the
290
/// run's pusher may read: what the job's workspace needs beside its own
291
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
292
/// then fails on the missing files with an ordinary error.
293
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
294
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
295
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
fd3db644 Andy McCall ↶ 296
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
25091e5c Andy McCall ↶ 297
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
fd3db644 Andy McCall ↶ 298
    let mut inputs = Vec::new();
299
    let mut ids = Vec::new();
25091e5c Andy McCall ↶ 300
    // (path prefix, repository, heads, depth), parents before children.
301
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
302
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
303
        if depth == MAX_SUBREPO_DEPTH {
304
            continue;
305
        }
306
        for (path, id, pinned) in pins_in(app, name, heads).await {
307
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
308
            for other in &rows {
309
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
310
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
311
                if !matches {
312
                    continue;
313
                }
314
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
315
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
316
                    inputs.push(flow_core::api::SubrepoInput {
317
                        path: path.clone(),
318
                        repo: format!("{}/{}", other.owner, other.name),
319
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
320
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
321
                    });
322
                    if !ids.contains(&other.id) {
323
                        ids.push(other.id);
324
                    }
325
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
326
                }
327
                break;
fd3db644 Andy McCall ↶ 328
            }
329
        }
330
    }
331
    (inputs, ids)
42fa9554 Andy McCall ↶ 332
}
333
334
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
335
    match fdb::job(&app.db, id).await? {
336
        Some(j) if j.status == "running" => Ok(j),
337
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
338
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
339
    }
340
}
341
342
/// `POST /api/v1/flow/jobs/{id}/log`.
343
pub async fn log(
344
    State(app): State<Shared>,
345
    headers: HeaderMap,
346
    Path(id): Path<i64>,
347
    Json(mut batch): Json<LogBatch>,
348
) -> Result<StatusCode, ApiError> {
349
    runner_auth(&app, &headers)?;
350
    let job = running_job(&app, id).await?;
351
    for r in &mut batch.records {
352
        if r.text.len() > MAX_RECORD_TEXT {
353
            let mut cut = MAX_RECORD_TEXT;
354
            while !r.text.is_char_boundary(cut) {
355
                cut -= 1;
356
            }
357
            r.text.truncate(cut);
358
        }
359
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
360
            r.stream = "console".into();
361
        }
362
    }
363
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
364
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
365
        tracing::info!("flow: job {} reached the log limit", job.id);
366
    }
367
    Ok(StatusCode::NO_CONTENT)
368
}
369
370
/// `POST /api/v1/flow/jobs/{id}/finish`.
371
pub async fn finish(
372
    State(app): State<Shared>,
373
    headers: HeaderMap,
374
    Path(id): Path<i64>,
375
    Json(f): Json<Finish>,
376
) -> Result<StatusCode, ApiError> {
cc19697f Andy McCall ↶ 377
    let token = runner_auth(&app, &headers)?;
42fa9554 Andy McCall ↶ 378
    let job = running_job(&app, id).await?;
379
    let status = match (f.exit_code, &f.error) {
380
        (Some(0), None) => "passed",
381
        (Some(_), _) => "failed",
382
        (None, _) => "error",
383
    };
384
    let (receipt, receipt_hash) = match &f.receipt {
385
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
386
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
387
        }
388
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
389
        None => (String::new(), String::new()),
390
    };
391
    let error = f.error.clone().unwrap_or_default();
392
    let changed = fdb::finish(
393
        &app.db,
394
        job.id,
395
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
396
    )
397
    .await?;
398
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
399
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
cc19697f Andy McCall ↶ 400
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
401
                let (attest, signer, note) = match keep_receipt(&app, &token, &row, &run, r, signed).await {
402
                    Ok((attest, signer)) => (attest, signer, String::new()),
403
                    Err(e) => {
404
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
405
                        (String::new(), String::new(), e)
406
                    }
407
                };
408
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
409
            }
42fa9554 Andy McCall ↶ 410
            for j in changed {
411
                let summary = match j.status.as_str() {
412
                    "passed" => format!("passed in {}", duration(&j)),
413
                    "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
414
                    "skipped" => "skipped: a dependency did not pass".into(),
415
                    _ => format!("could not run: {}", j.error),
416
                };
a0d4bbfb Andy McCall ↶ 417
                report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
42fa9554 Andy McCall ↶ 418
            }
419
        }
420
    }
421
    Ok(StatusCode::NO_CONTENT)
cc19697f Andy McCall ↶ 422
}
423
424
/// Check a runner's signed receipt for a job of `run` and keep it in the
425
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
426
/// name, or why it was not kept. It must be the job's receipt, for the
427
/// run's state and repository, signed by the key the runner's token first
428
/// signed with.
429
pub async fn keep_receipt(
430
    app: &Shared,
431
    token_hash: &str,
432
    row: &db::repos::RepoRow,
433
    run: &fdb::Run,
434
    receipt: &flow_core::Receipt,
435
    signed_hex: &str,
436
) -> Result<(String, String), String> {
437
    use oxy_core::receipt::SignedReceipt;
438
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
439
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
440
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
441
    if sr.receipt.hash().0 != receipt.hash() {
442
        return Err("it signs another receipt than the job's".into());
443
    }
444
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
445
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
446
    if sr.state() != state {
447
        return Err("it is for another state than the run's".into());
448
    }
449
    let key = sr.runner.hash().to_hex();
450
    let bound = fdb::bind_runner_key(&app.db, token_hash, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
451
    if bound != key {
452
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
453
    }
454
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
455
    let signer = sr.runner.name.clone();
456
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
457
    Ok((attest.to_hex(), signer))
42fa9554 Andy McCall ↶ 458
}
459
460
/// A job's wall time, as the pages show it.
461
pub fn duration(j: &fdb::Job) -> String {
462
    match (j.started_ms, j.finished_ms) {
463
        (Some(a), Some(b)) => human_ms(b - a),
464
        (Some(a), None) => human_ms(db::now_ms() - a),
465
        _ => "–".into(),
466
    }
467
}
468
469
pub fn human_ms(ms: i64) -> String {
470
    let ms = ms.max(0);
471
    if ms < 1000 {
472
        format!("{ms} ms")
473
    } else if ms < 60_000 {
474
        format!("{:.1} s", ms as f64 / 1000.0)
475
    } else {
476
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
477
    }
478
}
bf3d0754 Andy McCall ↶ 479
480
/// Where a live run stands among the runners' work: what its viewers may
481
/// know of it. Jobs of other repositories are named only when those are
482
/// public; the rest are counted.
483
#[derive(Clone, Debug, Default, PartialEq, Eq)]
484
pub struct QueueInfo {
485
    /// 1-based place of the run's first queued job among all queued jobs.
486
    pub position: Option<usize>,
487
    /// Jobs running now, and queued jobs ahead of the run's first one.
488
    pub running: Vec<String>,
489
    pub ahead: Vec<String>,
490
    /// Of those, jobs in repositories the page may not name.
491
    pub hidden: usize,
492
}
493
494
impl QueueInfo {
495
    /// "3rd in line" and the like.
496
    pub fn place(&self) -> Option<String> {
497
        self.position.map(|n| {
498
            let suffix = match (n % 10, n % 100) {
499
                (1, 11) | (2, 12) | (3, 13) => "th",
500
                (1, _) => "st",
501
                (2, _) => "nd",
502
                (3, _) => "rd",
503
                _ => "th",
504
            };
505
            format!("{n}{suffix} in line")
506
        })
507
    }
508
}
509
510
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
511
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
512
    let mut info = QueueInfo::default();
513
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
514
    info.position = first.map(|i| i + 1);
515
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
516
    let mut queued_seen = 0;
517
    for e in queue {
518
        if e.run_id == run_id {
519
            continue;
520
        }
521
        let ahead = match e.status.as_str() {
522
            "running" => true,
523
            _ => {
524
                queued_seen += 1;
525
                first.is_some_and(|f| queued_seen <= f)
526
            }
527
        };
528
        if !ahead {
529
            continue;
530
        }
531
        if e.repo_id != repo_id && e.visibility != "public" {
532
            info.hidden += 1;
533
        } else if e.status == "running" {
534
            info.running.push(label(e));
535
        } else {
536
            info.ahead.push(label(e));
537
        }
538
    }
539
    info
540
}
541
542
#[cfg(test)]
543
mod queue_tests {
544
    use super::*;
545
546
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
547
        fdb::QueueEntry {
548
            job_id,
549
            run_id,
550
            repo_id,
551
            owner: "o".into(),
552
            name: format!("r{repo_id}"),
553
            visibility: vis.into(),
554
            run_number: run_id,
555
            job: job.into(),
556
            status: status.into(),
557
        }
558
    }
559
560
    #[test]
561
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
562
        // Running first, then queued in claim order (as `fdb::queue` returns them).
563
        let q = vec![
564
            e(1, 10, 1, "public", "build", "running"),
565
            e(2, 11, 2, "private", "test", "running"),
566
            e(3, 10, 1, "public", "test", "queued"),
567
            e(4, 12, 3, "private", "lint", "queued"),
568
            e(5, 13, 4, "public", "build", "queued"),
569
            e(6, 13, 4, "public", "test", "queued"),
570
            e(7, 14, 5, "public", "late", "queued"),
571
        ];
572
        let info = queue_info(&q, 4, 13);
573
        assert_eq!(info.position, Some(3));
574
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
575
        assert_eq!(info.running, ["o/r1 #10 build"]);
576
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
577
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
578
        // A private repository's own jobs are named to its viewers.
579
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
580
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
581
        // A run with nothing queued has no place.
582
        assert_eq!(queue_info(&q, 1, 99).position, None);
583
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
584
        assert_eq!(
585
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
586
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
587
        );
588
    }
589
}