OXYGENHUB
◆ wiki-says-so oxygenhub / crates / hub / src / flow.rs
74.7 KiB · Rust · last changed Flow: say why a queued job waits, in oh and its check 25 days ago source blame history raw
42fa9554 Andy McCall ↶ 1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
9008bf6c Andy McCall ↶ 22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        "on" => true,
27
        "off" => false,
28
        _ => app.config.flow.allows(&row.owner, &row.name),
29
    }
30
}
31
42fa9554 Andy McCall ↶ 32
/// A bookmark moved by a push: start a run when the repository is allowed
33
/// and the new state has a pipeline.
34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
c889911b Andy McCall ↶ 35
    if heads.is_empty() || !enabled(app, row) {
42fa9554 Andy McCall ↶ 36
        return;
37
    }
c889911b Andy McCall ↶ 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;
745d225d Andy McCall ↶ 43
            let started = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await;
44
            if let Ok(Some(n)) = started {
45
                supersede(app, row, bookmark, n).await;
46
            }
47
            started
c889911b Andy McCall ↶ 48
        }
49
    };
50
    if let Err(e) = started {
1edac4b6 Andy McCall ↶ 51
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
52
    }
53
}
54
55
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
56
/// moving it. Returns the new run's number.
0650d94e Andy McCall ↶ 57
/// `jobs`, when not empty, are the only jobs to start (oxygenhub#130), with
58
/// the jobs they depend on.
59
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
1edac4b6 Andy McCall ↶ 60
    if !enabled(app, row) {
61
        return Err("OxygenFlow is not enabled for this repository".into());
62
    }
63
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
64
    let b = bookmark.to_string();
65
    let found = app
66
        .repos
67
        .read(&repo, move |r, _| {
68
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
69
            Ok(Some((st, r.store().get_state(&st)?.heads)))
70
        })
71
        .await
72
        .map_err(|e| e.to_string())?;
73
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
74
    if heads.is_empty() {
75
        return Err(format!("{bookmark} has no changes yet"));
76
    }
0650d94e Andy McCall ↶ 77
    start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs)
1edac4b6 Andy McCall ↶ 78
        .await?
79
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
80
}
81
4f692533 Andy McCall ↶ 82
/// "Run pipeline" on one committed change: the state whose only head is
83
/// `change`, whatever bookmarks hold it. Returns the new run's number.
0650d94e Andy McCall ↶ 84
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
4f692533 Andy McCall ↶ 85
    if !enabled(app, row) {
86
        return Err("OxygenFlow is not enabled for this repository".into());
87
    }
88
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
89
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
df15b747 Andy McCall ↶ 90
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
4f692533 Andy McCall ↶ 91
    if !known {
92
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
93
    }
94
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
0650d94e Andy McCall ↶ 95
    start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs)
4f692533 Andy McCall ↶ 96
        .await?
97
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
98
}
99
1edac4b6 Andy McCall ↶ 100
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
101
/// run whose checks replace the old ones. Returns the new run's number.
102
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
103
    if !enabled(app, row) {
104
        return Err("OxygenFlow is not enabled for this repository".into());
105
    }
106
    if run.live() {
107
        return Err(format!("run #{} is still {}", run.number, run.status));
108
    }
109
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
110
    if heads.is_empty() {
111
        return Err("the run names no changes".into());
112
    }
c889911b Andy McCall ↶ 113
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
114
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
115
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
1edac4b6 Andy McCall ↶ 116
        .await?
117
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
745d225d Andy McCall ↶ 118
}
119
120
/// Cancel a run still going (oxygenflow#32), its checks with it.
121
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
122
    if !run.live() {
123
        return Err(format!("run #{} is already {}", run.number, run.status));
124
    }
125
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
126
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
127
        "the run could not be cancelled".to_string()
128
    })
129
}
130
131
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
132
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
133
    report_changed(app, run.id, changed).await
c889911b Andy McCall ↶ 134
}
135
745d225d Andy McCall ↶ 136
/// A push makes the push runs still going on its bookmark moot: they are
137
/// cancelled, and the new run's scope reaches back past them.
138
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, number: i64) {
139
    let Ok(older) = fdb::live_push_runs_before(&app.db, row.id, bookmark, number).await else { return };
140
    for run in older {
141
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
142
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
143
        }
144
    }
145
}
146
c889911b Andy McCall ↶ 147
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
148
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
149
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
150
    use flow_core::pipeline::Event;
151
    let t = match trigger.strip_prefix("rerun:") {
152
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
153
        None => trigger,
154
    };
155
    if let Some(tag) = t.strip_prefix("tag:") {
156
        Event::Tag(tag)
157
    } else if t == "push" {
158
        Event::Push(bookmark)
159
    } else if t == "schedule" {
160
        Event::Schedule
161
    } else {
162
        Event::Manual
163
    }
1edac4b6 Andy McCall ↶ 164
}
165
c889911b Andy McCall ↶ 166
/// The event to repeat when rerunning `run`.
167
fn original_event(run: &fdb::Run) -> String {
168
    let t = run.trigger.as_str();
169
    match t.strip_prefix("rerun:") {
170
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
171
        None => t.to_string(),
172
    }
173
}
174
d7ce41ac Andy McCall ↶ 175
/// The paths the changes a push brought touch: the changes in `heads`'
176
/// state that the bookmark's previous run did not have, and the files their
177
/// edits and tree operations name, as the new state places them. `None`
178
/// (run every job) for the bookmark's first run, or when it cannot be told.
179
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
180
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
181
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
182
    if old.is_empty() {
183
        return None;
184
    }
185
    let repo = RepoName::new(&row.owner, &row.name)?;
186
    let new = heads.to_vec();
187
    app.repos
188
        .read(&repo, move |r, cache| {
189
            let fresh = r.negotiate(&old, &new)?;
190
            let loaded = cache.load(r, &new)?;
191
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
192
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
193
            let mut paths = std::collections::BTreeSet::new();
194
            for h in fresh {
195
                let (c, _) = r.store().get_change_lazy(&h)?;
196
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
197
                for n in nodes {
198
                    if let Some(p) = by_node.get(&n) {
199
                        paths.insert((*p).clone());
200
                    }
201
                }
202
            }
203
            Ok(paths.into_iter().collect::<Vec<String>>())
204
        })
205
        .await
206
        .ok()
207
}
208
209
/// Whether a job runs for a push that touched `touched`: always without a
210
/// scope or without knowing what was touched, else when a path matches.
211
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
212
    let Some(touched) = touched else { return true };
213
    if scope.is_empty() {
214
        return true;
215
    }
216
    let mut b = globset::GlobSetBuilder::new();
217
    for g in scope {
218
        match globset::Glob::new(g) {
219
            Ok(g) => {
220
                b.add(g);
221
            }
222
            Err(_) => return true,
223
        }
224
    }
225
    let Ok(set) = b.build() else { return true };
226
    touched.iter().any(|p| set.is_match(p))
