OXYGENHUB
# d7ce41acf22c oxygenhub / crates / hub / src / flow.rs
37.8 KiB · Rust · last changed Runs started by a push honour each job's scope 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
    let touched = touched_since_last_run(app, row, bookmark, heads).await;
39
    if let Err(e) = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await {
40
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
41
    }
42
}
43
44
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
45
/// moving it. Returns the new run's number.
46
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
47
    if !enabled(app, row) {
48
        return Err("OxygenFlow is not enabled for this repository".into());
49
    }
50
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
51
    let b = bookmark.to_string();
52
    let found = app
53
        .repos
54
        .read(&repo, move |r, _| {
55
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
56
            Ok(Some((st, r.store().get_state(&st)?.heads)))
57
        })
58
        .await
59
        .map_err(|e| e.to_string())?;
60
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
61
    if heads.is_empty() {
62
        return Err(format!("{bookmark} has no changes yet"));
63
    }
64
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
65
        .await?
66
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
67
}
68
69
/// "Run pipeline" on one committed change: the state whose only head is
70
/// `change`, whatever bookmarks hold it. Returns the new run's number.
71
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
72
    if !enabled(app, row) {
73
        return Err("OxygenFlow is not enabled for this repository".into());
74
    }
75
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
76
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
77
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
78
    if !known {
79
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
80
    }
81
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
82
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
83
        .await?
84
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
85
}
86
87
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
88
/// run whose checks replace the old ones. Returns the new run's number.
89
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
90
    if !enabled(app, row) {
91
        return Err("OxygenFlow is not enabled for this repository".into());
92
    }
93
    if run.live() {
94
        return Err(format!("run #{} is still {}", run.number, run.status));
95
    }
96
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
97
    if heads.is_empty() {
98
        return Err("the run names no changes".into());
99
    }
100
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &format!("rerun:{}", run.number))
101
        .await?
102
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
103
}
104
105
/// The paths the changes a push brought touch: the changes in `heads`'
106
/// state that the bookmark's previous run did not have, and the files their
107
/// edits and tree operations name, as the new state places them. `None`
108
/// (run every job) for the bookmark's first run, or when it cannot be told.
109
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
110
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
111
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
112
    if old.is_empty() {
113
        return None;
114
    }
115
    let repo = RepoName::new(&row.owner, &row.name)?;
116
    let new = heads.to_vec();
117
    app.repos
118
        .read(&repo, move |r, cache| {
119
            let fresh = r.negotiate(&old, &new)?;
120
            let loaded = cache.load(r, &new)?;
121
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
122
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
123
            let mut paths = std::collections::BTreeSet::new();
124
            for h in fresh {
125
                let (c, _) = r.store().get_change_lazy(&h)?;
126
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
127
                for n in nodes {
128
                    if let Some(p) = by_node.get(&n) {
129
                        paths.insert((*p).clone());
130
                    }
131
                }
132
            }
133
            Ok(paths.into_iter().collect::<Vec<String>>())
134
        })
135
        .await
136
        .ok()
137
}
138
139
/// Whether a job runs for a push that touched `touched`: always without a
140
/// scope or without knowing what was touched, else when a path matches.
141
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
142
    let Some(touched) = touched else { return true };
143
    if scope.is_empty() {
144
        return true;
145
    }
146
    let mut b = globset::GlobSetBuilder::new();
147
    for g in scope {
148
        match globset::Glob::new(g) {
149
            Ok(g) => {
150
                b.add(g);
151
            }
152
            Err(_) => return true,
153
        }
154
    }
155
    let Ok(set) = b.build() else { return true };
156
    touched.iter().any(|p| set.is_match(p))
