OXYGENHUB
# 7f5f6c056d8f oxygenhub / crates / hub / src / flow.rs
39.5 KiB · Rust · last changed A runner's own labels and time limit, from its admin page 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, and those
375
    // an administrator gave it (#98).
376
    let mut labels: Vec<String> = match &req.info {
377
        Some(i) => i.labels.clone(),
378
        None => me.reported_label_list().into_iter().map(String::from).collect(),
379
    };
380
    for l in me.admin_label_list() {
381
        if !labels.iter().any(|x| x == l) {
382
            labels.push(l.to_string());
383
        }
384
    }
385
    let Some(job) =
386
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
387
    else {
388
        return Ok(StatusCode::NO_CONTENT.into_response());
389
    };
390
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
391
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
392
    let row =
393
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
394
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
395
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
396
    // The job's limit: its own timeout, else its sandbox's, else the site
397
    // default, never past the site maximum; recorded for the reaper.
398
    let limits = limits(&app).await;
399
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
400
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
401
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
402
    // ...and never past the runner's own limit, when it has one (#98).
403
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
404
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
405
    }
406
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
407
    let claim = Claim {
408
        job_id: job.id,
409
        run_id: run.id,
410
        repo: format!("{}/{}", row.owner, row.name),
411
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
412
        token: secret,
413
        heads: run.head_list().into_iter().map(String::from).collect(),
414
        pipeline_hash: run.pipeline_hash.clone(),
415
        job: spec,
416
        sandbox,
417
        timeout_s,
418
        subrepos: vec![],
419
        state: run.state.clone(),
420
        bookmark: run.bookmark.clone(),
421
        run_number: run.number,
422
    };
423
    let mut claim = claim;
424
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
425
    if !ids.is_empty() {
426
        fdb::set_also_read(&app.db, job.id, &ids).await?;
427
    }
428
    claim.subrepos = inputs;
429
    Ok(Json(claim).into_response())
430
}
431
432
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
433
async fn pins_in(
434
    app: &Shared,
435
    name: crate::repos::RepoName,
436
    heads: Vec<oxy_core::hash::ChangeHash>,
437
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
438
    app.repos
439
        .read(&name, move |repo, cache| {
440
            let loaded = cache.load(repo, &heads)?;
441
            let mut out = Vec::new();
442
            for (path, f) in &loaded.snapshot.files {
443
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
444
                    continue;
445
                }
446
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
447
                    if let Some(b) = blobs.first() {
448
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
449
                            .read_blob(b)
450
                            .map_err(crate::repos::RepoError::Oxy)
451
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
452
                        {
453
                            out.push((path.clone(), id, heads));
454
                        }
455
                    }
456
                }
457
            }
458
            Ok(out)
459
        })
460
        .await
461
        .unwrap_or_default()
462
}
463
464
/// How deep subrepos of subrepos are followed.
465
const MAX_SUBREPO_DEPTH: usize = 3;
466
467
/// The Oxygen subrepos the run's state pins, and those their states pin in
468
/// turn (parents first, paths joined), that live on this hub and that the
469
/// run's pusher may read: what the job's workspace needs beside its own
470
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
471
/// then fails on the missing files with an ordinary error.
472
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
473
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
474
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
475
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
476
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
477
    let mut inputs = Vec::new();
478
    let mut ids = Vec::new();
479
    // (path prefix, repository, heads, depth), parents before children.
480
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
481
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
482
        if depth == MAX_SUBREPO_DEPTH {
483
            continue;
484
        }
485
        for (path, id, pinned) in pins_in(app, name, heads).await {
486
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
487
            for other in &rows {
488
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
489
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
490
                if !matches {
491
                    continue;
492
                }
493
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
494
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
495
                    inputs.push(flow_core::api::SubrepoInput {
496
                        path: path.clone(),
497
                        repo: format!("{}/{}", other.owner, other.name),
498
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
499
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
500
                    });
501
                    if !ids.contains(&other.id) {
502
                        ids.push(other.id);
503
                    }
504
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
505
                }
506
                break;
507
            }