227
}
228
229
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
230
/// left out of the run, unless a job that runs depends on them.
231
#[allow(clippy::too_many_arguments)]
232
pub async fn start_run_scoped(
233
    app: &Shared,
234
    row: &db::repos::RepoRow,
235
    bookmark: &str,
236
    state: &str,
237
    heads: &[ChangeHash],
238
    actor: &str,
239
    trigger: &str,
240
    touched: Option<&[String]>,
241
) -> Result<Option<i64>, String> {
242
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
243
}
244
1edac4b6 Andy McCall ↶ 245
/// Start a run of the pipeline in the state `heads` make (whose state
246
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
247
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
248
/// whose access the run's subrepo reads are checked against. Returns the
249
/// run's number, or `None` when the state has no pipeline. The caller
250
/// checks that Flow is enabled.
251
pub async fn start_run(
252
    app: &Shared,
253
    row: &db::repos::RepoRow,
254
    bookmark: &str,
255
    state: &str,
256
    heads: &[ChangeHash],
257
    actor: &str,
258
    trigger: &str,
259
) -> Result<Option<i64>, String> {
d7ce41ac Andy McCall ↶ 260
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
261
}
262
263
#[allow(clippy::too_many_arguments)]
264
async fn start_run_with(
265
    app: &Shared,
266
    row: &db::repos::RepoRow,
267
    bookmark: &str,
268
    state: &str,
269
    heads: &[ChangeHash],
270
    actor: &str,
271
    trigger: &str,
272
    touched: Option<&[String]>,
273
) -> Result<Option<i64>, String> {
0650d94e Andy McCall ↶ 274
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await
348f8c4b Andy McCall ↶ 275
}
276
0650d94e Andy McCall ↶ 277
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock;
278
/// `only`, when not empty, the jobs a manual run starts (oxygenhub#130).
348f8c4b Andy McCall ↶ 279
#[allow(clippy::too_many_arguments)]
280
async fn start_run_at(
281
    app: &Shared,
282
    row: &db::repos::RepoRow,
283
    bookmark: &str,
284
    state: &str,
285
    heads: &[ChangeHash],
286
    actor: &str,
287
    trigger: &str,
288
    touched: Option<&[String]>,
289
    at: Option<flow_core::pipeline::When>,
0650d94e Andy McCall ↶ 290
    only: &[String],
348f8c4b Andy McCall ↶ 291
) -> Result<Option<i64>, String> {
1edac4b6 Andy McCall ↶ 292
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
42fa9554 Andy McCall ↶ 293
    let h = heads.to_vec();
294
    let text = app
295
        .repos
296
        .read(&repo, move |r, cache| {
297
            let loaded = cache.load(r, &h)?;
298
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
299
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
300
                _ => None,
301
            })
302
        })
1edac4b6 Andy McCall ↶ 303
        .await
304
        .map_err(|e| format!("reading the pipeline: {e}"))?;
305
    let Some(text) = text else { return Ok(None) };
42fa9554 Andy McCall ↶ 306
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
307
    hex.sort();
308
    let heads_s = hex.join("+");
1edac4b6 Andy McCall ↶ 309
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
310
    let id = match Pipeline::parse(&text) {
42fa9554 Andy McCall ↶ 311
        Err(problems) => {
312
            let msg = problems.to_string();
1edac4b6 Andy McCall ↶ 313
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
42fa9554 Andy McCall ↶ 314
        }
315
        Ok(p) => {
316
            let depths = p.depths();
d7ce41ac Andy McCall ↶ 317
            // Jobs out of the push's scope stay out, unless a kept job
318
            // needs them.
c889911b Andy McCall ↶ 319
            // The jobs this run's event starts (`on`), out of those its
320
            // push touched (`scope`); then what they depend on.
348f8c4b Andy McCall ↶ 321
            let event = match at {
322
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
323
                None => event_of(trigger, bookmark),
324
            };
0650d94e Andy McCall ↶ 325
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
326
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
327
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
328
            } else {
329
                // Named jobs (oxygenhub#130): each one this event starts.
330
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
331
                for name in only {
332
                    if !startable.contains(&name.as_str()) {
333
                        let what = if p.jobs.contains_key(name) {
334
                            "is not started by hand (no `on { manual }`)"
335
                        } else {
336
                            "is not in the pipeline"
337
                        };
338
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
339
                    }
340
                }
341
                only.iter().cloned().collect()
342
            };
d7ce41ac Andy McCall ↶ 343
            loop {
344
                let needed: Vec<String> = p
345
                    .order()
346
                    .into_iter()
347
                    .filter(|j| keep.contains(&j.name))
348
                    .flat_map(|j| j.depends_on.iter().cloned())
349
                    .filter(|d| !keep.contains(d))
350
                    .collect();
351
                if needed.is_empty() {
352
                    break;
353
                }
354
                keep.extend(needed);
355
            }
42fa9554 Andy McCall ↶ 356
            let jobs: Vec<fdb::NewJob> = p
357
                .order()
358
                .into_iter()
d7ce41ac Andy McCall ↶ 359
                .filter(|j| keep.contains(&j.name))
42fa9554 Andy McCall ↶ 360
                .map(|j| {
361
                    let sb = &p.sandboxes[&j.sandbox];
362
                    fdb::NewJob {
363
                        name: j.name.clone(),
364
                        depth: depths[j.name.as_str()] as i64,
365
                        depends: j.depends_on.clone(),
366
                        spec: serde_json::to_string(j).unwrap_or_default(),
367
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
368
                        runtime: sb.runtime.as_str().into(),
369
                    }
370
                })
371
                .collect();
c889911b Andy McCall ↶ 372
            if jobs.is_empty() {
373
                // Nothing this event starts (or this push touched).
d7ce41ac Andy McCall ↶ 374
                return Ok(None);
375
            }
42fa9554 Andy McCall ↶ 376
            let hash = hex::encode(p.hash());
1edac4b6 Andy McCall ↶ 377
            let id =
378
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
379
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
42fa9554 Andy McCall ↶ 380
                for j in &jobs {
a0d4bbfb Andy McCall ↶ 381
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
42fa9554 Andy McCall ↶ 382
                }
383
            }
384
            id
385
        }
386
    };
1edac4b6 Andy McCall ↶ 387
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
388
    Ok(Some(run.number))
42fa9554 Andy McCall ↶ 389
}
390
a0d4bbfb Andy McCall ↶ 391
/// Record a job's check; it links to the run's page by the run's number.
392
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
393
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
42fa9554 Andy McCall ↶ 394
    let name = format!("flow/{job}");
395
    let c = db::hooks::upsert_check(
396
        &app.db,
397
        row.id,
398
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
399
    )
400
    .await;
401
    if let Ok(c) = c {
402
        let api = crate::api::check_of(&c);
403
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
404
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
405
    }
406
}
407
408
/// The check status a job's status shows as.
409
fn check_status(job_status: &str) -> &'static str {
410
    match job_status {
411
        "queued" => "queued",
412
        "running" => "running",
413
        "passed" => "passed",
414
        "skipped" => "skipped",
745d225d Andy McCall ↶ 415
        "cancelled" => "cancelled",
42fa9554 Andy McCall ↶ 416
        _ => "failed",
417
    }
418
}
419
b6b7e4cf Andy McCall ↶ 420
/// The runner a bearer token belongs to: a registered runner, or one
421
/// configured with a hub.toml token (tracked from its first request). A
422
/// disabled runner is refused.
423
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
42fa9554 Andy McCall ↶ 424
    let Some(t) = oxy_server::bearer_token(headers) else {
425
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
b6b7e4cf Andy McCall ↶ 426
    };
427
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
428
    let runner = match db::runners::by_token(&app.db, &hash).await? {
429
        Some(r) => r,
430
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
431
            db::runners::configured(&app.db, &hash, "runner").await?
432
        }
433
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
42fa9554 Andy McCall ↶ 434
    };