157
}
158
159
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
160
/// left out of the run, unless a job that runs depends on them.
161
#[allow(clippy::too_many_arguments)]
162
pub async fn start_run_scoped(
163
    app: &Shared,
164
    row: &db::repos::RepoRow,
165
    bookmark: &str,
166
    state: &str,
167
    heads: &[ChangeHash],
168
    actor: &str,
169
    trigger: &str,
170
    touched: Option<&[String]>,
171
) -> Result<Option<i64>, String> {
172
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
173
}
174
175
/// Start a run of the pipeline in the state `heads` make (whose state
176
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
177
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
178
/// whose access the run's subrepo reads are checked against. Returns the
179
/// run's number, or `None` when the state has no pipeline. The caller
180
/// checks that Flow is enabled.
181
pub async fn start_run(
182
    app: &Shared,
183
    row: &db::repos::RepoRow,
184
    bookmark: &str,
185
    state: &str,
186
    heads: &[ChangeHash],
187
    actor: &str,
188
    trigger: &str,
189
) -> Result<Option<i64>, String> {
190
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
191
}
192
193
#[allow(clippy::too_many_arguments)]
194
async fn start_run_with(
195
    app: &Shared,
196
    row: &db::repos::RepoRow,
197
    bookmark: &str,
198
    state: &str,
199
    heads: &[ChangeHash],
200
    actor: &str,
201
    trigger: &str,
202
    touched: Option<&[String]>,
203
) -> Result<Option<i64>, String> {
204
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
205
    let h = heads.to_vec();
206
    let text = app
207
        .repos
208
        .read(&repo, move |r, cache| {
209
            let loaded = cache.load(r, &h)?;
210
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
211
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
212
                _ => None,
213
            })
214
        })
215
        .await
216
        .map_err(|e| format!("reading the pipeline: {e}"))?;
217
    let Some(text) = text else { return Ok(None) };
218
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
219
    hex.sort();
220
    let heads_s = hex.join("+");
221
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
222
    let id = match Pipeline::parse(&text) {
223
        Err(problems) => {
224
            let msg = problems.to_string();
225
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
226
        }
227
        Ok(p) => {
228
            let depths = p.depths();
229
            // Jobs out of the push's scope stay out, unless a kept job
230
            // needs them.
231
            let mut keep: std::collections::HashSet<String> =
232
                p.order().into_iter().filter(|j| in_scope(&j.scope, touched)).map(|j| j.name.clone()).collect();
233
            loop {
234
                let needed: Vec<String> = p
235
                    .order()
236
                    .into_iter()
237
                    .filter(|j| keep.contains(&j.name))
238
                    .flat_map(|j| j.depends_on.iter().cloned())
239
                    .filter(|d| !keep.contains(d))
240
                    .collect();
241
                if needed.is_empty() {
242
                    break;
243
                }
244
                keep.extend(needed);
245
            }
246
            let jobs: Vec<fdb::NewJob> = p
247
                .order()
248
                .into_iter()
249
                .filter(|j| keep.contains(&j.name))
250
                .map(|j| {
251
                    let sb = &p.sandboxes[&j.sandbox];
252
                    fdb::NewJob {
253
                        name: j.name.clone(),
254
                        depth: depths[j.name.as_str()] as i64,
255
                        depends: j.depends_on.clone(),
256
                        spec: serde_json::to_string(j).unwrap_or_default(),
257
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
258
                        runtime: sb.runtime.as_str().into(),
259
                    }
260
                })
261
                .collect();
262
            if jobs.is_empty() && touched.is_some() {
263
                // Nothing this push touched is any job's business.
264
                return Ok(None);
265
            }
266
            let hash = hex::encode(p.hash());
267
            let id =
268
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
269
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
270
                for j in &jobs {
271
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
272
                }
273
            }
274
            id
275
        }
276
    };
277
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
278
    Ok(Some(run.number))
279
}
280
281
/// Record a job's check; it links to the run's page by the run's number.
282
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
283
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
284
    let name = format!("flow/{job}");
285
    let c = db::hooks::upsert_check(
286
        &app.db,
287
        row.id,
288
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
289
    )
290
    .await;
291
    if let Ok(c) = c {
292
        let api = crate::api::check_of(&c);
293
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
294
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
295
    }
296
}
297
298
/// The check status a job's status shows as.
299
fn check_status(job_status: &str) -> &'static str {
300
    match job_status {
301
        "queued" => "queued",
302
        "running" => "running",
303
        "passed" => "passed",
304
        "skipped" => "skipped",
305
        _ => "failed",
306
    }
