OXYGENHUB
# ea85398a51bc oxygenhub / crates / hub / src / flow.rs
33.7 KiB · Rust · last changed End the jobs a runner no longer holds at its next claim 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 bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
36
        return;
37
    }
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
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_meta(&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
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")?;
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
        })
130
        .await
131
        .map_err(|e| format!("reading the pipeline: {e}"))?;
132
    let Some(text) = text else { return Ok(None) };
133
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
134
    hex.sort();
135
    let heads_s = hex.join("+");
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) {
138
        Err(problems) => {
139
            let msg = problems.to_string();
140
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
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());
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 {
163
                for j in &jobs {
164
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
165
                }
166
            }
167
            id
168
        }
169
    };
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))
172
}
173
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);
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
202
/// The runner a bearer token belongs to: a registered runner, or one
203
/// configured with a hub.toml token (tracked from its first request). A
204
/// disabled runner is refused.
205
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
206
    let Some(t) = oxy_server::bearer_token(headers) else {
207
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
208
    };
209
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
210
    let runner = match db::runners::by_token(&app.db, &hash).await? {
211
        Some(r) => r,
212
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
213
            db::runners::configured(&app.db, &hash, "runner").await?
214
        }
215
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
216
    };
217
    if runner.state == "disabled" {
218
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
219
    }
220
    Ok(runner)
221
}
222
223
/// `POST /api/v1/flow/runners/register`: a one-time registration token
224
/// from Admin → Runners buys a runner credential, binding the runner's
225
/// signing identity (hub spec §13.2).
226
pub async fn register(
227
    State(app): State<Shared>,
228
    Json(req): Json<flow_core::api::Register>,
229
) -> Result<Json<flow_core::api::Registered>, ApiError> {
230
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
231
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
232
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
233
    if name.is_empty() {
234
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
235
    }
236
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
237
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
238
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
239
    let runner =
240
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
241
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
242
        })?;
243
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
244
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
245
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
246
}
247
248
/// `POST /api/v1/flow/claim`.
249
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
250
    let me = runner_auth(&app, &headers).await?;
251
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
252
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
253
    if let Some(holding) = &req.holding {
254
        end_jobs_not_held(&app, &me, holding).await?;
255
    }
256
    // A draining runner finishes what it has and takes nothing new.
257
    if me.state == "draining" {
258
        return Ok(StatusCode::NO_CONTENT.into_response());
259
    }
260
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
261
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
262
    let room = fdb::Room {
263
        memory_mib: req.free_memory_mib,
264
        cpus: req.free_cpus,
265
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
266
    };
267
    // The labels it reports now, or those it registered with.
268
    let labels: Vec<String> = match &req.info {
269
        Some(i) => i.labels.clone(),
270
        None => me.label_list().into_iter().map(String::from).collect(),
271
    };
272
    let Some(job) =
273
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
274
    else {
275
        return Ok(StatusCode::NO_CONTENT.into_response());
276
    };
277
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
278
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
279
    let row =
280
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
281
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
282
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
283
    let claim = Claim {
284
        job_id: job.id,
285
        run_id: run.id,
286
        repo: format!("{}/{}", row.owner, row.name),
287
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
288
        token: secret,
289
        heads: run.head_list().into_iter().map(String::from).collect(),
290
        pipeline_hash: run.pipeline_hash.clone(),
291
        job: serde_json::from_str(&job.spec).map_err(bad)?,
292
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
293
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
294
        subrepos: vec![],
295
        state: run.state.clone(),
296
        bookmark: run.bookmark.clone(),
297
        run_number: run.number,
298
    };
299
    let mut claim = claim;
300
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
301
    if !ids.is_empty() {
302
        fdb::set_also_read(&app.db, job.id, &ids).await?;
303
    }
304
    claim.subrepos = inputs;
305
    Ok(Json(claim).into_response())
306
}
307
308
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
309
async fn pins_in(
310
    app: &Shared,
311
    name: crate::repos::RepoName,
312
    heads: Vec<oxy_core::hash::ChangeHash>,
313
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
314
    app.repos
315
        .read(&name, move |repo, cache| {
316
            let loaded = cache.load(repo, &heads)?;
317
            let mut out = Vec::new();
318
            for (path, f) in &loaded.snapshot.files {
319
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
320
                    continue;
321
                }
322
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
323
                    if let Some(b) = blobs.first() {
324
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
325
                            .read_blob(b)
326
                            .map_err(crate::repos::RepoError::Oxy)
327
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
328
                        {
329
                            out.push((path.clone(), id, heads));
330
                        }
331
                    }
332
                }
333
            }
334
            Ok(out)
335
        })