b6b7e4cf Andy McCall ↶ 435
    if runner.state == "disabled" {
436
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
42fa9554 Andy McCall ↶ 437
    }
b6b7e4cf Andy McCall ↶ 438
    Ok(runner)
42fa9554 Andy McCall ↶ 439
}
440
b6b7e4cf Andy McCall ↶ 441
/// `POST /api/v1/flow/runners/register`: a one-time registration token
442
/// from Admin → Runners buys a runner credential, binding the runner's
443
/// signing identity (hub spec §13.2).
444
pub async fn register(
445
    State(app): State<Shared>,
446
    Json(req): Json<flow_core::api::Register>,
447
) -> Result<Json<flow_core::api::Registered>, ApiError> {
448
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
449
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
450
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
451
    if name.is_empty() {
452
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
453
    }
454
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
455
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
456
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
457
    let runner =
458
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
459
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
460
        })?;
461
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
462
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
463
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
464
}
465
42fa9554 Andy McCall ↶ 466
/// `POST /api/v1/flow/claim`.
467
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 468
    let me = runner_auth(&app, &headers).await?;
469
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
470
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
eef677c4 Andy McCall ↶ 471
    if let Some(holding) = &req.holding {
472
        end_jobs_not_held(&app, &me, holding).await?;
473
    }
b6b7e4cf Andy McCall ↶ 474
    // A draining runner finishes what it has and takes nothing new.
475
    if me.state == "draining" {
476
        return Ok(StatusCode::NO_CONTENT.into_response());
477
    }
478
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
42fa9554 Andy McCall ↶ 479
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
efd29a00 Andy McCall ↶ 480
    // What it holds and what it is (OxygenFlow spec §11): the images'
481
    // architectures it reports are remembered for every runner's claims.
482
    if let Some(archs) = &req.image_archs {
483
        fdb::record_image_archs(&app.db, archs).await?;
484
    }
485
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
4d2e44d7 Andy McCall ↶ 486
    // What it offers, kept for the placement pass; a job it declined is
487
    // not offered to it again while this stays the same (spec §11.5).
488
    let profile = profile_of(&req, &arch);
489
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), &profile).await?;
b0612e7d Andy McCall ↶ 490
    let room = fdb::Room {
4d2e44d7 Andy McCall ↶ 491
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
b0612e7d Andy McCall ↶ 492
        memory_mib: req.free_memory_mib,
493
        cpus: req.free_cpus,
494
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
efd29a00 Andy McCall ↶ 495
        arch,
496
        image_archs: fdb::image_archs(&app.db).await?,
b0612e7d Andy McCall ↶ 497
    };
7f5f6c05 Andy McCall ↶ 498
    // The labels it reports now, or those it registered with, and those
499
    // an administrator gave it (#98).
500
    let mut labels: Vec<String> = match &req.info {
c4c87f48 Andy McCall ↶ 501
        Some(i) => i.labels.clone(),
7f5f6c05 Andy McCall ↶ 502
        None => me.reported_label_list().into_iter().map(String::from).collect(),
c4c87f48 Andy McCall ↶ 503
    };
7f5f6c05 Andy McCall ↶ 504
    for l in me.admin_label_list() {
505
        if !labels.iter().any(|x| x == l) {
506
            labels.push(l.to_string());
507
        }
508
    }
c4c87f48 Andy McCall ↶ 509
    let Some(job) =
510
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
511
    else {
42fa9554 Andy McCall ↶ 512
        return Ok(StatusCode::NO_CONTENT.into_response());
513
    };
b6b7e4cf Andy McCall ↶ 514
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
42fa9554 Andy McCall ↶ 515
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
516
    let row =
517
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
a0d4bbfb Andy McCall ↶ 518
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
42fa9554 Andy McCall ↶ 519
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
5dead1f4 Andy McCall ↶ 520
    // The job's limit: its own timeout, else its sandbox's, else the site
521
    // default, never past the site maximum; recorded for the reaper.
522
    let limits = limits(&app).await;
523
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
524
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
7f5f6c05 Andy McCall ↶ 525
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
526
    // ...and never past the runner's own limit, when it has one (#98).
527
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
528
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
529
    }
5dead1f4 Andy McCall ↶ 530
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
42fa9554 Andy McCall ↶ 531
    let claim = Claim {
532
        job_id: job.id,
533
        run_id: run.id,
534
        repo: format!("{}/{}", row.owner, row.name),
535
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
536
        token: secret,
537
        heads: run.head_list().into_iter().map(String::from).collect(),
538
        pipeline_hash: run.pipeline_hash.clone(),
5dead1f4 Andy McCall ↶ 539
        job: spec,
540
        sandbox,
541
        timeout_s,
fd3db644 Andy McCall ↶ 542
        subrepos: vec![],
9a065bc0 Andy McCall ↶ 543
        state: run.state.clone(),
544
        bookmark: run.bookmark.clone(),
545
        run_number: run.number,
2a83f530 Andy McCall ↶ 546
        // The site's, the owner's and the repository's variables (#102).
547
        env: crate::variables::env_for_repo(&app.db, &row).await?,
4ac01a73 Andy McCall ↶ 548
        secrets: vec![],
42fa9554 Andy McCall ↶ 549
    };
fd3db644 Andy McCall ↶ 550
    let mut claim = claim;
4ac01a73 Andy McCall ↶ 551
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
552
    // The secrets the job declares, as files (#107); one not set is said
553
    // at the top of the log and left out, so the job fails on the file.
554
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
555
    let found = crate::secrets::for_job(&app, &row, &names).await?;
d1727c57 Andy McCall ↶ 556
    // The identities it asks for (#103): a token each, as a file.
557
    if !claim.job.identities.is_empty() {
558
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
559
        for i in &claim.job.identities {
560
            let token = issuer.mint(&crate::oidc::JobClaims {
561
                issuer: app.config.base_url(),
562
                audience: &i.audience,
563
                owner: &row.owner,
564
                name: &row.name,
565
                visibility: &row.visibility,
566
                bookmark: &run.bookmark,
567
                run_number: run.number,
568
                job: &job.name,
569
                state: &run.state,
570
                runner: &runner,
571
                actor: &run.actor,
572
                now_ms: db::now_ms(),
573
                ttl_s: timeout_s,
574
            });
575
            claim.secrets.push(flow_core::api::SecretInput {
576
                name: format!("identity:{}", i.audience),
577
                path: i.mount_path.clone(),
578
                value: token,
579
            });
580
        }
581
    }
4ac01a73 Andy McCall ↶ 582
    for s in &claim.job.secrets {
583
        match found.iter().find(|(n, _)| *n == s.name) {
584
            Some((name, value)) => {
585
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
586
            }
587
            None => left_out.push(format!(
588
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
589
                s.name, s.mount_path
590
            )),
591
        }
592
    }
fd3db644 Andy McCall ↶ 593
    if !ids.is_empty() {
594
        fdb::set_also_read(&app.db, job.id, &ids).await?;
595
    }
afd0be38 Andy McCall ↶ 596
    // Say at the top of the job's log what it will not have (oxygenhub#56),
597
    // rather than let it fail later on missing files.
598
    if !left_out.is_empty() {
599
        let records: Vec<flow_core::api::Record> =
600
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
601
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
602
    }
fd3db644 Andy McCall ↶ 603
    claim.subrepos = inputs;
42fa9554 Andy McCall ↶ 604
    Ok(Json(claim).into_response())