307
}
308
309
/// The runner a bearer token belongs to: a registered runner, or one
310
/// configured with a hub.toml token (tracked from its first request). A
311
/// disabled runner is refused.
312
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
313
    let Some(t) = oxy_server::bearer_token(headers) else {
314
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
315
    };
316
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
317
    let runner = match db::runners::by_token(&app.db, &hash).await? {
318
        Some(r) => r,
319
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
320
            db::runners::configured(&app.db, &hash, "runner").await?
321
        }
322
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
323
    };
324
    if runner.state == "disabled" {
325
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
326
    }
327
    Ok(runner)
328
}
329
330
/// `POST /api/v1/flow/runners/register`: a one-time registration token
331
/// from Admin → Runners buys a runner credential, binding the runner's
332
/// signing identity (hub spec §13.2).
333
pub async fn register(
334
    State(app): State<Shared>,
335
    Json(req): Json<flow_core::api::Register>,
336
) -> Result<Json<flow_core::api::Registered>, ApiError> {
337
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
338
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
339
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
340
    if name.is_empty() {
341
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
342
    }
343
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
344
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
345
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
346
    let runner =
347
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
348
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
349
        })?;
350
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
351
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
352
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
353
}
354
355
/// `POST /api/v1/flow/claim`.
356
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
357
    let me = runner_auth(&app, &headers).await?;
358
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
359
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
360
    if let Some(holding) = &req.holding {
361
        end_jobs_not_held(&app, &me, holding).await?;
362
    }
363
    // A draining runner finishes what it has and takes nothing new.
364
    if me.state == "draining" {
365
        return Ok(StatusCode::NO_CONTENT.into_response());
366
    }
367
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
368
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
369
    let room = fdb::Room {
370
        memory_mib: req.free_memory_mib,
371
        cpus: req.free_cpus,
372
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
373
    };
374
    // The labels it reports now, or those it registered with.
375
    let labels: Vec<String> = match &req.info {
376
        Some(i) => i.labels.clone(),
377
        None => me.label_list().into_iter().map(String::from).collect(),
378
    };
379
    let Some(job) =
380
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
381
    else {
382
        return Ok(StatusCode::NO_CONTENT.into_response());
383
    };
384
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
385
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
386
    let row =
387
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
388
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
389
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
390
    let claim = Claim {
391
        job_id: job.id,
392
        run_id: run.id,
393
        repo: format!("{}/{}", row.owner, row.name),
394
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
395
        token: secret,
396
        heads: run.head_list().into_iter().map(String::from).collect(),
397
        pipeline_hash: run.pipeline_hash.clone(),
398
        job: serde_json::from_str(&job.spec).map_err(bad)?,
399
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
400
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
401
        subrepos: vec![],
402
        state: run.state.clone(),
403
        bookmark: run.bookmark.clone(),
404
        run_number: run.number,
405
    };
406
    let mut claim = claim;
407
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
408
    if !ids.is_empty() {
409
        fdb::set_also_read(&app.db, job.id, &ids).await?;
410
    }
411
    claim.subrepos = inputs;
412
    Ok(Json(claim).into_response())
413
}
414
415
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
416
async fn pins_in(
417
    app: &Shared,
418
    name: crate::repos::RepoName,
419
    heads: Vec<oxy_core::hash::ChangeHash>,
420
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
421
    app.repos
422
        .read(&name, move |repo, cache| {
423
            let loaded = cache.load(repo, &heads)?;
424
            let mut out = Vec::new();
425
            for (path, f) in &loaded.snapshot.files {
426
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
427
                    continue;
428
                }
429
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
430
                    if let Some(b) = blobs.first() {
431
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
432
                            .read_blob(b)
433
                            .map_err(crate::repos::RepoError::Oxy)
434
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
435
                        {
436
                            out.push((path.clone(), id, heads));
437
                        }
438
                    }
439
                }
440
            }
441
            Ok(out)
442
        })
443
        .await
444
        .unwrap_or_default()