336
        .await
337
        .unwrap_or_default()
338
}
339
340
/// How deep subrepos of subrepos are followed.
341
const MAX_SUBREPO_DEPTH: usize = 3;
342
343
/// The Oxygen subrepos the run's state pins, and those their states pin in
344
/// turn (parents first, paths joined), that live on this hub and that the
345
/// run's pusher may read: what the job's workspace needs beside its own
346
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
347
/// then fails on the missing files with an ordinary error.
348
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
349
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
350
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
351
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
352
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
353
    let mut inputs = Vec::new();
354
    let mut ids = Vec::new();
355
    // (path prefix, repository, heads, depth), parents before children.
356
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
357
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
358
        if depth == MAX_SUBREPO_DEPTH {
359
            continue;
360
        }
361
        for (path, id, pinned) in pins_in(app, name, heads).await {
362
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
363
            for other in &rows {
364
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
365
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
366
                if !matches {
367
                    continue;
368
                }
369
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
370
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
371
                    inputs.push(flow_core::api::SubrepoInput {
372
                        path: path.clone(),
373
                        repo: format!("{}/{}", other.owner, other.name),
374
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
375
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
376
                    });
377
                    if !ids.contains(&other.id) {
378
                        ids.push(other.id);
379
                    }
380
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
381
                }
382
                break;
383
            }
384
        }
385
    }
386
    (inputs, ids)
387
}
388
389
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
390
    match fdb::job(&app.db, id).await? {
391
        Some(j) if j.status == "running" => Ok(j),
392
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
393
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
394
    }
395
}
396
397
/// `POST /api/v1/flow/jobs/{id}/log`.
398
pub async fn log(
399
    State(app): State<Shared>,
400
    headers: HeaderMap,
401
    Path(id): Path<i64>,
402
    Json(mut batch): Json<LogBatch>,
403
) -> Result<StatusCode, ApiError> {
404
    runner_auth(&app, &headers).await?;
405
    let job = running_job(&app, id).await?;
406
    for r in &mut batch.records {
407
        if r.text.len() > MAX_RECORD_TEXT {
408
            let mut cut = MAX_RECORD_TEXT;
409
            while !r.text.is_char_boundary(cut) {
410
                cut -= 1;
411
            }
412
            r.text.truncate(cut);
413
        }
414
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
415
            r.stream = "console".into();
416
        }
417
    }
418
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
419
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
420
        tracing::info!("flow: job {} reached the log limit", job.id);
421
    }
422
    Ok(StatusCode::NO_CONTENT)
423
}
424
425
/// Largest artifact the hub keeps; the runner refuses larger ones first.
426
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
427
428
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
429
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
430
/// repository. Answers `{"blob"}`.
431
pub async fn artifact(
432
    State(app): State<Shared>,
433
    headers: HeaderMap,
434
    Path((id, name)): Path<(i64, String)>,
435
    body: axum::body::Bytes,
436
) -> Result<Response, ApiError> {
437
    runner_auth(&app, &headers).await?;
438
    let job = running_job(&app, id).await?;
439
    let spec: flow_core::Job =
440
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
441
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
442
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
443
    };
444
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
445
    let row =
446
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
447
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
448
    let content = blake3::hash(&body).to_hex().to_string();
449
    let size = body.len() as i64;
450
    let blob = app
451
        .repos
452
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
453
        .await
454
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
455
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
456
    fdb::add_artifact(&app.db, &a).await?;
457
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
458
}
459
460
/// `POST /api/v1/flow/jobs/{id}/finish`.
461
pub async fn finish(
462
    State(app): State<Shared>,
463
    headers: HeaderMap,
464
    Path(id): Path<i64>,
465
    Json(f): Json<Finish>,
466
) -> Result<StatusCode, ApiError> {
467
    let me = runner_auth(&app, &headers).await?;
468
    let job = running_job(&app, id).await?;
469
    let status = match (f.exit_code, &f.error) {
470
        (Some(0), None) => "passed",
471
        (Some(_), _) => "failed",
472
        (None, _) => "error",
473
    };
474
    let (receipt, receipt_hash) = match &f.receipt {
475
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
476
            // The receipt names exactly the artifacts uploaded, in name order.
477
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
478
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
479
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
480
            }
481
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
482
        }