508
        }
509
    }
510
    (inputs, ids)
511
}
512
513
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
514
    match fdb::job(&app.db, id).await? {
515
        Some(j) if j.status == "running" => Ok(j),
516
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
517
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
518
    }
519
}
520
521
/// `POST /api/v1/flow/jobs/{id}/log`.
522
pub async fn log(
523
    State(app): State<Shared>,
524
    headers: HeaderMap,
525
    Path(id): Path<i64>,
526
    Json(mut batch): Json<LogBatch>,
527
) -> Result<StatusCode, ApiError> {
528
    runner_auth(&app, &headers).await?;
529
    let job = running_job(&app, id).await?;
530
    for r in &mut batch.records {
531
        if r.text.len() > MAX_RECORD_TEXT {
532
            let mut cut = MAX_RECORD_TEXT;
533
            while !r.text.is_char_boundary(cut) {
534
                cut -= 1;
535
            }
536
            r.text.truncate(cut);
537
        }
538
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
539
            r.stream = "console".into();
540
        }
541
    }
542
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
543
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
544
        tracing::info!("flow: job {} reached the log limit", job.id);
545
    }
546
    Ok(StatusCode::NO_CONTENT)
547
}
548
549
/// Largest artifact the hub keeps; the runner refuses larger ones first.
550
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
551
552
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
553
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
554
/// repository. Answers `{"blob"}`.
555
pub async fn artifact(
556
    State(app): State<Shared>,
557
    headers: HeaderMap,
558
    Path((id, name)): Path<(i64, String)>,
559
    body: axum::body::Bytes,
560
) -> Result<Response, ApiError> {
561
    runner_auth(&app, &headers).await?;
562
    let job = running_job(&app, id).await?;
563
    let spec: flow_core::Job =
564
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
565
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
566
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
567
    };
568
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
569
    let row =
570
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
571
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
572
    let content = blake3::hash(&body).to_hex().to_string();
573
    let size = body.len() as i64;
574
    let blob = app
575
        .repos
576
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
577
        .await
578
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
579
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
580
    fdb::add_artifact(&app.db, &a).await?;
581
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
582
}
583
584
/// `POST /api/v1/flow/jobs/{id}/finish`.
585
pub async fn finish(
586
    State(app): State<Shared>,
587
    headers: HeaderMap,
588
    Path(id): Path<i64>,
589
    Json(f): Json<Finish>,
590
) -> Result<StatusCode, ApiError> {
591
    let me = runner_auth(&app, &headers).await?;
592
    let job = running_job(&app, id).await?;
593
    let status = match (f.exit_code, &f.error) {
594
        (Some(0), None) => "passed",
595
        (Some(_), _) => "failed",
596
        (None, _) => "error",
597
    };
598
    let (receipt, receipt_hash) = match &f.receipt {
599
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
600
            // The receipt names exactly the artifacts uploaded, in name order.
601
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
602
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
603
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
604
            }
605
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
606
        }
607
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
608
        None => (String::new(), String::new()),
609
    };
610
    let error = f.error.clone().unwrap_or_default();
611
    let changed = fdb::finish(
612
        &app.db,
613
        job.id,
614
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
615
    )
616
    .await?;
617
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
618
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
619
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
620
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
621
                    Ok((attest, signer)) => (attest, signer, String::new()),
622
                    Err(e) => {
623
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
624
                        (String::new(), String::new(), e)
625
                    }
626
                };
627
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
628
            }
629
        }
630
    }
631
    report_changed(&app, job.run_id, changed).await?;
632
    Ok(StatusCode::NO_CONTENT)
633
}
634
635
/// Report the checks of jobs whose status changed.
636
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
637
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
638
        return Ok(());
639
    };
640
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
641
        return Ok(());
642
    };