445
}
446
447
/// How deep subrepos of subrepos are followed.
448
const MAX_SUBREPO_DEPTH: usize = 3;
449
450
/// The Oxygen subrepos the run's state pins, and those their states pin in
451
/// turn (parents first, paths joined), that live on this hub and that the
452
/// run's pusher may read: what the job's workspace needs beside its own
453
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
454
/// then fails on the missing files with an ordinary error.
455
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
456
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
457
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
458
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
459
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
460
    let mut inputs = Vec::new();
461
    let mut ids = Vec::new();
462
    // (path prefix, repository, heads, depth), parents before children.
463
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
464
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
465
        if depth == MAX_SUBREPO_DEPTH {
466
            continue;
467
        }
468
        for (path, id, pinned) in pins_in(app, name, heads).await {
469
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
470
            for other in &rows {
471
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
472
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
473
                if !matches {
474
                    continue;
475
                }
476
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
477
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
478
                    inputs.push(flow_core::api::SubrepoInput {
479
                        path: path.clone(),
480
                        repo: format!("{}/{}", other.owner, other.name),
481
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
482
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
483
                    });
484
                    if !ids.contains(&other.id) {
485
                        ids.push(other.id);
486
                    }
487
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
488
                }
489
                break;
490
            }
491
        }
492
    }
493
    (inputs, ids)
494
}
495
496
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
497
    match fdb::job(&app.db, id).await? {
498
        Some(j) if j.status == "running" => Ok(j),
499
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
500
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
501
    }
502
}
503
504
/// `POST /api/v1/flow/jobs/{id}/log`.
505
pub async fn log(
506
    State(app): State<Shared>,
507
    headers: HeaderMap,
508
    Path(id): Path<i64>,
509
    Json(mut batch): Json<LogBatch>,
510
) -> Result<StatusCode, ApiError> {
511
    runner_auth(&app, &headers).await?;
512
    let job = running_job(&app, id).await?;
513
    for r in &mut batch.records {
514
        if r.text.len() > MAX_RECORD_TEXT {
515
            let mut cut = MAX_RECORD_TEXT;
516
            while !r.text.is_char_boundary(cut) {
517
                cut -= 1;
518
            }
519
            r.text.truncate(cut);
520
        }
521
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
522
            r.stream = "console".into();
523
        }
524
    }
525
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
526
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
527
        tracing::info!("flow: job {} reached the log limit", job.id);
528
    }
529
    Ok(StatusCode::NO_CONTENT)
530
}
531
532
/// Largest artifact the hub keeps; the runner refuses larger ones first.
533
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
534
535
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
536
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
537
/// repository. Answers `{"blob"}`.
538
pub async fn artifact(
539
    State(app): State<Shared>,
540
    headers: HeaderMap,
541
    Path((id, name)): Path<(i64, String)>,
542
    body: axum::body::Bytes,
543
) -> Result<Response, ApiError> {
544
    runner_auth(&app, &headers).await?;
545
    let job = running_job(&app, id).await?;
546
    let spec: flow_core::Job =
547
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
548
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
549
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
550
    };
551
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
552
    let row =
553
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
554
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
555
    let content = blake3::hash(&body).to_hex().to_string();
556
    let size = body.len() as i64;
557
    let blob = app
558
        .repos
559
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
560
        .await
561
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
562
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
563
    fdb::add_artifact(&app.db, &a).await?;
564
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
565
}
566
567
/// `POST /api/v1/flow/jobs/{id}/finish`.
568
pub async fn finish(
569
    State(app): State<Shared>,
570
    headers: HeaderMap,
571
    Path(id): Path<i64>,
572
    Json(f): Json<Finish>,
573
) -> Result<StatusCode, ApiError> {
574
    let me = runner_auth(&app, &headers).await?;
575
    let job = running_job(&app, id).await?;
576
    let status = match (f.exit_code, &f.error) {
577
        (Some(0), None) => "passed",
578
        (Some(_), _) => "failed",
579
        (None, _) => "error",
580
    };
581
    let (receipt, receipt_hash) = match &f.receipt {
582
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
583
            // The receipt names exactly the artifacts uploaded, in name order.
584
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
585
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
586
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
587
            }
588
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
589
        }
590
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
591
        None => (String::new(), String::new()),
592
    };
593
    let error = f.error.clone().unwrap_or_default();
594
    let changed = fdb::finish(
595
        &app.db,
596
        job.id,
597
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
598
    )