483
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
484
        None => (String::new(), String::new()),
485
    };
486
    let error = f.error.clone().unwrap_or_default();
487
    let changed = fdb::finish(
488
        &app.db,
489
        job.id,
490
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
491
    )
492
    .await?;
493
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
494
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
495
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
496
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
497
                    Ok((attest, signer)) => (attest, signer, String::new()),
498
                    Err(e) => {
499
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
500
                        (String::new(), String::new(), e)
501
                    }
502
                };
503
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
504
            }
505
        }
506
    }
507
    report_changed(&app, job.run_id, changed).await?;
508
    Ok(StatusCode::NO_CONTENT)
509
}
510
511
/// Report the checks of jobs whose status changed.
512
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
513
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
514
        return Ok(());
515
    };
516
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
517
        return Ok(());
518
    };
519
    for j in changed {
520
        let summary = match j.status.as_str() {
521
            "passed" => format!("passed in {}", duration(&j)),
522
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
523
            "skipped" => "skipped: a dependency did not pass".into(),
524
            _ => format!("could not run: {}", j.error),
525
        };
526
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
527
    }
528
    Ok(())
529
}
530
531
/// Grace past the job time limit before the hub gives a job up: a runner
532
/// stops a job at the limit and reports it within moments, so one still
533
/// running this long after is one the runner no longer reports (it was
534
/// restarted, or lost the hub while the job ended, oxygenhub#71).
535
const LOST_GRACE_MS: i64 = 5 * 60 * 1000;
536
537
/// End every job still running past the job time limit plus the grace:
538
/// it is marked an error with the reason, the jobs that depended on it are
539
/// skipped and its run settled, as when a runner reports an error.
540
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
541
    let limit_ms = app.config.flow.job_timeout_minutes.max(1) as i64 * 60_000 + LOST_GRACE_MS;
542
    let lost = fdb::running_since_before(&app.db, db::now_ms() - limit_ms).await?;
543
    let n = lost.len();
544
    for job in lost {
545
        let error = format!(
546
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
547
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
548
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
549
        );
550
        let changed =
551
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
552
                .await?;
553
        tracing::warn!("flow: job {} {error}", job.id);
554
        report_changed(app, job.run_id, changed).await?;
555
    }
556
    Ok(n)
557
}
558
559
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
560
/// it restarted since claiming them, or never received the claim. A
561
/// runner claims one job at a time, so a job it holds is always listed.
562
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
563
    for job in fdb::running_on(&app.db, runner.id).await? {
564
        if holding.contains(&job.id) {
565
            continue;
566
        }
567
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
568
        let changed =
569
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
570
                .await?;
571
        tracing::warn!("flow: job {} {error}", job.id);
572
        report_changed(app, job.run_id, changed).await?;
573
    }
574
    Ok(())