643
    for j in changed {
644
        let summary = match j.status.as_str() {
645
            "passed" => format!("passed in {}", duration(&j)),
646
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
647
            "skipped" => "skipped: a dependency did not pass".into(),
648
            _ => format!("could not run: {}", j.error),
649
        };
650
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
651
    }
652
    Ok(())
653
}
654
655
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
656
/// Settings, else from `hub.toml`.
657
pub struct Limits {
658
    /// For a job whose pipeline names none.
659
    pub default_s: u64,
660
    /// The most a pipeline may ask for.
661
    pub max_s: u64,
662
    /// Grace past a job's limit before the hub gives it up: a runner stops
663
    /// a job at its limit and reports it within moments, so one still
664
    /// running this long after is one the runner no longer reports (it was
665
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
666
    pub grace_ms: i64,
667
}
668
669
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
670
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
671
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
672
673
pub async fn limits(app: &Shared) -> Limits {
674
    let minutes = |key: &'static str| async move {
675
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
676
    };
677
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
678
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
679
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
680
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
681
}
682
683
/// End every job still running past its time limit plus the grace:
684
/// it is marked an error with the reason, the jobs that depended on it are
685
/// skipped and its run settled, as when a runner reports an error.
686
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
687
    let limits = limits(app).await;
688
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
689
    let n = lost.len();
690
    for job in lost {
691
        let error = format!(
692
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
693
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
694
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
695
        );
696
        let changed =
697
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
698
                .await?;
699
        tracing::warn!("flow: job {} {error}", job.id);
700
        report_changed(app, job.run_id, changed).await?;
701
    }
702
    Ok(n)
703
}
704
705
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
706
/// it restarted since claiming them, or never received the claim. A
707
/// runner claims one job at a time, so a job it holds is always listed.
708
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
709
    for job in fdb::running_on(&app.db, runner.id).await? {
710
        if holding.contains(&job.id) {
711
            continue;
712
        }
713
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
714
        let changed =
715
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
716
                .await?;
717
        tracing::warn!("flow: job {} {error}", job.id);
718
        report_changed(app, job.run_id, changed).await?;
719
    }
720
    Ok(())
721
}
722
723
/// Look for lost jobs once a minute.
724
pub async fn reaper(app: Shared) {
725
    loop {
726
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
727
        if let Err(e) = reap_lost_jobs(&app).await {
728
            tracing::warn!("flow: looking for lost jobs: {e}");
729
        }
730
    }
731
}
732
733
/// Check a runner's signed receipt for a job of `run` and keep it in the
734
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
735
/// name, or why it was not kept. It must be the job's receipt, for the
736
/// run's state and repository, signed by the key the runner's token first
737
/// signed with.
738
pub async fn keep_receipt(
739
    app: &Shared,
740
    runner: &db::runners::Runner,
741
    row: &db::repos::RepoRow,
742
    run: &fdb::Run,
743
    receipt: &flow_core::Receipt,
744
    signed_hex: &str,
745
) -> Result<(String, String), String> {
746
    use oxy_core::receipt::SignedReceipt;
747
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
748
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
749
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
750
    if sr.receipt.hash().0 != receipt.hash() {
751
        return Err("it signs another receipt than the job's".into());
752
    }
753
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
754
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
755
    if sr.state() != state {
756
        return Err("it is for another state than the run's".into());
757
    }
758
    let key = sr.runner.hash().to_hex();
759
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
760
    if bound != key {
761
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
762
    }
763
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
764
    let signer = sr.runner.name.clone();
765
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
766
    Ok((attest.to_hex(), signer))
767
}
768
769
/// A job's wall time, as the pages show it.
770
pub fn duration(j: &fdb::Job) -> String {
771
    match (j.started_ms, j.finished_ms) {
772
        (Some(a), Some(b)) => human_ms(b - a),
773
        (Some(a), None) => human_ms(db::now_ms() - a),
774
        _ => "–".into(),
775
    }
776
}
777
778
pub fn human_ms(ms: i64) -> String {
779
    let ms = ms.max(0);
780
    if ms < 1000 {
781
        format!("{ms} ms")
782
    } else if ms < 60_000 {
783
        format!("{:.1} s", ms as f64 / 1000.0)
784
    } else {
785
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
786
    }
787
}
788
789
/// Where a live run stands among the runners' work: what its viewers may
790
/// know of it. Jobs of other repositories are named only when those are
791
/// public; the rest are counted.
792
#[derive(Clone, Debug, Default, PartialEq, Eq)]
793
pub struct QueueInfo {
794
    /// 1-based place of the run's first queued job among all queued jobs.
795
    pub position: Option<usize>,
796
    /// Jobs running now, and queued jobs ahead of the run's first one.
797
    pub running: Vec<String>,
798
    pub ahead: Vec<String>,
799
    /// Of those, jobs in repositories the page may not name.
800
    pub hidden: usize,
801
}
802
803
impl QueueInfo {
804
    /// "3rd in line" and the like.
805
    pub fn place(&self) -> Option<String> {
806
        self.position.map(|n| {
807
            let suffix = match (n % 10, n % 100) {
808
                (1, 11) | (2, 12) | (3, 13) => "th",
809
                (1, _) => "st",
810
                (2, _) => "nd",
811
                (3, _) => "rd",
812
                _ => "th",
813
            };
814
            format!("{n}{suffix} in line")
815
        })
816
    }
817
}
818
819
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
820
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
821
    let mut info = QueueInfo::default();