599
    .await?;
600
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
601
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
602
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
603
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
604
                    Ok((attest, signer)) => (attest, signer, String::new()),
605
                    Err(e) => {
606
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
607
                        (String::new(), String::new(), e)
608
                    }
609
                };
610
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
611
            }
612
        }
613
    }
614
    report_changed(&app, job.run_id, changed).await?;
615
    Ok(StatusCode::NO_CONTENT)
616
}
617
618
/// Report the checks of jobs whose status changed.
619
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
620
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
621
        return Ok(());
622
    };
623
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
624
        return Ok(());
625
    };
626
    for j in changed {
627
        let summary = match j.status.as_str() {
628
            "passed" => format!("passed in {}", duration(&j)),
629
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
630
            "skipped" => "skipped: a dependency did not pass".into(),
631
            _ => format!("could not run: {}", j.error),
632
        };
633
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
634
    }
635
    Ok(())
636
}
637
638
/// Grace past the job time limit before the hub gives a job up: a runner
639
/// stops a job at the limit and reports it within moments, so one still
640
/// running this long after is one the runner no longer reports (it was
641
/// restarted, or lost the hub while the job ended, oxygenhub#71).
642
const LOST_GRACE_MS: i64 = 5 * 60 * 1000;
643
644
/// End every job still running past the job time limit plus the grace:
645
/// it is marked an error with the reason, the jobs that depended on it are
646
/// skipped and its run settled, as when a runner reports an error.
647
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
648
    let limit_ms = app.config.flow.job_timeout_minutes.max(1) as i64 * 60_000 + LOST_GRACE_MS;
649
    let lost = fdb::running_since_before(&app.db, db::now_ms() - limit_ms).await?;
650
    let n = lost.len();
651
    for job in lost {
652
        let error = format!(
653
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
654
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
655
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
656
        );
657
        let changed =
658
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
659
                .await?;
660
        tracing::warn!("flow: job {} {error}", job.id);
661
        report_changed(app, job.run_id, changed).await?;
662
    }
663
    Ok(n)
664
}
665
666
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
667
/// it restarted since claiming them, or never received the claim. A
668
/// runner claims one job at a time, so a job it holds is always listed.
669
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
670
    for job in fdb::running_on(&app.db, runner.id).await? {
671
        if holding.contains(&job.id) {
672
            continue;
673
        }
674
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
675
        let changed =
676
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
677
                .await?;
678
        tracing::warn!("flow: job {} {error}", job.id);
679
        report_changed(app, job.run_id, changed).await?;
680
    }
681
    Ok(())