575
}
576
577
/// Look for lost jobs once a minute.
578
pub async fn reaper(app: Shared) {
579
    loop {
580
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
581
        if let Err(e) = reap_lost_jobs(&app).await {
582
            tracing::warn!("flow: looking for lost jobs: {e}");
583
        }
584
    }
585
}
586
587
/// Check a runner's signed receipt for a job of `run` and keep it in the
588
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
589
/// name, or why it was not kept. It must be the job's receipt, for the
590
/// run's state and repository, signed by the key the runner's token first
591
/// signed with.
592
pub async fn keep_receipt(
593
    app: &Shared,
594
    runner: &db::runners::Runner,
595
    row: &db::repos::RepoRow,
596
    run: &fdb::Run,
597
    receipt: &flow_core::Receipt,
598
    signed_hex: &str,
599
) -> Result<(String, String), String> {
600
    use oxy_core::receipt::SignedReceipt;
601
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
602
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
603
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
604
    if sr.receipt.hash().0 != receipt.hash() {
605
        return Err("it signs another receipt than the job's".into());
606
    }
607
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
608
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
609
    if sr.state() != state {
610
        return Err("it is for another state than the run's".into());
611
    }
612
    let key = sr.runner.hash().to_hex();
613
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
614
    if bound != key {
615
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
616
    }
617
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
618
    let signer = sr.runner.name.clone();
619
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
620
    Ok((attest.to_hex(), signer))
621
}
622
623
/// A job's wall time, as the pages show it.
624
pub fn duration(j: &fdb::Job) -> String {
625
    match (j.started_ms, j.finished_ms) {
626
        (Some(a), Some(b)) => human_ms(b - a),
627
        (Some(a), None) => human_ms(db::now_ms() - a),
628
        _ => "–".into(),
629
    }
630
}
631
632
pub fn human_ms(ms: i64) -> String {
633
    let ms = ms.max(0);
634
    if ms < 1000 {
635
        format!("{ms} ms")
636
    } else if ms < 60_000 {
637
        format!("{:.1} s", ms as f64 / 1000.0)
638
    } else {
639
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
640
    }
641
}
642
643
/// Where a live run stands among the runners' work: what its viewers may
644
/// know of it. Jobs of other repositories are named only when those are
645
/// public; the rest are counted.
646
#[derive(Clone, Debug, Default, PartialEq, Eq)]
647
pub struct QueueInfo {
648
    /// 1-based place of the run's first queued job among all queued jobs.
649
    pub position: Option<usize>,
650
    /// Jobs running now, and queued jobs ahead of the run's first one.
651
    pub running: Vec<String>,
652
    pub ahead: Vec<String>,
653
    /// Of those, jobs in repositories the page may not name.
654
    pub hidden: usize,
655
}
656
657
impl QueueInfo {
658
    /// "3rd in line" and the like.
659
    pub fn place(&self) -> Option<String> {
660
        self.position.map(|n| {
661
            let suffix = match (n % 10, n % 100) {
662
                (1, 11) | (2, 12) | (3, 13) => "th",
663
                (1, _) => "st",
664
                (2, _) => "nd",
665
                (3, _) => "rd",
666
                _ => "th",
667
            };
668
            format!("{n}{suffix} in line")
669
        })
670
    }
671
}
672
673
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
674
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
675
    let mut info = QueueInfo::default();
676
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
677
    info.position = first.map(|i| i + 1);
678
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
679
    let mut queued_seen = 0;
680
    for e in queue {
681
        if e.run_id == run_id {
682
            continue;
683
        }
684
        let ahead = match e.status.as_str() {
685
            "running" => true,
686
            _ => {
687
                queued_seen += 1;
688
                first.is_some_and(|f| queued_seen <= f)
689
            }
690
        };
691
        if !ahead {
692
            continue;
693
        }
694
        if e.repo_id != repo_id && e.visibility != "public" {
695
            info.hidden += 1;
696
        } else if e.status == "running" {
697
            info.running.push(label(e));
698
        } else {
699
            info.ahead.push(label(e));
700
        }
701
    }
702
    info
703
}
704
705
#[cfg(test)]
706
mod queue_tests {
707
    use super::*;
708
709
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
710
        fdb::QueueEntry {
711
            job_id,
712
            run_id,
713
            repo_id,
714
            owner: "o".into(),
715
            name: format!("r{repo_id}"),
716
            visibility: vis.into(),
717
            run_number: run_id,
718
            job: job.into(),
719
            status: status.into(),
720
            runner: String::new(),
721
            spec: String::new(),
722
            sandbox: String::new(),
723
        }
724
    }
725
726
    #[test]
727
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
728
        // Running first, then queued in claim order (as `fdb::queue` returns them).
729
        let q = vec![
730
            e(1, 10, 1, "public", "build", "running"),
731
            e(2, 11, 2, "private", "test", "running"),
732
            e(3, 10, 1, "public", "test", "queued"),
733
            e(4, 12, 3, "private", "lint", "queued"),
734
            e(5, 13, 4, "public", "build", "queued"),
735
            e(6, 13, 4, "public", "test", "queued"),
736
            e(7, 14, 5, "public", "late", "queued"),
737
        ];
738
        let info = queue_info(&q, 4, 13);
739
        assert_eq!(info.position, Some(3));
740
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
741
        assert_eq!(info.running, ["o/r1 #10 build"]);
742
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
743
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
744
        // A private repository's own jobs are named to its viewers.
745
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
746
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
747
        // A run with nothing queued has no place.
748
        assert_eq!(queue_info(&q, 1, 99).position, None);
749
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
750
        assert_eq!(
751
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
752
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
753
        );
754
    }
755
}