fd3db644 Andy McCall ↶ 605
}
606
4d2e44d7 Andy McCall ↶ 607
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
608
/// labels and capacity, each list sorted.
609
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
610
    let sorted = |v: &[String]| {
611
        let mut v = v.to_vec();
612
        v.sort();
613
        v
614
    };
615
    let info = req.info.as_ref();
616
    let v = serde_json::json!({
617
        "arch": arch,
618
        "tiers": sorted(&req.runtimes),
619
        "images": req.images.as_deref().map(sorted),
620
        "labels": info.map(|i| sorted(&i.labels)),
621
        "memory_mib": info.and_then(|i| i.memory_mib),
622
        "cpus": info.and_then(|i| i.cpus),
623
    });
624
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
625
}
626
627
/// A job handed back by this many different runners is unplaceable.
628
pub const UNPLACEABLE_AFTER: usize = 3;
629
630
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
631
/// runner will not run a job it was handed. The job is queued again and
632
/// not offered to that runner while its profile stays the same (and for
633
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
634
/// different runners have declined it, it ends as unplaceable with their
635
/// reasons. A decline is never a failed check.
636
pub async fn decline(
637
    State(app): State<Shared>,
638
    headers: HeaderMap,
639
    Path(id): Path<i64>,
640
    Json(d): Json<flow_core::api::Decline>,
641
) -> Result<StatusCode, ApiError> {
642
    let me = runner_auth(&app, &headers).await?;
643
    db::runners::seen(&app.db, me.id, None, None).await?;
644
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
645
    let reason = match reason.trim() {
646
        "" => "no reason given".to_string(),
647
        r => r.to_string(),
648
    };
649
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
650
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
651
    };
652
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
653
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
654
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
655
    if runners.len() >= UNPLACEABLE_AFTER {
656
        let error = format!(
657
            "unplaceable: declined by {} runners: {}",
658
            runners.len(),
659
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
660
        );
661
        let changed =
662
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
663
                .await?;
664
        report_changed(&app, job.run_id, changed).await?;
665
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
666
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
667
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
668
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
669
            fdb::set_waiting(&app.db, id, &summary).await?;
670
        }
671
    }
672
    Ok(StatusCode::NO_CONTENT)
673
}
674
675
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
676
/// now (spec §11.4), from what each runner offered at its last claim:
677
/// empty when one could (it waits its turn or a free slot), else what
678
/// each one lacks. `declines` are the job's.
679
pub fn why_waiting(
680
    job: &fdb::Job,
681
    owner: &str,
682
    repo_id: i64,
683
    runners: &[db::runners::Runner],
684
    image_archs: &std::collections::HashMap<String, String>,
685
    declines: &[fdb::DeclineRow],
686
    now: i64,
687
) -> String {
688
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
689
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
690
    let wanted_arch = job.arch().or_else(|| image_archs.get(rootfs).cloned());
691
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
692
    let needs = job.needs();
693
    let lacks = |r: &db::runners::Runner| -> Option<String> {
694
        if r.state == "draining" {
695
            return Some("is draining".into());
696
        }
697
        let scope = r.scope();
698
        if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
699
            return Some("serves other repositories".into());
700
        }
701
        let tiers = r.tier_list();
702
        if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
703
            return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
704
        }
705
        if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
706
            return Some(format!("is {}; the job needs {w}", r.arch));
707
        }
708
        if r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
709
            return Some(format!("does not hold image {}", &rootfs[..12.min(rootfs.len())]));
710
        }
711
        let labels = r.label_list();
712
        if let Some(l) = needs.iter().find(|l| !labels.contains(&l.as_str())) {
713
            return Some(format!("lacks label {l}"));
714
        }
715
        if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
716
            return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
717
        }
718
        if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
719
            return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
720
        }
721
        if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && now - d.ms < fdb::DECLINE_HOLDS_MS) {
722
            return Some(format!("declined it: {}", d.reason));
723
        }
724
        None
725
    };
726
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
727
    if online.is_empty() {
728
        return "no runner is online".into();
729
    }
730
    let mut why = Vec::new();
731
    for r in &online {
732
        match lacks(r) {
733
            None => return String::new(),
734
            Some(w) => why.push(format!("{} {w}", r.name)),
735
        }
736
    }
737
    let more = why.len().saturating_sub(4);
738
    why.truncate(4);
739
    let mut text = format!("no runner can take it: {}", why.join("; "));
740
    if more > 0 {
741
        text.push_str(&format!("; and {more} more"));
742
    }
743
    text
744
}
745
746
/// One placement pass: say why each ready queued job waits.
747
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
748
    let ready = fdb::ready_queued(&app.db).await?;
749
    if ready.is_empty() {
750
        return Ok(());
751
    }
752
    let runners = db::runners::list(&app.db).await?;
753
    let archs = fdb::image_archs(&app.db).await?;
754
    let now = db::now_ms();
755
    for (job, owner, repo_id) in ready {
756
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
757
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now);
67040b15 Andy McCall ↶ 758
        if why == job.waiting {
759
            continue;
760
        }
4d2e44d7 Andy McCall ↶ 761
        fdb::set_waiting(&app.db, job.id, &why).await?;
67040b15 Andy McCall ↶ 762
        // The job's check says it too, so a proposal shows why it is stuck
763
        // (oxygenhub#28).
764
        if let (Some(run), Some(row)) = (fdb::run_by_id(&app.db, job.run_id).await?, db::repos::get_by_id(&app.db, repo_id).await?) {
765
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
766
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
767
        }
4d2e44d7 Andy McCall ↶ 768
    }
769
    Ok(())
770
}
771
772
/// Run a placement pass every 15 seconds.
773
pub async fn placement(app: Shared) {
774
    loop {
775
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
776
        if let Err(e) = explain_waits(&app).await {
777
            tracing::warn!("flow: placement pass: {e}");
778
        }
779
    }
780
}
781
25091e5c Andy McCall ↶ 782
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
783
async fn pins_in(
784
    app: &Shared,
785
    name: crate::repos::RepoName,
786
    heads: Vec<oxy_core::hash::ChangeHash>,
787
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
788
    app.repos
fd3db644 Andy McCall ↶ 789
        .read(&name, move |repo, cache| {
790
            let loaded = cache.load(repo, &heads)?;
791
            let mut out = Vec::new();
792
            for (path, f) in &loaded.snapshot.files {
793
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
794
                    continue;
795
                }
796
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
797
                    if let Some(b) = blobs.first() {
798
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
799
                            .read_blob(b)
800
                            .map_err(crate::repos::RepoError::Oxy)
801
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
802
                        {
803
                            out.push((path.clone(), id, heads));
804
                        }
805
                    }
806
                }
807
            }
808
            Ok(out)
809
        })
810
        .await
25091e5c Andy McCall ↶ 811
        .unwrap_or_default()