822
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
823
    info.position = first.map(|i| i + 1);
824
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
825
    let mut queued_seen = 0;
826
    for e in queue {
827
        if e.run_id == run_id {
828
            continue;
829
        }
830
        let ahead = match e.status.as_str() {
831
            "running" => true,
832
            _ => {
833
                queued_seen += 1;
834
                first.is_some_and(|f| queued_seen <= f)
835
            }
836
        };
837
        if !ahead {
838
            continue;
839
        }
840
        if e.repo_id != repo_id && e.visibility != "public" {
841
            info.hidden += 1;
842
        } else if e.status == "running" {
843
            info.running.push(label(e));
844
        } else {
845
            info.ahead.push(label(e));
846
        }
847
    }
848
    info
849
}
850
851
#[cfg(test)]
852
mod queue_tests {
853
    use super::*;
854
855
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
856
        fdb::QueueEntry {
857
            job_id,
858
            run_id,
859
            repo_id,
860
            owner: "o".into(),
861
            name: format!("r{repo_id}"),
862
            visibility: vis.into(),
863
            run_number: run_id,
864
            job: job.into(),
865
            status: status.into(),
866
            runner: String::new(),
867
            spec: String::new(),
868
            sandbox: String::new(),
869
        }
870
    }
871
872
    #[test]
873
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
874
        // Running first, then queued in claim order (as `fdb::queue` returns them).
875
        let q = vec![
876
            e(1, 10, 1, "public", "build", "running"),
877
            e(2, 11, 2, "private", "test", "running"),
878
            e(3, 10, 1, "public", "test", "queued"),
879
            e(4, 12, 3, "private", "lint", "queued"),
880
            e(5, 13, 4, "public", "build", "queued"),
881
            e(6, 13, 4, "public", "test", "queued"),
882
            e(7, 14, 5, "public", "late", "queued"),
883
        ];
884
        let info = queue_info(&q, 4, 13);
885
        assert_eq!(info.position, Some(3));
886
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
887
        assert_eq!(info.running, ["o/r1 #10 build"]);
888
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
889
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
890
        // A private repository's own jobs are named to its viewers.
891
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
892
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
893
        // A run with nothing queued has no place.
894
        assert_eq!(queue_info(&q, 1, 99).position, None);
895
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
896
        assert_eq!(
897
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
898
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
899
        );
900
    }
901
}