682
}
683
684
/// Look for lost jobs once a minute.
685
pub async fn reaper(app: Shared) {
686
    loop {
687
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
688
        if let Err(e) = reap_lost_jobs(&app).await {
689
            tracing::warn!("flow: looking for lost jobs: {e}");
690
        }
691
    }
692
}
693
694
/// Check a runner's signed receipt for a job of `run` and keep it in the
695
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
696
/// name, or why it was not kept. It must be the job's receipt, for the
697
/// run's state and repository, signed by the key the runner's token first
698
/// signed with.
699
pub async fn keep_receipt(
700
    app: &Shared,
701
    runner: &db::runners::Runner,
702
    row: &db::repos::RepoRow,
703
    run: &fdb::Run,
704
    receipt: &flow_core::Receipt,
705
    signed_hex: &str,
706
) -> Result<(String, String), String> {
707
    use oxy_core::receipt::SignedReceipt;
708
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
709
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
710
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
711
    if sr.receipt.hash().0 != receipt.hash() {
712
        return Err("it signs another receipt than the job's".into());
713
    }
714
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
715
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
716
    if sr.state() != state {
717
        return Err("it is for another state than the run's".into());
718
    }
719
    let key = sr.runner.hash().to_hex();
720
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
721
    if bound != key {
722
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
723
    }
724
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
725
    let signer = sr.runner.name.clone();
726
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
727
    Ok((attest.to_hex(), signer))
728
}
729
730
/// A job's wall time, as the pages show it.
731
pub fn duration(j: &fdb::Job) -> String {
732
    match (j.started_ms, j.finished_ms) {
733
        (Some(a), Some(b)) => human_ms(b - a),
734
        (Some(a), None) => human_ms(db::now_ms() - a),
735
        _ => "–".into(),
736
    }
737
}
738
739
pub fn human_ms(ms: i64) -> String {
740
    let ms = ms.max(0);
741
    if ms < 1000 {
742
        format!("{ms} ms")
743
    } else if ms < 60_000 {
744
        format!("{:.1} s", ms as f64 / 1000.0)
745
    } else {
746
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
747
    }
748
}
749
750
/// Where a live run stands among the runners' work: what its viewers may
751
/// know of it. Jobs of other repositories are named only when those are
752
/// public; the rest are counted.
753
#[derive(Clone, Debug, Default, PartialEq, Eq)]
754
pub struct QueueInfo {
755
    /// 1-based place of the run's first queued job among all queued jobs.
756
    pub position: Option<usize>,
757
    /// Jobs running now, and queued jobs ahead of the run's first one.
758
    pub running: Vec<String>,
759
    pub ahead: Vec<String>,
760
    /// Of those, jobs in repositories the page may not name.
761
    pub hidden: usize,
762
}
763
764
impl QueueInfo {
765
    /// "3rd in line" and the like.
766
    pub fn place(&self) -> Option<String> {
767
        self.position.map(|n| {
768
            let suffix = match (n % 10, n % 100) {
769
                (1, 11) | (2, 12) | (3, 13) => "th",
770
                (1, _) => "st",
771
                (2, _) => "nd",
772
                (3, _) => "rd",
773
                _ => "th",
774
            };
775
            format!("{n}{suffix} in line")
776
        })
777
    }
778
}
779
780
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
781
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
782
    let mut info = QueueInfo::default();
783
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
784
    info.position = first.map(|i| i + 1);
785
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
786
    let mut queued_seen = 0;
787
    for e in queue {
788
        if e.run_id == run_id {
789
            continue;
790
        }
791
        let ahead = match e.status.as_str() {
792
            "running" => true,
793
            _ => {
794
                queued_seen += 1;
795
                first.is_some_and(|f| queued_seen <= f)
796
            }
797
        };
798
        if !ahead {
799
            continue;
800
        }
801
        if e.repo_id != repo_id && e.visibility != "public" {
802
            info.hidden += 1;
803
        } else if e.status == "running" {
804
            info.running.push(label(e));
805
        } else {
806
            info.ahead.push(label(e));
807
        }
808
    }
809
    info
810
}
811
812
#[cfg(test)]
813
mod queue_tests {
814
    use super::*;
815
816
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
817
        fdb::QueueEntry {
818
            job_id,
819
            run_id,
820
            repo_id,
821
            owner: "o".into(),
822
            name: format!("r{repo_id}"),
823
            visibility: vis.into(),
824
            run_number: run_id,
825
            job: job.into(),
826
            status: status.into(),
827
            runner: String::new(),
828
            spec: String::new(),
829
            sandbox: String::new(),
830
        }
831
    }
832
833
    #[test]
834
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
835
        // Running first, then queued in claim order (as `fdb::queue` returns them).
836
        let q = vec![
837
            e(1, 10, 1, "public", "build", "running"),
838
            e(2, 11, 2, "private", "test", "running"),
839
            e(3, 10, 1, "public", "test", "queued"),
840
            e(4, 12, 3, "private", "lint", "queued"),
841
            e(5, 13, 4, "public", "build", "queued"),
842
            e(6, 13, 4, "public", "test", "queued"),
843
            e(7, 14, 5, "public", "late", "queued"),
844
        ];
845
        let info = queue_info(&q, 4, 13);
846
        assert_eq!(info.position, Some(3));
847
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
848
        assert_eq!(info.running, ["o/r1 #10 build"]);
849
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
850
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
851
        // A private repository's own jobs are named to its viewers.
852
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
853
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
854
        // A run with nothing queued has no place.
855
        assert_eq!(queue_info(&q, 1, 99).position, None);
856
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
857
        assert_eq!(
858
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
859
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
860
        );
861
    }
862
}