812
}
813
814
/// How deep subrepos of subrepos are followed.
815
const MAX_SUBREPO_DEPTH: usize = 3;
816
817
/// The Oxygen subrepos the run's state pins, and those their states pin in
818
/// turn (parents first, paths joined), that live on this hub and that the
819
/// run's pusher may read: what the job's workspace needs beside its own
afd0be38 Andy McCall ↶ 820
/// tree. A pin the hub cannot serve is left out, with what it pins, and
821
/// the third list says why, for the job's log.
822
async fn subrepo_inputs(
823
    app: &Shared,
824
    row: &db::repos::RepoRow,
825
    run: &fdb::Run,
826
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
827
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
25091e5c Andy McCall ↶ 828
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
fd3db644 Andy McCall ↶ 829
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
25091e5c Andy McCall ↶ 830
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
fd3db644 Andy McCall ↶ 831
    let mut inputs = Vec::new();
832
    let mut ids = Vec::new();
afd0be38 Andy McCall ↶ 833
    let mut left_out = Vec::new();
834
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
25091e5c Andy McCall ↶ 835
    // (path prefix, repository, heads, depth), parents before children.
836
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
837
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
838
        if depth == MAX_SUBREPO_DEPTH {
839
            continue;
840
        }
841
        for (path, id, pinned) in pins_in(app, name, heads).await {
842
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
afd0be38 Andy McCall ↶ 843
            let mut hosted = false;
25091e5c Andy McCall ↶ 844
            for other in &rows {
845
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
846
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
847
                if !matches {
848
                    continue;
849
                }
afd0be38 Andy McCall ↶ 850
                hosted = true;
25091e5c Andy McCall ↶ 851
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
852
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
853
                    inputs.push(flow_core::api::SubrepoInput {
854
                        path: path.clone(),
855
                        repo: format!("{}/{}", other.owner, other.name),
856
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
857
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
858
                    });
859
                    if !ids.contains(&other.id) {
860
                        ids.push(other.id);
861
                    }
862
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
afd0be38 Andy McCall ↶ 863
                } else {
864
                    left_out.push(format!(
865
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
866
                        other.owner, other.name
867
                    ));
25091e5c Andy McCall ↶ 868
                }
869
                break;
afd0be38 Andy McCall ↶ 870
            }
871
            if !hosted {
872
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
fd3db644 Andy McCall ↶ 873
            }
874
        }
875
    }
afd0be38 Andy McCall ↶ 876
    (inputs, ids, left_out)
42fa9554 Andy McCall ↶ 877
}
878
879
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
880
    match fdb::job(&app.db, id).await? {
881
        Some(j) if j.status == "running" => Ok(j),
882
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
883
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
884
    }
885
}
886
887
/// `POST /api/v1/flow/jobs/{id}/log`.
888
pub async fn log(
889
    State(app): State<Shared>,
890
    headers: HeaderMap,
891
    Path(id): Path<i64>,
892
    Json(mut batch): Json<LogBatch>,
893
) -> Result<StatusCode, ApiError> {
d94449e4 Andy McCall ↶ 894
    let me = runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 895
    let job = running_job(&app, id).await?;
d94449e4 Andy McCall ↶ 896
    // A runner busy with its only job claims nothing, so its log and
897
    // finish calls are what keep it online (oxygenhub#78).
898
    db::runners::seen(&app.db, me.id, None, None).await?;
745d225d Andy McCall ↶ 899
    // An empty batch asks only whether the job still runs: a cancelled
900
    // one answers 409 and its runner stops it (oxygenflow#32).
901
    if batch.records.is_empty() {
902
        return Ok(StatusCode::NO_CONTENT);
903
    }
4ac01a73 Andy McCall ↶ 904
    // A secret's value never reaches the stored log (#107).
905
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
906
        Ok(spec) if !spec.secrets.is_empty() => {
907
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
908
            let row = match run {
909
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
910
                None => None,
911
            };
912
            match row {
913
                Some(row) => {
914
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
915
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
916
                }
917
                None => vec![],
918
            }
919
        }
920
        _ => vec![],
921
    };
d1727c57 Andy McCall ↶ 922
    let kid = app.oidc.get().map(|i| i.kid.clone());
42fa9554 Andy McCall ↶ 923
    for r in &mut batch.records {
4ac01a73 Andy McCall ↶ 924
        if !values.is_empty() {
925
            r.text = crate::secrets::mask(&r.text, &values);
d1727c57 Andy McCall ↶ 926
        }
927
        if let Some(kid) = &kid {
928
            r.text = crate::oidc::mask(&r.text, kid);
4ac01a73 Andy McCall ↶ 929
        }
42fa9554 Andy McCall ↶ 930
        if r.text.len() > MAX_RECORD_TEXT {
931
            let mut cut = MAX_RECORD_TEXT;
932
            while !r.text.is_char_boundary(cut) {
933
                cut -= 1;
934
            }
935
            r.text.truncate(cut);
936
        }
937
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
938
            r.stream = "console".into();
939
        }
940
    }
941
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
942
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
943
        tracing::info!("flow: job {} reached the log limit", job.id);
944
    }
945
    Ok(StatusCode::NO_CONTENT)
946
}
947
a3c8288b Andy McCall ↶ 948
/// Largest artifact the hub keeps; the runner refuses larger ones first.
949
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
950
951
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
952
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
953
/// repository. Answers `{"blob"}`.
954
pub async fn artifact(
955
    State(app): State<Shared>,
956
    headers: HeaderMap,
957
    Path((id, name)): Path<(i64, String)>,
958
    body: axum::body::Bytes,
959
) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 960
    runner_auth(&app, &headers).await?;
a3c8288b Andy McCall ↶ 961
    let job = running_job(&app, id).await?;
962
    let spec: flow_core::Job =
963
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
964
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
965
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
966
    };
967
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
968
    let row =
969
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
970
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
a36bab6b Andy McCall ↶ 971
    // A report's artifact is its manifest (#127): checked, and its files
972
    // found in the store, before it is kept.
973
    let report = match out.kind {
974
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
975
        flow_core::OutputKind::Artifact => None,
976
    };
a3c8288b Andy McCall ↶ 977
    let content = blake3::hash(&body).to_hex().to_string();
978
    let size = body.len() as i64;
a36bab6b Andy McCall ↶ 979
    let artifact = report.is_none();
980
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
981
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
a3c8288b Andy McCall ↶ 982
    let blob = app
983
        .repos
984
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
985
        .await
986
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
987
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
988
    fdb::add_artifact(&app.db, &a).await?;
a36bab6b Andy McCall ↶ 989
    if let Some((manifest, declaration)) = report {
990
        let origin = crate::reports::Origin {
991
            repo_id: row.id,
992
            run_id: run.id,
993
            run_number: run.number,
994
            job_id: job.id,
995
            job: &job.name,
996
            bookmark: &run.bookmark,
997
            state: &run.state,
998
        };
999
        let said = match declaration {
1000
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
1001
                Ok(_) => {
1002
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
1003
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
1004
                }
1005
                Err(e) => format!("report {}: not indexed: {e}", a.name),
1006
            },
1007
            Err(why) => {
1008
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
1009
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
1010
                format!("report {}: report.toml: {why}", a.name)
1011
            }
1012
        };
1013
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1014
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1015
    }
554ccacf Andy McCall ↶ 1016
    // A coverage report (#113) is read now; what it says, or why it could
1017
    // not be read, goes in the job's log. It never fails the upload.
1018
    if let Some(bytes) = report_bytes {
1019
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
1020
            Ok(text) | Err(text) => text,
1021
        };
1022
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1023
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1024
    }
0b203bbf Andy McCall ↶ 1025
    // A test report (#114) likewise.
1026
    if let Some(bytes) = junit_bytes {
1027
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
1028
            Ok(text) | Err(text) => text,
1029
        };
1030
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1031
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1032
    }
