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