a3c8288b Andy McCall ↶ 1033
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
a36bab6b Andy McCall ↶ 1034
}
1035
1036
/// Check report manifest `body` (#127) and that the repository holds every
1037
/// file it names; answers it with the report's declaration, or why that
1038
/// could not be read.
1039
async fn report_manifest(
1040
    app: &Shared,
1041
    repo: &RepoName,
1042
    name: &str,
1043
    body: &[u8],
1044
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
1045
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
1046
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
1047
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
1048
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
1049
    let found = app
1050
        .repos
1051
        .read(repo, move |r, _| {
1052
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
1053
                return Ok(Err(path.clone()));
1054
            }
1055
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
1056
        })
1057
        .await
1058
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
1059
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
1060
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
1061
    Ok((manifest, declaration))
1062
}
1063
1064
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
1065
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
1066
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
1067
/// the files follows as the report's artifact.
1068
pub async fn report_file(
1069
    State(app): State<Shared>,
1070
    headers: HeaderMap,
1071
    Path((id, name)): Path<(i64, String)>,
1072
    body: axum::body::Bytes,
1073
) -> Result<Response, ApiError> {
1074
    runner_auth(&app, &headers).await?;
1075
    let job = running_job(&app, id).await?;
1076
    let spec: flow_core::Job =
1077
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1078
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
1079
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
1080
    }
1081
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
1082
    if !crate::reports::path_ok(path) {
1083
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
1084
    }
1085
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1086
    let row =
1087
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1088
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1089
    let blob = app
1090
        .repos
1091
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1092
        .await
1093
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
1094
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
0b203bbf Andy McCall ↶ 1095
}
1096
1097
/// Parse and keep a job's JUnit report: what it says, or why not.
1098
async fn read_junit(
1099
    app: &Shared,
1100
    row: &db::repos::RepoRow,
1101
    run: &fdb::Run,
1102
    job: &fdb::Job,
1103
    blob: &str,
1104
    bytes: axum::body::Bytes,
1105
) -> Result<String, String> {
1106
    if bytes.len() > crate::junit::MAX_REPORT {
1107
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
1108
    }
1109
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
1110
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
1111
        .await
1112
        .map_err(|e| format!("tests: {e}"))?
1113
        .map_err(|e| format!("tests: {e}"))?;
1114
    let origin = crate::junit::Origin {
1115
        repo_id: row.id,
1116
        run_id: run.id,
1117
        run_number: run.number,
1118
        job_id: job.id,
1119
        job: &job.name,
1120
        bookmark: &run.bookmark,
1121
        state: &run.state,
1122
        blob,
1123
    };
1124
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
1125
    let r = crate::junit::for_run(&app.db, run.id)
1126
        .await
1127
        .map_err(|e| format!("tests: {e}"))?
1128
        .into_iter()
1129
        .find(|r| r.job_id == job.id)
1130
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
3d5c825a Andy McCall ↶ 1131
    // The `tests` check on the run's state: what fails, and which of those
1132
    // passed in the same job's latest report on the default bookmark, so a
1133
    // proposal names what it broke.
1134
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
1135
    let newly = match &base {
1136
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
1137
        _ => vec![],
1138
    };
1139
    let (status, summary) = if r.broken() == 0 {
1140
        ("passed", format!("{} passed", r.passed))
1141
    } else if let Some(b) = &base {
1142
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
1143
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
1144
        let new = if newly.is_empty() {
1145
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
1146
        } else {
1147
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
1148
        };
1149
        ("failed", format!("{} failing; {new}", r.broken()))
1150
    } else {
1151
        ("failed", format!("{} failing", r.broken()))
1152
    };
1153
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
1154
    if let Ok(c) = db::hooks::upsert_check(
1155
        &app.db,
1156
        row.id,
1157
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
1158
    )
1159
    .await
1160
    {
1161
        let api = crate::api::check_of(&c);
1162
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1163
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1164
    }
0b203bbf Andy McCall ↶ 1165
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
554ccacf Andy McCall ↶ 1166
}
1167
1168
/// Parse and keep a job's coverage report: what it says, or why not.
1169
async fn read_coverage(
1170
    app: &Shared,
1171
    repo: &RepoName,
1172
    row: &db::repos::RepoRow,
1173
    run: &fdb::Run,
1174
    job: &fdb::Job,
1175
    blob: &str,
1176
    bytes: axum::body::Bytes,
1177
) -> Result<String, String> {
1178
    if bytes.len() > crate::coverage::MAX_REPORT {
1179
        return Err(format!(
1180
            "coverage: the report is {} MiB; at most {} MiB is read",
1181
            bytes.len() >> 20,
1182
            crate::coverage::MAX_REPORT >> 20
1183
        ));
1184
    }
7fff30c4 Andy McCall ↶ 1185
    let text = String::from_utf8(bytes.to_vec())
1186
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
1187
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
554ccacf Andy McCall ↶ 1188
        .await
1189
        .map_err(|e| format!("coverage: {e}"))?
1190
        .map_err(|e| format!("coverage: {e}"))?;
1191
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
1192
    let in_state: std::collections::HashSet<String> = app
1193
        .repos
1194
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
1195
        .await
1196
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
1197
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
1198
    let origin = crate::coverage::Origin {
1199
        repo_id: row.id,
1200
        run_id: run.id,
1201
        run_number: run.number,
1202
        job_id: job.id,
1203
        job: &job.name,
1204
        bookmark: &run.bookmark,
1205
        state: &run.state,
1206
        heads: &heads,
1207
        blob,
7fff30c4 Andy McCall ↶ 1208
        format,
554ccacf Andy McCall ↶ 1209
    };
1210
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
1211
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
1212
        return Err("coverage: the report was not kept".into());
1213
    };
7edaff01 Andy McCall ↶ 1214
    // The `coverage` check on the run's state (#113): against the default
1215
    // bookmark, under the repository's rules.
1216
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
1217
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
1218
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
1219
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
1220
    if let Ok(c) = db::hooks::upsert_check(
1221
        &app.db,
1222
        row.id,
1223
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
1224
    )
1225
    .await
1226
    {
1227
        let api = crate::api::check_of(&c);
1228
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1229
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1230
    }
554ccacf Andy McCall ↶ 1231
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
1232
    let dropped =
1233
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
1234
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
a3c8288b Andy McCall ↶ 1235
}
1236
42fa9554 Andy McCall ↶ 1237
/// `POST /api/v1/flow/jobs/{id}/finish`.
1238
pub async fn finish(
1239
    State(app): State<Shared>,
1240
    headers: HeaderMap,
1241
    Path(id): Path<i64>,
1242
    Json(f): Json<Finish>,
1243
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 1244
    let me = runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 1245
    let job = running_job(&app, id).await?;
d94449e4 Andy McCall ↶ 1246
    db::runners::seen(&app.db, me.id, None, None).await?;
42fa9554 Andy McCall ↶ 1247
    let status = match (f.exit_code, &f.error) {
1248
        (Some(0), None) => "passed",
1249
        (Some(_), _) => "failed",
1250
        (None, _) => "error",
1251
    };
1252
    let (receipt, receipt_hash) = match &f.receipt {
1253
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
d29070fa Andy McCall ↶ 1254
            // A passing job's receipt names exactly the artifacts uploaded,
1255
            // in name order; a failing one's names none, though it may have
1256
            // kept `when "always"` artifacts (oxygenflow#37).
1257
            let kept: Vec<String> =
1258
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
a3c8288b Andy McCall ↶ 1259
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
1260
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
1261
            }
42fa9554 Andy McCall ↶ 1262
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
1263
        }
1264
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
1265
        None => (String::new(), String::new()),
1266
    };
1267
    let error = f.error.clone().unwrap_or_default();
1268
    let changed = fdb::finish(
1269
        &app.db,
1270
        job.id,
1271
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
1272
    )
1273
    .await?;
1274
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1275
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
cc19697f Andy McCall ↶ 1276
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
b6b7e4cf Andy McCall ↶ 1277
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
cc19697f Andy McCall ↶ 1278
                    Ok((attest, signer)) => (attest, signer, String::new()),
1279
                    Err(e) => {
1280
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
1281
                        (String::new(), String::new(), e)
1282
                    }
1283
                };
1284
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
1285
            }
42fa9554 Andy McCall ↶ 1286
        }
1287
    }
070a7b64 Andy McCall ↶ 1288
    report_changed(&app, job.run_id, changed).await?;
42fa9554 Andy McCall ↶ 1289
    Ok(StatusCode::NO_CONTENT)
070a7b64 Andy McCall ↶ 1290
}
1291
1292
/// Report the checks of jobs whose status changed.
1293
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
1294
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
1295
        return Ok(());
1296
    };
1297
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
1298
        return Ok(());
1299
    };
1300
    for j in changed {
1301
        let summary = match j.status.as_str() {
1302
            "passed" => format!("passed in {}", duration(&j)),
1303
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
1304
            "skipped" => "skipped: a dependency did not pass".into(),
745d225d Andy McCall ↶ 1305
            "cancelled" => j.error.clone(),
070a7b64 Andy McCall ↶ 1306
            _ => format!("could not run: {}", j.error),
1307
        };
1308
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
1309
    }
1310
    Ok(())
1311
}
1312
5dead1f4 Andy McCall ↶ 1313
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
1314
/// Settings, else from `hub.toml`.
1315
pub struct Limits {
1316
    /// For a job whose pipeline names none.
1317
    pub default_s: u64,
1318
    /// The most a pipeline may ask for.
1319
    pub max_s: u64,
1320
    /// Grace past a job's limit before the hub gives it up: a runner stops
1321
    /// a job at its limit and reports it within moments, so one still
1322
    /// running this long after is one the runner no longer reports (it was
1323
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
1324
    pub grace_ms: i64,
1325
}
1326
1327
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
1328
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
1329
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
1330
1331
pub async fn limits(app: &Shared) -> Limits {
1332
    let minutes = |key: &'static str| async move {
1333
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
1334
    };
1335
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
1336
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
1337
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
1338
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
1339
}
070a7b64 Andy McCall ↶ 1340
5dead1f4 Andy McCall ↶ 1341
/// End every job still running past its time limit plus the grace:
070a7b64 Andy McCall ↶ 1342
/// it is marked an error with the reason, the jobs that depended on it are
1343
/// skipped and its run settled, as when a runner reports an error.
1344
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
5dead1f4 Andy McCall ↶ 1345
    let limits = limits(app).await;
1346
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
070a7b64 Andy McCall ↶ 1347
    let n = lost.len();
1348
    for job in lost {
1349
        let error = format!(
1350
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
1351
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
1352
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
1353
        );
1354
        let changed =
1355
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1356
                .await?;
1357
        tracing::warn!("flow: job {} {error}", job.id);
1358
        report_changed(app, job.run_id, changed).await?;
1359
    }
1360
    Ok(n)
eef677c4 Andy McCall ↶ 1361
}
1362
1363
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
1364
/// it restarted since claiming them, or never received the claim. A
1365
/// runner claims one job at a time, so a job it holds is always listed.
1366
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
1367
    for job in fdb::running_on(&app.db, runner.id).await? {
1368
        if holding.contains(&job.id) {
1369
            continue;
1370
        }
1371
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
1372
        let changed =
1373
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1374
                .await?;
1375
        tracing::warn!("flow: job {} {error}", job.id);
1376
        report_changed(app, job.run_id, changed).await?;
1377
    }
1378
    Ok(())
348f8c4b Andy McCall ↶ 1379
}
1380
1381
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
1382
/// repository that runs Flow, the jobs of its default bookmark's pipeline
1383
/// with a `schedule` line due then. Returns how many runs started.
1384
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
1385
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
1386
    let mut started = 0;
1387
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
1388
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
1389
        let b = row.default_bookmark.clone();
1390
        let found = app
1391
            .repos
1392
            .read(&repo, move |r, _| {
1393
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
1394
                Ok(Some((st, r.store().get_state(&st)?.heads)))
1395
            })
1396
            .await;
1397
        let Ok(Some((state, heads))) = found else { continue };
1398
        if heads.is_empty() {
1399
            continue;
1400
        }
0650d94e Andy McCall ↶ 1401
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
348f8c4b Andy McCall ↶ 1402
            Ok(Some(_)) => started += 1,
1403
            Ok(None) => {}
1404
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
1405
        }
1406
    }
1407
    started
1408
}
1409
1410
/// The hub's clock as a [`When`](flow_core::pipeline::When).
1411
pub fn when_now() -> flow_core::pipeline::When {
1412
    let now = time::OffsetDateTime::now_utc();
1413
    flow_core::pipeline::When {
1414
        minute: now.minute() as u32,
1415
        hour: now.hour() as u32,
1416
        day: now.day() as u32,
1417
        month: u8::from(now.month()) as u32,
1418
        weekday: now.weekday().number_days_from_sunday() as u32,
1419
    }
1420
}
1421
1422
/// Start the scheduled runs due each minute, once a minute.
1423
pub async fn scheduler(app: Shared) {
1424
    let mut last = None;
1425
    loop {
1426
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1427
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
1428
        if last == Some(minute) {
1429
            continue;
1430
        }
1431
        last = Some(minute);
1432
        let n = run_due(&app, when_now()).await;
1433
        if n > 0 {
1434
            tracing::info!("flow: {n} scheduled run(s) started");
1435
        }
1436
    }
070a7b64 Andy McCall ↶ 1437
}
1438
1439
/// Look for lost jobs once a minute.
1440
pub async fn reaper(app: Shared) {
1441
    loop {
1442
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1443
        if let Err(e) = reap_lost_jobs(&app).await {
1444
            tracing::warn!("flow: looking for lost jobs: {e}");
1445
        }
1446
    }
cc19697f Andy McCall ↶ 1447
}
1448
1449
/// Check a runner's signed receipt for a job of `run` and keep it in the
1450
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
1451
/// name, or why it was not kept. It must be the job's receipt, for the
1452
/// run's state and repository, signed by the key the runner's token first
1453
/// signed with.
1454
pub async fn keep_receipt(
1455
    app: &Shared,
b6b7e4cf Andy McCall ↶ 1456
    runner: &db::runners::Runner,
cc19697f Andy McCall ↶ 1457
    row: &db::repos::RepoRow,
1458
    run: &fdb::Run,
1459
    receipt: &flow_core::Receipt,
1460
    signed_hex: &str,
1461
) -> Result<(String, String), String> {
1462
    use oxy_core::receipt::SignedReceipt;
1463
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
1464
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
1465
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
1466
    if sr.receipt.hash().0 != receipt.hash() {
1467
        return Err("it signs another receipt than the job's".into());
1468
    }
1469
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1470
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
1471
    if sr.state() != state {
1472
        return Err("it is for another state than the run's".into());
1473
    }
1474
    let key = sr.runner.hash().to_hex();
b6b7e4cf Andy McCall ↶ 1475
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
cc19697f Andy McCall ↶ 1476
    if bound != key {
1477
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
1478
    }
1479
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
1480
    let signer = sr.runner.name.clone();
1481
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
1482
    Ok((attest.to_hex(), signer))
42fa9554 Andy McCall ↶ 1483
}
1484
1485
/// A job's wall time, as the pages show it.
1486
pub fn duration(j: &fdb::Job) -> String {
1487
    match (j.started_ms, j.finished_ms) {
1488
        (Some(a), Some(b)) => human_ms(b - a),
1489
        (Some(a), None) => human_ms(db::now_ms() - a),
1490
        _ => "–".into(),
1491
    }
1492
}
1493
1494
pub fn human_ms(ms: i64) -> String {
1495
    let ms = ms.max(0);
1496
    if ms < 1000 {
1497
        format!("{ms} ms")
1498
    } else if ms < 60_000 {
1499
        format!("{:.1} s", ms as f64 / 1000.0)
1500
    } else {
1501
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
1502
    }
1503
}
bf3d0754 Andy McCall ↶ 1504
1505
/// Where a live run stands among the runners' work: what its viewers may
1506
/// know of it. Jobs of other repositories are named only when those are
1507
/// public; the rest are counted.
1508
#[derive(Clone, Debug, Default, PartialEq, Eq)]
1509
pub struct QueueInfo {
1510
    /// 1-based place of the run's first queued job among all queued jobs.
1511
    pub position: Option<usize>,
1512
    /// Jobs running now, and queued jobs ahead of the run's first one.
1513
    pub running: Vec<String>,
1514
    pub ahead: Vec<String>,
1515
    /// Of those, jobs in repositories the page may not name.
1516
    pub hidden: usize,
1517
}
1518
1519
impl QueueInfo {
1520
    /// "3rd in line" and the like.
1521
    pub fn place(&self) -> Option<String> {
1522
        self.position.map(|n| {
1523
            let suffix = match (n % 10, n % 100) {
1524
                (1, 11) | (2, 12) | (3, 13) => "th",
1525
                (1, _) => "st",
1526
                (2, _) => "nd",
1527
                (3, _) => "rd",
1528
                _ => "th",
1529
            };
1530
            format!("{n}{suffix} in line")
1531
        })
1532
    }
1533
}
1534
1535
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
1536
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
1537
    let mut info = QueueInfo::default();
1538
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
1539
    info.position = first.map(|i| i + 1);
1540
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
1541
    let mut queued_seen = 0;
1542
    for e in queue {
1543
        if e.run_id == run_id {
1544
            continue;
1545
        }
1546
        let ahead = match e.status.as_str() {
1547
            "running" => true,
1548
            _ => {
1549
                queued_seen += 1;
1550
                first.is_some_and(|f| queued_seen <= f)
1551
            }
1552
        };
1553
        if !ahead {
1554
            continue;
1555
        }
1556
        if e.repo_id != repo_id && e.visibility != "public" {
1557
            info.hidden += 1;
1558
        } else if e.status == "running" {
1559
            info.running.push(label(e));
1560
        } else {
1561
            info.ahead.push(label(e));
1562
        }
1563
    }
1564
    info
1565
}
1566
1567
#[cfg(test)]
1568
mod queue_tests {
1569
    use super::*;
1570
1571
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
1572
        fdb::QueueEntry {
1573
            job_id,
1574
            run_id,
1575
            repo_id,
1576
            owner: "o".into(),
1577
            name: format!("r{repo_id}"),
1578
            visibility: vis.into(),
1579
            run_number: run_id,
1580
            job: job.into(),
1581
            status: status.into(),
b66e70b7 Andy McCall ↶ 1582
            runner: String::new(),
c4c87f48 Andy McCall ↶ 1583
            spec: String::new(),
1584
            sandbox: String::new(),
bf3d0754 Andy McCall ↶ 1585
        }
1586
    }
1587
1588
    #[test]
1589
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
1590
        // Running first, then queued in claim order (as `fdb::queue` returns them).
1591
        let q = vec![
1592
            e(1, 10, 1, "public", "build", "running"),
1593
            e(2, 11, 2, "private", "test", "running"),
1594
            e(3, 10, 1, "public", "test", "queued"),
1595
            e(4, 12, 3, "private", "lint", "queued"),
1596
            e(5, 13, 4, "public", "build", "queued"),
1597
            e(6, 13, 4, "public", "test", "queued"),
1598
            e(7, 14, 5, "public", "late", "queued"),
1599
        ];
1600
        let info = queue_info(&q, 4, 13);
1601
        assert_eq!(info.position, Some(3));
1602
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
1603
        assert_eq!(info.running, ["o/r1 #10 build"]);
1604
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
1605
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
1606
        // A private repository's own jobs are named to its viewers.
1607
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
1608
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
1609
        // A run with nothing queued has no place.
1610
        assert_eq!(queue_info(&q, 1, 99).position, None);
1611
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
1612
        assert_eq!(
1613
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
1614
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
4d2e44d7 Andy McCall ↶ 1615
        );
1616
    }
1617
}
1618
1619
#[cfg(test)]
1620
mod placement_tests {
1621
    use super::*;
1622
1623
    #[test]
1624
    fn a_waiting_job_says_what_each_runner_lacks() {
1625
        use crate::db::runners::Runner;
1626
        let rootfs = "ab".repeat(32);
1627
        let job = fdb::Job {
1628
            id: 7,
1629
            runtime: "syscall".into(),
1630
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
1631
            spec: serde_json::json!({ "name": "build" }).to_string(),
1632
            ..Default::default()
1633
        };
1634
        let now = db::now_ms();
1635
        let runner = |id: i64, name: &str| Runner {
1636
            id,
1637
            name: name.into(),
1638
            state: "active".into(),
1639
            last_seen_ms: Some(now),
1640
            arch: "x86_64".into(),
1641
            tiers: "microvm,syscall".into(),
1642
            images: serde_json::to_string(&[&rootfs]).unwrap(),
1643
            memory_mib: Some(16384),
1644
            cpus: Some(8),
1645
            ..Default::default()
1646
        };
1647
        let archs = std::collections::HashMap::new();
1648
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| why_waiting(&job, "andy", 1, runners, &archs, declines, now);
1649
        assert_eq!(why(&[], &[]), "no runner is online");
1650
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
1651
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
1652
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
1653
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
1654
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
1655
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
1656
        assert_eq!(
1657
            why(&[microvm_only, small, other, offline], &[]),
1658
            "no runner can take it: earth-1 has no syscall tier (it offers microvm); pi has 2048 MiB; the job needs 4096 MiB; bobs serves other repositories"
1659
        );
1660
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
1661
        assert_eq!(
1662
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now),
1663
            "no runner can take it: narvi is riscv64; the job needs x86_64"
1664
        );
1665
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
1666
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
1667
        let d = fdb::DeclineRow {
1668
            job_id: 7,
1669
            runner_id: 1,
1670
            runner: "earth-1".into(),
1671
            reason: "only 512 MiB of memory is free here".into(),
1672
            ms: now,
1673
        };
1674
        assert_eq!(
1675
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
1676
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
bf3d0754 Andy McCall ↶ 1677
        );
4d2e44d7 Andy McCall ↶ 1678
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
1679
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
bf3d0754 Andy McCall ↶ 1680
    }
1681
}