OXYGENHUB
◆ v0.9.3 oxygenhub / crates / hub / src / flow.rs
136.8 KiB · Rust · last changed Runners hand jobs back, and the queue takes them first (closes #312, refs andymccall/oxygenflow#76) 10 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 {
166f6e9b Andy McCall 25
    // A drop never runs Flow (#279), whatever it holds.
26
    if row.is_drop() {
27
        return false;
28
    }
9008bf6c Andy McCall ↶ 29
    match row.flow.as_str() {
1870e4fa Andy McCall ↶ 30
        // `own`: on, run only by the repository's or its owner's runners (#225).
31
        "on" | "own" => true,
9008bf6c Andy McCall ↶ 32
        "off" => false,
33
        _ => app.config.flow.allows(&row.owner, &row.name),
34
    }
35
}
36
42fa9554 Andy McCall ↶ 37
/// A bookmark moved by a push: start a run when the repository is allowed
38
/// and the new state has a pipeline.
39
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
c889911b Andy McCall ↶ 40
    if heads.is_empty() || !enabled(app, row) {
42fa9554 Andy McCall ↶ 41
        return;
42
    }
c889911b Andy McCall ↶ 43
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
44
    let started = match bookmark.strip_prefix("tags/") {
45
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
46
        None => {
367f34b8 Andy McCall ↶ 47
            // `[skip flow]` in the newest change it brings (#284): recorded,
48
            // no jobs.
49
            if let Some((change, marker)) = skip_asked(app, row, bookmark, heads).await {
50
                skip_run(app, row, bookmark, state, heads, actor, &change, marker).await;
51
                return;
52
            }
a9f1c0bf Andy McCall ↶ 53
            // It supersedes the push runs still going on the bookmark (#267).
c889911b Andy McCall ↶ 54
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
a9f1c0bf Andy McCall ↶ 55
            start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await
c889911b Andy McCall ↶ 56
        }
57
    };
58
    if let Err(e) = started {
1edac4b6 Andy McCall ↶ 59
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
367f34b8 Andy McCall ↶ 60
    }
61
}
62
5588bb36 Andy McCall ↶ 63
/// Whether a job's earlier pass may stand for it on the same state (#265):
64
/// not a job that waits for a person, deploys to an environment, holds
65
/// secrets or asks for identity tokens, whose running is the point.
66
fn reusable(job: &flow_core::Job) -> bool {
67
    job.approval.is_none() && job.environment.is_none() && job.secrets.is_empty() && job.identities.is_empty()
68
}
69
367f34b8 Andy McCall ↶ 70
/// The markers that ask a push not to start a run (#284), as the forges
71
/// spell them; matched in any case.
72
pub const SKIP_MARKERS: [&str; 3] = ["[skip flow]", "[skip ci]", "[ci skip]"];
73
3b3a51af Andy McCall ↶ 74
/// The marker in `message` that asks for no run, if any. A marker quoted
75
/// in code (between backticks, or in a fenced block) only mentions one
76
/// and does not count.
367f34b8 Andy McCall ↶ 77
pub fn skip_marker(message: &str) -> Option<&'static str> {
3b3a51af Andy McCall ↶ 78
    let m = outside_code(message).to_lowercase();
367f34b8 Andy McCall ↶ 79
    SKIP_MARKERS.into_iter().find(|k| m.contains(k))
80
}
81
3b3a51af Andy McCall ↶ 82
/// `text` without its fenced blocks (lines between ``` fences) and inline
83
/// code spans (between backticks).
84
fn outside_code(text: &str) -> String {
85
    let mut out = String::new();
86
    let mut fenced = false;
87
    for line in text.lines() {
88
        if line.trim_start().starts_with("```") {
89
            fenced = !fenced;
90
            continue;
91
        }
92
        if fenced {
93
            continue;
94
        }
95
        // Inline spans: every other backtick-separated piece is code; an
96
        // unmatched backtick leaves the rest as text.
97
        let pieces: Vec<&str> = line.split('`').collect();
98
        let closed = pieces.len() % 2 == 1;
99
        for (i, piece) in pieces.iter().enumerate() {
100
            let in_code = i % 2 == 1 && (closed || i + 1 < pieces.len());
101
            if !in_code {
102
                out.push_str(piece);
103
                out.push(' ');
104
            }
105
        }
106
        out.push('\n');
107
    }
108
    out
109
}
110
367f34b8 Andy McCall ↶ 111
/// Whether the repository lets a push to `bookmark` skip its run: `any`
112
/// bookmark (the default), `others` than the default one, or `never`.
113
pub fn skip_allowed(row: &db::repos::RepoRow, bookmark: &str) -> bool {
114
    match row.flow_skip.as_str() {
115
        "never" => false,
116
        "others" => bookmark != row.default_bookmark,
117
        _ => true,
118
    }
119
}
120
121
/// The newest change of `heads` (by its clock) when its message asks for
122
/// no run and the repository allows that here: its hash and the marker.
123
async fn skip_asked(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<(ChangeHash, &'static str)> {
124
    if !skip_allowed(row, bookmark) {
125
        return None;
126
    }
127
    let repo = RepoName::new(&row.owner, &row.name)?;
128
    let h = heads.to_vec();
129
    let newest = app
130
        .repos
131
        .read(&repo, move |r, _| {
132
            let mut best: Option<(oxy_core::objects::Hlc, ChangeHash, String)> = None;
133
            for x in &h {
134
                let (c, _) = r.store().get_change_lazy(x)?;
135
                if best.as_ref().is_none_or(|(t, _, _)| c.hlc > *t) {
136
                    best = Some((c.hlc, *x, c.message.clone()));
137
                }
138
            }
139
            Ok(best)
140
        })
141
        .await
142
        .ok()??;
143
    skip_marker(&newest.2).map(|m| (newest.1, m))
144
}
145
146
/// Record a push's run as skipped (#284): finished, with no jobs, saying
147
/// which change asked and who pushed it. The next run's scope still covers
148
/// what this push brought, as a skipped run is never its baseline.
149
#[allow(clippy::too_many_arguments)]
150
async fn skip_run(
151
    app: &Shared,
152
    row: &db::repos::RepoRow,
153
    bookmark: &str,
154
    state: &str,
155
    heads: &[ChangeHash],
156
    actor: &str,
157
    change: &ChangeHash,
158
    marker: &str,
159
) {
160
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
161
    hex.sort();
162
    let heads_s = hex.join("+");
163
    let why = format!("{marker} in change {}, pushed by {actor}", change.short(12));
164
    let base =
165
        db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger: "push", error: None };
166
    if let Err(e) = db::flow::create_skipped_run(&app.db, base, &why).await {
167
        tracing::warn!("flow: recording a skipped run of {}/{}: {e}", row.owner, row.name);
1edac4b6 Andy McCall ↶ 168
    }
169
}
170
171
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
172
/// moving it. Returns the new run's number.
0650d94e Andy McCall ↶ 173
/// `jobs`, when not empty, are the only jobs to start (oxygenhub#130), with
174
/// the jobs they depend on.
175
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
1edac4b6 Andy McCall ↶ 176
    if !enabled(app, row) {
177
        return Err("OxygenFlow is not enabled for this repository".into());
178
    }
179
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
180
    let b = bookmark.to_string();
181
    let found = app
182
        .repos
183
        .read(&repo, move |r, _| {
184
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
185
            Ok(Some((st, r.store().get_state(&st)?.heads)))
186
        })
187
        .await
188
        .map_err(|e| e.to_string())?;
189
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
190
    if heads.is_empty() {
191
        return Err(format!("{bookmark} has no changes yet"));
192
    }
0650d94e Andy McCall ↶ 193
    start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs)
1edac4b6 Andy McCall ↶ 194
        .await?
195
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
196
}
197
4f692533 Andy McCall ↶ 198
/// "Run pipeline" on one committed change: the state whose only head is
199
/// `change`, whatever bookmarks hold it. Returns the new run's number.
0650d94e Andy McCall ↶ 200
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
4f692533 Andy McCall ↶ 201
    if !enabled(app, row) {
202
        return Err("OxygenFlow is not enabled for this repository".into());
203
    }
204
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
205
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
df15b747 Andy McCall ↶ 206
    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 ↶ 207
    if !known {
208
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
209
    }
210
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
0650d94e Andy McCall ↶ 211
    start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs)
4f692533 Andy McCall ↶ 212
        .await?
213
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
214
}
215
1edac4b6 Andy McCall ↶ 216
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
217
/// run whose checks replace the old ones. Returns the new run's number.
218
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
219
    if !enabled(app, row) {
220
        return Err("OxygenFlow is not enabled for this repository".into());
221
    }
222
    if run.live() {
223
        return Err(format!("run #{} is still {}", run.number, run.status));
224
    }
225
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
226
    if heads.is_empty() {
227
        return Err("the run names no changes".into());
228
    }
c889911b Andy McCall ↶ 229
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
230
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
231
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
1edac4b6 Andy McCall ↶ 232
        .await?
233
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
745d225d Andy McCall ↶ 234
}
235
e8fa5369 Andy McCall ↶ 236
/// Run one job of a finished run again (oxygenflow#31), with the jobs
237
/// that depend on it; the jobs it depends on keep their results, so a
238
/// flaky test reruns without the build before it. Its checks go back to
239
/// queued. Returns how many jobs were queued again.
240
pub async fn rerun_job(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, job: &str, actor: &str) -> Result<usize, String> {
241
    if !enabled(app, row) {
242
        return Err("OxygenFlow is not enabled for this repository".into());
243
    }
244
    if run.live() {
245
        return Err(format!("run #{} is still {}: wait for it, or cancel it, first", run.number, run.status));
246
    }
247
    let changed = fdb::rerun_job(&app.db, run.id, job).await.map_err(|e| {
248
        tracing::warn!("flow: rerunning {job} of run #{} of {}/{}: {e}", run.number, row.owner, row.name);
249
        "the job could not be queued again".to_string()
250
    })?;
251
    if changed.is_empty() {
252
        return Err(format!("run #{} has no job {job:?}", run.number));
253
    }
254
    for j in &changed {
255
        report_check(app, row, &run.state, run.number, &j.name, "queued", &format!("queued again by {actor}")).await;
256
    }
257
    Ok(changed.len())
258
}
259
745d225d Andy McCall ↶ 260
/// Cancel a run still going (oxygenflow#32), its checks with it.
261
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
262
    if !run.live() {
263
        return Err(format!("run #{} is already {}", run.number, run.status));
264
    }
265
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
266
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
267
        "the run could not be cancelled".to_string()
268
    })
269
}
270
271
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
272
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
273
    report_changed(app, run.id, changed).await
c889911b Andy McCall ↶ 274
}
275
417184c7 Andy McCall ↶ 276
/// Why job `job` of `run` cannot be decided on by `who` (oxygenflow#57),
277
/// or its approval: it must be waiting, the jobs it depends on done and
278
/// passed, `who` must have the role it names on the repository, and with
279
/// others-only must not be the person whose push started the run.
280
pub async fn may_decide(
281
    app: &Shared,
282
    row: &db::repos::RepoRow,
283
    run: &fdb::Run,
284
    job: &fdb::Job,
285
    who: &db::accounts::Account,
286
) -> Result<flow_core::Approval, String> {
287
    let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?;
288
    let Some(approval) = spec.approval else { return Err(format!("{} does not wait for approval", job.name)) };
289
    if job.status != "queued" || job.approval != "waiting" {
290
        return Err(format!("{} is not waiting for approval", job.name));
291
    }
292
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
293
    if let Some(d) = job.depends_on().iter().find(|d| !jobs.iter().any(|j| &j.name == *d && j.status == "passed")) {
294
        return Err(format!("{} waits for {d} first", job.name));
295
    }
296
    let need = crate::auth::roles::Role::parse(&approval.by).unwrap_or(crate::auth::roles::Role::Maintain);
297
    let role = crate::auth::roles::role_for(&app.db, Some(who), row).await.map_err(|e| e.to_string())?;
298
    if role.is_none_or(|r| r < need) {
299
        return Err(format!("approving {} takes the {} role on {}/{}", job.name, approval.by, row.owner, row.name));
300
    }
301
    if approval.others_only && who.username == run.actor {
302
        return Err(format!("{} must be approved by someone other than {}, whose push started the run", job.name, run.actor));
303
    }
304
    Ok(approval)
305
}
306
307
/// Approve or reject job `name` of `run` as `who` (oxygenflow#57): the
308
/// decision goes on the job, its log and the audit log; a rejected job ends
309
/// as cancelled and the jobs after it are skipped. What happened, or why not.
310
pub async fn decide(
311
    app: &Shared,
312
    row: &db::repos::RepoRow,
313
    run: &fdb::Run,
314
    name: &str,
315
    who: &db::accounts::Account,
316
    approve: bool,
05f1a85a Andy McCall ↶ 317
    via: Option<&str>,
417184c7 Andy McCall ↶ 318
) -> Result<String, String> {
05f1a85a Andy McCall ↶ 319
    decide_with(app, row, run, name, who, approve, Through { seal: None, via }).await
47e37402 Andy McCall ↶ 320
}
321
05f1a85a Andy McCall ↶ 322
/// How a decision arrived: with a sealed approval's ref, or through an
323
/// agent token (by its name).
324
#[derive(Clone, Copy, Default)]
325
struct Through<'a> {
326
    seal: Option<&'a str>,
327
    via: Option<&'a str>,
328
}
329
47e37402 Andy McCall ↶ 330
/// [`decide`], from a verified seal (`Some(approval ref)`) or without one;
331
/// a gate that takes a sealed approval is not approved without.
332
async fn decide_with(
333
    app: &Shared,
334
    row: &db::repos::RepoRow,
335
    run: &fdb::Run,
336
    name: &str,
337
    who: &db::accounts::Account,
338
    approve: bool,
05f1a85a Andy McCall ↶ 339
    through: Through<'_>,
47e37402 Andy McCall ↶ 340
) -> Result<String, String> {
05f1a85a Andy McCall ↶ 341
    let Through { seal, via } = through;
417184c7 Andy McCall ↶ 342
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
343
    let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) };
47e37402 Andy McCall ↶ 344
    let approval = may_decide(app, row, run, &job, who).await?;
4a68f106 Andy McCall ↶ 345
    // A gate exists so a person decides (#258): an agent's token decides it
346
    // only where the repository allows it. A sealed decision is a person's
347
    // key, whatever carried it.
348
    if let (Some(agent), None) = (via, seal) {
349
        if !row.agents_decide_gates {
350
            return Err(format!(
351
                "{name} is decided by a person here, not through an agent's token ({agent}): decide it yourself on the run page, or a maintainer can let agents decide approval gates in Settings › OxygenFlow"
352
            ));
353
        }
354
    }
47e37402 Andy McCall ↶ 355
    if approve && approval.sealed && seal.is_none() {
356
        return Err(format!(
357
            "{name} takes an approval signed with your key: in a checkout of {}/{}, run oxy seal --flow {}/{}/{}/flow/{} --job {name}",
358
            row.owner,
359
            row.name,
360
            app.config.base_url(),
361
            row.owner,
362
            row.name,
363
            run.number
364
        ));
365
    }
417184c7 Andy McCall ↶ 366
    let target = format!("{}/{}#{} {name}", row.owner, row.name, run.number);
47e37402 Andy McCall ↶ 367
    let sealed = seal.map(|r| format!(", sealed (approval {})", &r[..12.min(r.len())])).unwrap_or_default();
05f1a85a Andy McCall ↶ 368
    // Through an agent's token, it says so (oxygenhub#229).
369
    let by = match via {
370
        Some(v) => format!("{} via {v}", who.username),
371
        None => who.username.clone(),
372
    };
373
    let detail = [seal.unwrap_or(""), &via.map(|v| format!("via {v}")).unwrap_or_default()]
374
        .iter()
375
        .filter(|s| !s.is_empty())
376
        .cloned()
377
        .collect::<Vec<_>>()
378
        .join(", ");
417184c7 Andy McCall ↶ 379
    let said = if approve {
05f1a85a Andy McCall ↶ 380
        if !fdb::approve_job(&app.db, job.id, &who.username, seal.unwrap_or(""), via.unwrap_or("")).await.map_err(|e| e.to_string())? {
417184c7 Andy McCall ↶ 381
            return Err(format!("{name} was decided on meanwhile"));
382
        }
05f1a85a Andy McCall ↶ 383
        db::accounts::audit(&app.db, Some(who), "flow.approved", &target, &detail).await.ok();
384
        format!("approved by {by}{sealed}")
417184c7 Andy McCall ↶ 385
    } else {
05f1a85a Andy McCall ↶ 386
        fdb::note_rejection(&app.db, job.id, &who.username, via.unwrap_or("")).await.map_err(|e| e.to_string())?;
387
        let why = format!("rejected by {by}{sealed}");
417184c7 Andy McCall ↶ 388
        let changed =
389
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
390
                .await
391
                .map_err(|e| e.to_string())?;
392
        report_changed(app, run.id, changed).await.ok();
05f1a85a Andy McCall ↶ 393
        db::accounts::audit(&app.db, Some(who), "flow.rejected", &target, &detail).await.ok();
417184c7 Andy McCall ↶ 394
        why
395
    };
396
    let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said.clone() };
397
    fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
398
    Ok(format!("{name}: {said}"))
399
}
400
dc59d1b5 Andy McCall ↶ 401
/// Tell the people who may approve a job of `run` that has just become
402
/// ready (its dependencies all passed) and waits for them (oxygenflow#57).
403
/// Told once: the job's log notes it.
404
async fn tell_approvers(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
405
    const TOLD: &str = "waiting for approval; the people who may approve were told";
406
    let Ok(jobs) = fdb::jobs(&app.db, run.id).await else { return };
407
    for job in jobs.iter().filter(|j| j.status == "queued" && j.approval == "waiting") {
408
        if !job.depends_on().iter().all(|d| jobs.iter().any(|j| &j.name == d && j.status == "passed")) {
409
            continue;
410
        }
411
        let already = fdb::log(&app.db, job.id, 0, 50).await.unwrap_or_default().iter().any(|l| l.text == TOLD);
412
        if already {
413
            continue;
414
        }
415
        let mut targets = Vec::new();
416
        for a in db::accounts::accounts(&app.db).await.unwrap_or_default().iter().filter(|a| !a.blocked()) {
417
            if may_decide(app, row, run, job, a).await.is_ok() {
418
                targets.push((a.id, db::notify::Reason::Participating));
419
            }
420
        }
421
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: TOLD.into() };
422
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
423
        let title = format!("{}/{} run #{}: {} waits for your approval", row.owner, row.name, run.number, job.name);
424
        let url = format!("/{}/{}/flow/{}", row.owner, row.name, run.number);
425
        crate::notify::tell(app, "flow.approval", &title, &url, &run.actor, Some(row.id), targets).await;
426
    }
47e37402 Andy McCall ↶ 427
}
428
429
/// What approving job `name` of `run` means, for the approver to sign
430
/// (oxygenhub#235): this repository, the run's URL, the job's hash with its
431
/// sandbox, the run's state, the blobs it takes, its environment.
432
pub async fn approval_statement(
433
    app: &Shared,
434
    row: &db::repos::RepoRow,
435
    run: &fdb::Run,
436
    name: &str,
437
) -> Result<(oxy_core::approval::Statement, fdb::Job), String> {
438
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
439
    let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) };
440
    let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?;
441
    let sandbox: flow_core::Sandbox = serde_json::from_str(&job.sandbox).map_err(|e| format!("stored sandbox: {e}"))?;
442
    let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
443
    let repo = app.repos.read(&repo_name, |r, _| Ok(r.id()?)).await.map_err(|e| e.to_string())?;
444
    let state = oxy_core::hash::ObjectRef::from_hex(&run.state).ok_or("the run has no state")?;
445
    let inputs = artifact_inputs(app, &job, &spec).await?.iter().filter_map(|i| oxy_core::hash::ObjectRef::from_hex(&i.blob)).collect();
446
    let statement = oxy_core::approval::Statement {
447
        repo,
448
        run: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, run.number),
449
        job: name.to_string(),
450
        job_hash: oxy_core::hash::ObjectRef(spec.hash(&sandbox)),
451
        state,
452
        inputs,
453
        environment: spec.environment.clone().unwrap_or_default(),
454
        approve: true,
455
    };
456
    Ok((statement, job))
457
}
458
317d776c Andy McCall ↶ 459
/// An unlinked key's account, when its identity succeeds a linked key's
460
/// through `previous` links that each keep the key pair (a renewal or a
461
/// delegation, Oxygen spec §14.2), with the identities from it down to the
462
/// linked one, to link (oxygenhub#236). A step that changes the key pair
463
/// ends the search: that is a new key, linked by proving it.
464
async fn linked_through_chain(
465
    app: &Shared,
466
    repo: &RepoName,
467
    signer: &oxy_core::identity::Identity,
468
    links: &[(String, String)],
469
) -> Option<(String, Vec<oxy_core::identity::Identity>)> {
470
    let mut chain = vec![signer.clone()];
471
    let mut cur = signer.clone();
472
    for _ in 0..64 {
473
        let prev = cur.previous?;
474
        let hex = prev.to_hex();
475
        let found = match db::accounts::identity_by_key(&app.db, &hex).await.ok()? {
476
            Some(l) => oxy_core::identity::Identity::decode(&l.object).ok(),
477
            None => app.repos.read(repo, move |r, _| Ok(r.store().get_identity(&prev).ok())).await.ok().flatten(),
478
        };
479
        let id = found?;
480
        if id.hash() != prev || id.ed25519_pk != cur.ed25519_pk || id.mldsa65_pk != cur.mldsa65_pk {
481
            return None;
482
        }
483
        if let Some((_, u)) = links.iter().find(|(k, _)| *k == hex) {
484
            return Some((u.clone(), chain));
485
        }
486
        chain.push(id.clone());
487
        cur = id;
488
    }
489
    None
490
}
491
47e37402 Andy McCall ↶ 492
/// Decide on job `name` of `run` from a signed approval (oxygenhub#235): it
493
/// must verify, cover exactly this job's statement, and be signed by a key
494
/// linked to an account (a delegated agent key is not) that may decide; it
495
/// is kept in the repository. What happened, or why not.
496
pub async fn decide_sealed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, name: &str, signed_hex: &str) -> Result<String, String> {
497
    let bytes = hex::decode(signed_hex.trim()).map_err(|_| "the signed approval is not hex".to_string())?;
498
    let sa = oxy_core::approval::SignedApproval::decode(&bytes).map_err(|e| format!("not a signed approval: {e}"))?;
499
    sa.verify().map_err(|e| format!("it does not verify: {e}"))?;
500
    let (mut expected, _) = approval_statement(app, row, run, name).await?;
501
    let given = sa.statement().map_err(|e| e.to_string())?;
502
    expected.approve = given.approve;
503
    if !sa.covers(&expected) {
504
        return Err(format!(
505
            "it approves something else than job {name} of run #{} as it is now (another run, state, plan or environment): sign again",
506
            run.number
507
        ));
508
    }
509
    let key = sa.signer.hash().to_hex();
510
    let links = db::accounts::identity_accounts(&app.db).await.map_err(|e| e.to_string())?;
511
    let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
317d776c Andy McCall ↶ 512
    let username = match links.iter().find(|(k, _)| *k == key) {
513
        Some((_, u)) => u.clone(),
514
        // A renewal or delegation of a linked key keeps its key pair: it is
515
        // the same person's, and linked now (oxygenhub#236).
516
        None => match linked_through_chain(app, &repo_name, &sa.signer, &links).await {
517
            Some((u, chain)) => {
518
                if let Ok(Some(acc)) = db::accounts::account_by_name(&app.db, &u).await {
519
                    for id in &chain {
520
                        let k = id.hash().to_hex();
521
                        if let Ok(None) = db::accounts::identity_by_key(&app.db, &k).await {
522
                            db::accounts::link_identity(&app.db, acc.id, &k, &id.name, &id.email, &id.encode()).await.ok();
523
                        }
524
                    }
525
                }
526
                u
527
            }
528
            None => {
529
                return Err(format!(
530
                    "key {} is not linked to an account here, nor a renewal of a key that is (link it under Settings › Keys; an agent's delegated key cannot approve)",
531
                    &key[..12]
532
                ))
533
            }
534
        },
535
    };
536
    let who = db::accounts::account_by_name(&app.db, &username).await.map_err(|e| e.to_string())?.ok_or("the key's account is gone")?;
47e37402 Andy McCall ↶ 537
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
538
    let job = jobs.iter().find(|j| j.name == name).ok_or("no such job")?;
539
    may_decide(app, row, run, job, &who).await?;
965b950f Andy McCall ↶ 540
    let (rid, signers) = (sa.attest.repo, sa.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
47e37402 Andy McCall ↶ 541
    let kept = app.repos.write(&repo_name, move |r, _| Ok(r.add_approval(&sa)?)).await.map_err(|e| e.to_string())?;
965b950f Andy McCall ↶ 542
    crate::tlog::log(app, vec![crate::tlog::Pending { kind: "approval", repo: rid, repo_row: Some(row.id), object: kept, signers }]).await;
05f1a85a Andy McCall ↶ 543
    decide_with(app, row, run, name, &who, given.approve, Through { seal: Some(&kept.to_hex()), via: None }).await
dc59d1b5 Andy McCall ↶ 544
}
545
417184c7 Andy McCall ↶ 546
/// End the approvals that waited too long (oxygenflow#57): from when the
547
/// jobs a waiting job depends on finished (or its run began).
548
pub async fn expire_approvals(app: &Shared, now: i64) -> sqlx::Result<usize> {
549
    let mut ended = 0;
550
    for job in fdb::waiting_for_approval(&app.db).await? {
551
        let Ok(spec) = serde_json::from_str::<flow_core::Job>(&job.spec) else { continue };
552
        let Some(approval) = spec.approval else { continue };
553
        let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? else { continue };
554
        let jobs = fdb::jobs(&app.db, run.id).await?;
555
        let wanted = job.depends_on();
556
        let deps: Vec<&fdb::Job> = jobs.iter().filter(|j| wanted.contains(&j.name)).collect();
557
        if deps.iter().any(|d| d.status != "passed") {
558
            continue;
559
        }
560
        let since = deps.iter().filter_map(|d| d.finished_ms).max().unwrap_or(run.created_ms);
561
        if now - since < approval.expires_s as i64 * 1000 {
562
            continue;
563
        }
564
        let hours = approval.expires_s as f64 / 3600.0;
565
        let why =
566
            format!("no approval within {}", if hours >= 1.0 { format!("{hours:.0}h") } else { format!("{}m", approval.expires_s / 60) });
567
        let changed =
568
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
569
                .await?;
570
        report_changed(app, run.id, changed).await?;
571
        ended += 1;
572
    }
573
    Ok(ended)
a9f1c0bf Andy McCall ↶ 574
}
575
576
/// Newest run wins (oxygenhub#267): whether a new run started by `trigger`
577
/// cancels the older runs of its bookmark still live.
578
///
579
/// - A **push** run supersedes the older push runs of its bookmark: they
580
///   test states the bookmark has moved past. Diffusion's bumps start push
581
///   runs too.
582
/// - A **proposal** run (bookmark `proposal/N`, started when the
583
///   proposal's target moved, `web::proposals::settle_after_push`)
584
///   supersedes the older proposal runs of the same proposal: they test
585
///   projected states it no longer has. It leaves the proposal's source
586
///   bookmark's push runs alone: those test the source head, a different
587
///   state that other proposals or the bookmark's own checks may want.
588
/// - **Tag**, **manual** ("Run pipeline" on a bookmark or a change),
589
///   **schedule** and **rerun** runs were each asked for in their own
590
///   right (a person or a cron), so they supersede nothing and nothing
591
///   supersedes them: a rerun of an old push run tests the state it names,
592
///   on purpose. They are cancelled by hand.
593
///
594
/// Only runs of the same trigger on the same bookmark are superseded, so a
595
/// push never cancels a rerun or a proposal's run, nor the other way round.
596
fn supersedes(trigger: &str) -> bool {
597
    matches!(trigger, "push" | "proposal")
417184c7 Andy McCall ↶ 598
}
599
a9f1c0bf Andy McCall ↶ 600
/// Cancel what run `number` supersedes ([`supersedes`]): the older runs of
601
/// `bookmark` started by the same `trigger` still queued, and those already
602
/// running unless the repository keeps them (`flow_keep_running`, Settings
603
/// › OxygenFlow). A push run's scope reaches back past them either way
604
/// ([`fdb::latest_on`]).
605
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, trigger: &str, number: i64) {
606
    let Ok(older) = fdb::live_runs_before(&app.db, row.id, bookmark, trigger, number, !row.flow_keep_running).await else { return };
745d225d Andy McCall ↶ 607
    for run in older {
608
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
609
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
610
        }
611
    }
612
}
613
2abea308 Andy McCall ↶ 614
/// A proposal landed (#263): its runs that no runner has started can no
615
/// longer change anything, and they hold the queue ahead of the target's
616
/// own run, so they are cancelled. Its source bookmark's push runs (when
617
/// that bookmark is in this repository and is not the target) and its
618
/// `proposal/N` runs; running ones are left to finish.
619
pub async fn moot_after_landing(app: &Shared, row: &db::repos::RepoRow, p: &db::proposals::ProposalRow) {
620
    let now = db::now_ms();
621
    let mut moot = Vec::new();
622
    let same_repo = p.source_repo_id.is_none_or(|id| id == row.id);
623
    if p.source_kind == "bookmark" && same_repo && !p.source_bookmark.is_empty() && p.source_bookmark != p.target {
624
        moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &p.source_bookmark, "push", now).await.unwrap_or_default());
625
    }
626
    moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &format!("proposal/{}", p.number), "proposal", now).await.unwrap_or_default());
627
    for run in moot {
628
        if let Err(e) = cancel_with(app, &run, &format!("moot: proposal #{} landed", p.number)).await {
629
            tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
630
        }
631
    }
632
}
633
c889911b Andy McCall ↶ 634
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
635
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
636
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
637
    use flow_core::pipeline::Event;
638
    let t = match trigger.strip_prefix("rerun:") {
639
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
640
        None => trigger,
641
    };
642
    if let Some(tag) = t.strip_prefix("tag:") {
643
        Event::Tag(tag)
644
    } else if t == "push" {
645
        Event::Push(bookmark)
646
    } else if t == "schedule" {
647
        Event::Schedule
648
    } else {
649
        Event::Manual
650
    }
1edac4b6 Andy McCall ↶ 651
}
652
c889911b Andy McCall ↶ 653
/// The event to repeat when rerunning `run`.
654
fn original_event(run: &fdb::Run) -> String {
655
    let t = run.trigger.as_str();
656
    match t.strip_prefix("rerun:") {
657
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
658
        None => t.to_string(),
659
    }
660
}
661
d7ce41ac Andy McCall ↶ 662
/// The paths the changes a push brought touch: the changes in `heads`'
663
/// state that the bookmark's previous run did not have, and the files their
664
/// edits and tree operations name, as the new state places them. `None`
665
/// (run every job) for the bookmark's first run, or when it cannot be told.
666
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
667
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
668
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
669
    if old.is_empty() {
670
        return None;
671
    }
672
    let repo = RepoName::new(&row.owner, &row.name)?;
673
    let new = heads.to_vec();
674
    app.repos
675
        .read(&repo, move |r, cache| {
676
            let fresh = r.negotiate(&old, &new)?;
677
            let loaded = cache.load(r, &new)?;
678
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
679
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
680
            let mut paths = std::collections::BTreeSet::new();
681
            for h in fresh {
682
                let (c, _) = r.store().get_change_lazy(&h)?;
683
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
684
                for n in nodes {
685
                    if let Some(p) = by_node.get(&n) {
686
                        paths.insert((*p).clone());
687
                    }
688
                }
689
            }
690
            Ok(paths.into_iter().collect::<Vec<String>>())
691
        })
692
        .await
693
        .ok()
694
}
695
696
/// Whether a job runs for a push that touched `touched`: always without a
697
/// scope or without knowing what was touched, else when a path matches.
698
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
699
    let Some(touched) = touched else { return true };
700
    if scope.is_empty() {
701
        return true;
702
    }
703
    let mut b = globset::GlobSetBuilder::new();
704
    for g in scope {
705
        match globset::Glob::new(g) {
706
            Ok(g) => {
707
                b.add(g);
708
            }
709
            Err(_) => return true,
710
        }
711
    }
712
    let Ok(set) = b.build() else { return true };
713
    touched.iter().any(|p| set.is_match(p))
714
}
715
716
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
717
/// left out of the run, unless a job that runs depends on them.
718
#[allow(clippy::too_many_arguments)]
719
pub async fn start_run_scoped(
720
    app: &Shared,
721
    row: &db::repos::RepoRow,
722
    bookmark: &str,
723
    state: &str,
724
    heads: &[ChangeHash],
725
    actor: &str,
726
    trigger: &str,
727
    touched: Option<&[String]>,
728
) -> Result<Option<i64>, String> {
729
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
730
}
731
1edac4b6 Andy McCall ↶ 732
/// Start a run of the pipeline in the state `heads` make (whose state
733
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
734
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
735
/// whose access the run's subrepo reads are checked against. Returns the
736
/// run's number, or `None` when the state has no pipeline. The caller
737
/// checks that Flow is enabled.
738
pub async fn start_run(
739
    app: &Shared,
740
    row: &db::repos::RepoRow,
741
    bookmark: &str,
742
    state: &str,
743
    heads: &[ChangeHash],
744
    actor: &str,
745
    trigger: &str,
746
) -> Result<Option<i64>, String> {
d7ce41ac Andy McCall ↶ 747
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
748
}
749
750
#[allow(clippy::too_many_arguments)]
751
async fn start_run_with(
752
    app: &Shared,
753
    row: &db::repos::RepoRow,
754
    bookmark: &str,
755
    state: &str,
756
    heads: &[ChangeHash],
757
    actor: &str,
758
    trigger: &str,
759
    touched: Option<&[String]>,
760
) -> Result<Option<i64>, String> {
0650d94e Andy McCall ↶ 761
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await
348f8c4b Andy McCall ↶ 762
}
763
0650d94e Andy McCall ↶ 764
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock;
765
/// `only`, when not empty, the jobs a manual run starts (oxygenhub#130).
348f8c4b Andy McCall ↶ 766
#[allow(clippy::too_many_arguments)]
767
async fn start_run_at(
768
    app: &Shared,
769
    row: &db::repos::RepoRow,
770
    bookmark: &str,
771
    state: &str,
772
    heads: &[ChangeHash],
773
    actor: &str,
774
    trigger: &str,
775
    touched: Option<&[String]>,
776
    at: Option<flow_core::pipeline::When>,
0650d94e Andy McCall ↶ 777
    only: &[String],
348f8c4b Andy McCall ↶ 778
) -> Result<Option<i64>, String> {
1edac4b6 Andy McCall ↶ 779
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
42fa9554 Andy McCall ↶ 780
    let h = heads.to_vec();
781
    let text = app
782
        .repos
783
        .read(&repo, move |r, cache| {
784
            let loaded = cache.load(r, &h)?;
785
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
786
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
787
                _ => None,
788
            })
789
        })
1edac4b6 Andy McCall ↶ 790
        .await
791
        .map_err(|e| format!("reading the pipeline: {e}"))?;
792
    let Some(text) = text else { return Ok(None) };
42fa9554 Andy McCall ↶ 793
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
794
    hex.sort();
795
    let heads_s = hex.join("+");
1edac4b6 Andy McCall ↶ 796
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
797
    let id = match Pipeline::parse(&text) {
42fa9554 Andy McCall ↶ 798
        Err(problems) => {
799
            let msg = problems.to_string();
1edac4b6 Andy McCall ↶ 800
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
42fa9554 Andy McCall ↶ 801
        }
802
        Ok(p) => {
803
            let depths = p.depths();
d7ce41ac Andy McCall ↶ 804
            // Jobs out of the push's scope stay out, unless a kept job
805
            // needs them.
c889911b Andy McCall ↶ 806
            // The jobs this run's event starts (`on`), out of those its
807
            // push touched (`scope`); then what they depend on.
348f8c4b Andy McCall ↶ 808
            let event = match at {
809
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
810
                None => event_of(trigger, bookmark),
811
            };
0650d94e Andy McCall ↶ 812
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
813
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
814
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
815
            } else {
816
                // Named jobs (oxygenhub#130): each one this event starts.
817
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
818
                for name in only {
819
                    if !startable.contains(&name.as_str()) {
820
                        let what = if p.jobs.contains_key(name) {
821
                            "is not started by hand (no `on { manual }`)"
822
                        } else {
823
                            "is not in the pipeline"
824
                        };
825
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
826
                    }
827
                }
828
                only.iter().cloned().collect()
829
            };
d7ce41ac Andy McCall ↶ 830
            loop {
831
                let needed: Vec<String> = p
832
                    .order()
833
                    .into_iter()
834
                    .filter(|j| keep.contains(&j.name))
835
                    .flat_map(|j| j.depends_on.iter().cloned())
836
                    .filter(|d| !keep.contains(d))
837
                    .collect();
838
                if needed.is_empty() {
839
                    break;
840
                }
841
                keep.extend(needed);
842
            }
44a3d2eb Andy McCall ↶ 843
            let mut jobs: Vec<fdb::NewJob> = p
42fa9554 Andy McCall ↶ 844
                .order()
845
                .into_iter()
d7ce41ac Andy McCall ↶ 846
                .filter(|j| keep.contains(&j.name))
42fa9554 Andy McCall ↶ 847
                .map(|j| {
848
                    let sb = &p.sandboxes[&j.sandbox];
849
                    fdb::NewJob {
850
                        name: j.name.clone(),
851
                        depth: depths[j.name.as_str()] as i64,
852
                        depends: j.depends_on.clone(),
853
                        spec: serde_json::to_string(j).unwrap_or_default(),
854
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
855
                        runtime: sb.runtime.as_str().into(),
44a3d2eb Andy McCall ↶ 856
                        need_disk_mib: None,
5588bb36 Andy McCall ↶ 857
                        reused: None,
42fa9554 Andy McCall ↶ 858
                    }
859
                })
860
                .collect();
44a3d2eb Andy McCall ↶ 861
            // What each job needs, learned from its past runs (oxygenflow#41).
862
            for j in &mut jobs {
863
                j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?;
864
            }
c889911b Andy McCall ↶ 865
            if jobs.is_empty() {
866
                // Nothing this event starts (or this push touched).
d7ce41ac Andy McCall ↶ 867
                return Ok(None);
5588bb36 Andy McCall ↶ 868
            }
869
            // A job that passed on this very state, with the same spec and
870
            // sandbox, is not run again by a push (#265): main after a
871
            // proposal landed it as tested. Never one that publishes, deploys
872
            // or holds secrets, nor one whose artifacts a job of this run
873
            // takes; and not where the repository turned reuse off.
874
            if trigger == "push" && row.flow_reuse {
875
                let consumed: std::collections::HashSet<&str> = p
876
                    .order()
877
                    .into_iter()
878
                    .filter(|j| keep.contains(&j.name))
879
                    .flat_map(|j| j.inputs.iter().map(|i| i.from.as_str()))
880
                    .collect();
881
                for j in &mut jobs {
882
                    let Some(spec) = p.jobs.get(&j.name) else { continue };
883
                    if !reusable(spec) || consumed.contains(j.name.as_str()) {
884
                        continue;
885
                    }
886
                    j.reused =
887
                        fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
888
                }
d7ce41ac Andy McCall ↶ 889
            }
42fa9554 Andy McCall ↶ 890
            let hash = hex::encode(p.hash());
1edac4b6 Andy McCall ↶ 891
            let id =
892
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
893
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
5588bb36 Andy McCall ↶ 894
                for j in jobs.iter().filter(|j| j.reused.is_none()) {
a0d4bbfb Andy McCall ↶ 895
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
42fa9554 Andy McCall ↶ 896
                }
5588bb36 Andy McCall ↶ 897
                // Reused passes (#265) report as any pass does: their checks,
898
                // and a run that passed with them (Diffusion, landing a bump).
899
                if jobs.iter().any(|j| j.reused.is_some()) {
900
                    let reused: Vec<fdb::Job> =
901
                        fdb::jobs(&app.db, run.id).await.unwrap_or_default().into_iter().filter(|j| j.reused_from().is_some()).collect();
902
                    if let Err(e) = report_changed(app, run.id, reused).await {
903
                        tracing::warn!("flow: reporting reused passes of run #{}: {e}", run.number);
904
                    }
905
                }
1c8276b9 Andy McCall ↶ 906
                images_check(app, row, state, run.number, &jobs).await;
42fa9554 Andy McCall ↶ 907
            }
908
            id
909
        }
910
    };
1edac4b6 Andy McCall ↶ 911
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
a9f1c0bf Andy McCall ↶ 912
    if supersedes(trigger) {
913
        supersede(app, row, bookmark, trigger, run.number).await;
914
    }
1edac4b6 Andy McCall ↶ 915
    Ok(Some(run.number))
42fa9554 Andy McCall ↶ 916
}
917
1c8276b9 Andy McCall ↶ 918
/// The `pipeline/images` check (#137): set failing when an image the run's
919
/// jobs pin is held by neither the hub nor a runner, so a proposal pinning
920
/// one says so before it lands rather than its runs queueing for good;
921
/// passed again by a later run of the state once every image is held.
922
async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) {
923
    let mut pinned: Vec<String> = jobs
924
        .iter()
925
        .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok())
926
        .filter_map(|v| v["rootfs"].as_str().map(String::from))
927
        .filter(|r| !r.is_empty())
928
        .collect();
929
    pinned.sort();
930
    pinned.dedup();
931
    if pinned.is_empty() {
932
        return;
933
    }
934
    let mut held = crate::images::held(&app.db).await.unwrap_or_default();
935
    // Nobody can be said to lack an image unless some runner is known and
936
    // every one reports what it holds.
937
    let runners: Vec<db::runners::Runner> =
938
        db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect();
939
    if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) {
940
        return;
941
    }
942
    for r in &runners {
943
        held.extend(r.image_list().unwrap_or_default());
944
    }
945
    let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect();
946
    // Only a missing image makes the check; one that was failing on this
947
    // state turns to passed once its images are held.
948
    let failing_before = db::hooks::checks_for_state(&app.db, row.id, state)
949
        .await
950
        .unwrap_or_default()
951
        .iter()
952
        .any(|c| c.name == "pipeline/images" && c.status != "passed");
953
    if missing.is_empty() && !failing_before {
954
        return;
955
    }
956
    let (status, summary) = if missing.is_empty() {
957
        (
958
            "passed",
959
            format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }),
960
        )
961
    } else {
962
        let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect();
963
        ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", ")))
964
    };
965
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
966
    if let Ok(c) = db::hooks::upsert_check(
967
        &app.db,
968
        row.id,
969
        db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" },
970
    )
971
    .await
972
    {
973
        let api = crate::api::check_of(&c);
33d98147 Andy McCall 974
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1c8276b9 Andy McCall ↶ 975
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
976
    }
977
}
978
a0d4bbfb Andy McCall ↶ 979
/// Record a job's check; it links to the run's page by the run's number.
980
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
981
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
42fa9554 Andy McCall ↶ 982
    let name = format!("flow/{job}");
983
    let c = db::hooks::upsert_check(
984
        &app.db,
985
        row.id,
986
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
987
    )
988
    .await;
989
    if let Ok(c) = c {
990
        let api = crate::api::check_of(&c);
33d98147 Andy McCall 991
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
42fa9554 Andy McCall ↶ 992
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
993
    }
994
}
995
996
/// The check status a job's status shows as.
997
fn check_status(job_status: &str) -> &'static str {
998
    match job_status {
999
        "queued" => "queued",
1000
        "running" => "running",
1001
        "passed" => "passed",
1002
        "skipped" => "skipped",
745d225d Andy McCall ↶ 1003
        "cancelled" => "cancelled",
42fa9554 Andy McCall ↶ 1004
        _ => "failed",
1005
    }
1006
}
1007
b6b7e4cf Andy McCall ↶ 1008
/// The runner a bearer token belongs to: a registered runner, or one
1009
/// configured with a hub.toml token (tracked from its first request). A
1010
/// disabled runner is refused.
92a797b2 Andy McCall ↶ 1011
pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
42fa9554 Andy McCall ↶ 1012
    let Some(t) = oxy_server::bearer_token(headers) else {
1013
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
b6b7e4cf Andy McCall ↶ 1014
    };
1015
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
1016
    let runner = match db::runners::by_token(&app.db, &hash).await? {
1017
        Some(r) => r,
1018
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
1019
            db::runners::configured(&app.db, &hash, "runner").await?
1020
        }
1021
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
42fa9554 Andy McCall ↶ 1022
    };
b6b7e4cf Andy McCall ↶ 1023
    if runner.state == "disabled" {
1024
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
42fa9554 Andy McCall ↶ 1025
    }
b6b7e4cf Andy McCall ↶ 1026
    Ok(runner)
42fa9554 Andy McCall ↶ 1027
}
1028
b6b7e4cf Andy McCall ↶ 1029
/// `POST /api/v1/flow/runners/register`: a one-time registration token
1030
/// from Admin → Runners buys a runner credential, binding the runner's
1031
/// signing identity (hub spec §13.2).
1032
pub async fn register(
1033
    State(app): State<Shared>,
1034
    Json(req): Json<flow_core::api::Register>,
1035
) -> Result<Json<flow_core::api::Registered>, ApiError> {
1036
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
1037
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
1038
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
1039
    if name.is_empty() {
1040
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
1041
    }
1042
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
1043
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
1044
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
1045
    let runner =
1046
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
1047
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
1048
        })?;
1049
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
1050
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
1051
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
1052
}
1053
42fa9554 Andy McCall ↶ 1054
/// `POST /api/v1/flow/claim`.
1055
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 1056
    let me = runner_auth(&app, &headers).await?;
1057
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
1058
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
eef677c4 Andy McCall ↶ 1059
    if let Some(holding) = &req.holding {
1060
        end_jobs_not_held(&app, &me, holding).await?;
1061
    }
b6b7e4cf Andy McCall ↶ 1062
    // A draining runner finishes what it has and takes nothing new.
1063
    if me.state == "draining" {
1064
        return Ok(StatusCode::NO_CONTENT.into_response());
1065
    }
1066
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
42fa9554 Andy McCall ↶ 1067
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
efd29a00 Andy McCall ↶ 1068
    // What it holds and what it is (OxygenFlow spec §11): the images'
1069
    // architectures it reports are remembered for every runner's claims.
1070
    if let Some(archs) = &req.image_archs {
1071
        fdb::record_image_archs(&app.db, archs).await?;
1072
    }
1073
    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 ↶ 1074
    // What it offers, kept for the placement pass; a job it declined is
1075
    // not offered to it again while this stays the same (spec §11.5).
1076
    let profile = profile_of(&req, &arch);
92a797b2 Andy McCall ↶ 1077
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
2fb70d01 Andy McCall ↶ 1078
    if let Some(f) = &req.facts {
1079
        db::runners::set_facts(&app.db, me.id, f).await?;
1080
    }
a46d15e7 Andy McCall ↶ 1081
    app.swarm.claimed(me.id, req.swarm.as_ref());
c2be148c Andy McCall ↶ 1082
    // The build caches it holds (oxygenflow#51), and when each last kept a
1083
    // disk (oxygenflow#54; 0 from an older runner), for placing jobs by.
4fa0da12 Andy McCall ↶ 1084
    if let Some(c) = &req.caches {
c2be148c Andy McCall ↶ 1085
        let kept = |slot: &str| req.cache_times.as_ref().and_then(|t| t.get(slot)).map_or(0, |ms| *ms as i64);
4fa0da12 Andy McCall ↶ 1086
        CACHES
1087
            .lock()
1088
            .unwrap_or_else(|p| p.into_inner())
1089
            .get_or_insert_with(Default::default)
c2be148c Andy McCall ↶ 1090
            .insert((app.repos.root().display().to_string(), me.id), c.iter().map(|s| (s.clone(), kept(s))).collect());
4fa0da12 Andy McCall ↶ 1091
    }
b0612e7d Andy McCall ↶ 1092
    let room = fdb::Room {
4d2e44d7 Andy McCall ↶ 1093
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
b0612e7d Andy McCall ↶ 1094
        memory_mib: req.free_memory_mib,
1095
        cpus: req.free_cpus,
1096
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
efd29a00 Andy McCall ↶ 1097
        arch,
1098
        image_archs: fdb::image_archs(&app.db).await?,
92a797b2 Andy McCall ↶ 1099
        fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() },
44a3d2eb Andy McCall ↶ 1100
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
b0612e7d Andy McCall ↶ 1101
    };
7f5f6c05 Andy McCall ↶ 1102
    // The labels it reports now, or those it registered with, and those
1103
    // an administrator gave it (#98).
1104
    let mut labels: Vec<String> = match &req.info {
c4c87f48 Andy McCall ↶ 1105
        Some(i) => i.labels.clone(),
7f5f6c05 Andy McCall ↶ 1106
        None => me.reported_label_list().into_iter().map(String::from).collect(),
c4c87f48 Andy McCall ↶ 1107
    };
7f5f6c05 Andy McCall ↶ 1108
    for l in me.admin_label_list() {
1109
        if !labels.iter().any(|x| x == l) {
1110
            labels.push(l.to_string());
1111
        }
1112
    }
501d8b26 Andy McCall ↶ 1113
    // Ranking (oxygenflow#42): a job another free runner would do better
1114
    // (it holds the image, or is faster) is held for it briefly.
1115
    let runners = db::runners::list(&app.db).await?;
1116
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
1117
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
4fa0da12 Andy McCall ↶ 1118
    let queue = fdb::queue(&app.db).await?;
1119
    let owners: std::collections::HashMap<i64, (String, i64)> = queue.iter().map(|q| (q.job_id, (q.owner.clone(), q.repo_id))).collect();
1120
    // Each job's build cache slot (oxygenflow#51), as runners report theirs.
1121
    let slots: std::collections::HashMap<i64, String> =
1122
        queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect();
501d8b26 Andy McCall ↶ 1123
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
1124
    let now = db::now_ms();
1125
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
c95446a1 Andy McCall ↶ 1126
    let hub_key = app.repos.root().display().to_string();
501d8b26 Andy McCall ↶ 1127
    let better = |j: &fdb::Job| -> bool {
1128
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
1129
        let rootfs = rootfs_of(j);
1130
        let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner());
1131
        let holds = holds.get_or_insert_with(Default::default);
1132
        holds.retain(|_, t| now - *t < 600_000);
c95446a1 Andy McCall ↶ 1133
        if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
501d8b26 Andy McCall ↶ 1134
            return false;
1135
        }
4fa0da12 Andy McCall ↶ 1136
        let slot = slots.get(&j.id).map(String::as_str).unwrap_or("");
c2be148c Andy McCall ↶ 1137
        let mine = fit(&hub_key, &me_now, &rootfs, slot);
501d8b26 Andy McCall ↶ 1138
        let other = runners.iter().any(|r| {
1139
            r.id != me.id
1140
                && r.state == "active"
1141
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
1142
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
c2be148c Andy McCall ↶ 1143
                && fit(&hub_key, r, &rootfs, slot).beats(&mine)
501d8b26 Andy McCall ↶ 1144
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
1145
        });
1146
        if other {
c95446a1 Andy McCall ↶ 1147
            holds.entry((hub_key.clone(), j.id)).or_insert(now);
501d8b26 Andy McCall ↶ 1148
        }
1149
        other
1150
    };
c4c87f48 Andy McCall ↶ 1151
    let Some(job) =
501d8b26 Andy McCall ↶ 1152
        fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await?
c4c87f48 Andy McCall ↶ 1153
    else {
42fa9554 Andy McCall ↶ 1154
        return Ok(StatusCode::NO_CONTENT.into_response());
1155
    };
501d8b26 Andy McCall ↶ 1156
    // Why here (oxygenflow#42).
c95446a1 Andy McCall ↶ 1157
    let waited =
1158
        HELD.lock().unwrap_or_else(|p| p.into_inner()).as_mut().and_then(|h| h.remove(&(hub_key.clone(), job.id))).map(|t| now - t);
501d8b26 Andy McCall ↶ 1159
    let rootfs = rootfs_of(&job);
1160
    let mut why = vec![if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
1161
        "holds the image".to_string()
1162
    } else {
1163
        "fetches the image from the hub".to_string()
1164
    }];
c2be148c Andy McCall ↶ 1165
    if let Some(kept) = slots.get(&job.id).and_then(|slot| cache_kept(&hub_key, me.id, slot)) {
1166
        why.insert(
1167
            0,
1168
            if kept > 0 { format!("holds its build cache (kept {} ago)", ago(now - kept)) } else { "holds its build cache".into() },
1169
        );
4fa0da12 Andy McCall ↶ 1170
    }
501d8b26 Andy McCall ↶ 1171
    match running.get(&me.id).copied().unwrap_or(0) {
1172
        0 => why.push("idle".into()),
1173
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
1174
    }
1175
    if let Some(sp) = me_now.speed {
1176
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
1177
    }
1178
    if let Some(w) = waited {
1179
        why.push(format!("after {} s held for a better runner", w / 1000));
1180
    }
1181
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
b6b7e4cf Andy McCall ↶ 1182
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
42fa9554 Andy McCall ↶ 1183
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1184
    let row =
1185
        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 ↶ 1186
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
42fa9554 Andy McCall ↶ 1187
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
5dead1f4 Andy McCall ↶ 1188
    // The job's limit: its own timeout, else its sandbox's, else the site
1189
    // default, never past the site maximum; recorded for the reaper.
1190
    let limits = limits(&app).await;
1191
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
1192
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
7f5f6c05 Andy McCall ↶ 1193
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
1194
    // ...and never past the runner's own limit, when it has one (#98).
1195
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
1196
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
1197
    }
5dead1f4 Andy McCall ↶ 1198
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
42fa9554 Andy McCall ↶ 1199
    let claim = Claim {
1200
        job_id: job.id,
1201
        run_id: run.id,
1202
        repo: format!("{}/{}", row.owner, row.name),
1203
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
1204
        token: secret,
1205
        heads: run.head_list().into_iter().map(String::from).collect(),
1206
        pipeline_hash: run.pipeline_hash.clone(),
5dead1f4 Andy McCall ↶ 1207
        job: spec,
1208
        sandbox,
1209
        timeout_s,
fd3db644 Andy McCall ↶ 1210
        subrepos: vec![],
9a065bc0 Andy McCall ↶ 1211
        state: run.state.clone(),
1212
        bookmark: run.bookmark.clone(),
1213
        run_number: run.number,
2a83f530 Andy McCall ↶ 1214
        // The site's, the owner's and the repository's variables (#102).
1215
        env: crate::variables::env_for_repo(&app.db, &row).await?,
4ac01a73 Andy McCall ↶ 1216
        secrets: vec![],
cd437c4f Andy McCall ↶ 1217
        inputs: vec![],
42fa9554 Andy McCall ↶ 1218
    };
fd3db644 Andy McCall ↶ 1219
    let mut claim = claim;
4ac01a73 Andy McCall ↶ 1220
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
1221
    // The secrets the job declares, as files (#107); one not set is said
1222
    // at the top of the log and left out, so the job fails on the file.
1223
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
1224
    let found = crate::secrets::for_job(&app, &row, &names).await?;
d1727c57 Andy McCall ↶ 1225
    // The identities it asks for (#103): a token each, as a file.
b644e175 Andy McCall ↶ 1226
    // An environment goes in its tokens only once the job was approved
1227
    // (oxygenflow#59): the approval is the environment's protection.
1228
    let environment = claim.job.environment.clone().filter(|_| job.approval == "approved");
1229
    if let (Some(e), None, false) = (&claim.job.environment, &environment, claim.job.identities.is_empty()) {
1230
        left_out.push(format!(
1231
            "environment {e}: not in the job's identity tokens, because the job was not approved (give it an approval block)"
1232
        ));
1233
    }
d1727c57 Andy McCall ↶ 1234
    if !claim.job.identities.is_empty() {
1235
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
1236
        for i in &claim.job.identities {
1237
            let token = issuer.mint(&crate::oidc::JobClaims {
1238
                issuer: app.config.base_url(),
1239
                audience: &i.audience,
1240
                owner: &row.owner,
1241
                name: &row.name,
1242
                visibility: &row.visibility,
1243
                bookmark: &run.bookmark,
1244
                run_number: run.number,
1245
                job: &job.name,
1246
                state: &run.state,
1247
                runner: &runner,
1248
                actor: &run.actor,
b644e175 Andy McCall ↶ 1249
                environment: environment.as_deref(),
47e37402 Andy McCall ↶ 1250
                approved_by: (job.approval == "approved").then_some(job.approved_by.as_str()),
1251
                approval: (!job.approval_seal.is_empty()).then_some(job.approval_seal.as_str()),
d1727c57 Andy McCall ↶ 1252
                now_ms: db::now_ms(),
1253
                ttl_s: timeout_s,
1254
            });
1255
            claim.secrets.push(flow_core::api::SecretInput {
1256
                name: format!("identity:{}", i.audience),
1257
                path: i.mount_path.clone(),
1258
                value: token,
1259
            });
1260
        }
1261
    }
4ac01a73 Andy McCall ↶ 1262
    for s in &claim.job.secrets {
1263
        match found.iter().find(|(n, _)| *n == s.name) {
1264
            Some((name, value)) => {
1265
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
1266
            }
1267
            None => left_out.push(format!(
1268
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
1269
                s.name, s.mount_path
1270
            )),
1271
        }
1272
    }
fd3db644 Andy McCall ↶ 1273
    if !ids.is_empty() {
1274
        fdb::set_also_read(&app.db, job.id, &ids).await?;
1275
    }
afd0be38 Andy McCall ↶ 1276
    // Say at the top of the job's log what it will not have (oxygenhub#56),
1277
    // rather than let it fail later on missing files.
1278
    if !left_out.is_empty() {
1279
        let records: Vec<flow_core::api::Record> =
1280
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
1281
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
1282
    }
fd3db644 Andy McCall ↶ 1283
    claim.subrepos = inputs;
cd437c4f Andy McCall ↶ 1284
    // Artifacts of jobs it depends on (oxygenflow#58), with the blob each
1285
    // producer's receipt names; the runner checks what it fetches against it.
1286
    match artifact_inputs(&app, &job, &claim.job).await {
1287
        Ok(found) => claim.inputs = found,
1288
        Err(why) => {
1289
            let error = format!("inputs: {why}");
1290
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1291
                .await?;
1292
            return Err(ApiError(StatusCode::CONFLICT, error));
1293
        }
1294
    }
42fa9554 Andy McCall ↶ 1295
    Ok(Json(claim).into_response())
cd437c4f Andy McCall ↶ 1296
}
1297
1298
/// The artifacts a job takes (oxygenflow#58), each from a job of the same
1299
/// run that passed with a receipt; a passing job's receipt names exactly the
1300
/// artifacts it uploaded (checked at finish), so their blobs are attested.
1301
async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> {
1302
    if spec.inputs.is_empty() {
1303
        return Ok(vec![]);
1304
    }
1305
    let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?;
1306
    let mut out = Vec::new();
1307
    for i in &spec.inputs {
1308
        let Some(producer) = jobs.iter().find(|j| j.name == i.from) else {
1309
            return Err(format!("{:?} is not a job of this run", i.from));
1310
        };
1311
        if producer.status != "passed" || producer.receipt_hash.is_empty() {
1312
            return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact));
1313
        }
1314
        let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?;
1315
        let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else {
1316
            return Err(format!("{} kept no artifact {}", i.from, i.artifact));
1317
        };
1318
        out.push(flow_core::api::ArtifactInput {
1319
            artifact: i.artifact.clone(),
1320
            from: i.from.clone(),
1321
            path: i.path.clone(),
1322
            blob: a.blob.clone(),
1323
        });
1324
    }
1325
    Ok(out)
1326
}
1327
1328
/// `GET /api/v1/flow/jobs/{id}/inputs/{index}`: the bytes of input `index`
1329
/// of a running job (oxygenflow#58), for the runner holding it.
1330
pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> {
1331
    let runner = runner_auth(&app, &headers).await?;
1332
    let job = running_job(&app, id).await?;
1333
    // A registered runner reads only the inputs of a job it holds (a
1334
    // configured token's runner names itself when it claims).
1335
    if runner.source == "registered" && job.runner != runner.name {
1336
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1337
    }
1338
    let spec: flow_core::Job =
1339
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1340
    let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?;
1341
    let Some(i) = found.get(index) else {
1342
        return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name)));
1343
    };
1344
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1345
    let row =
1346
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1347
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1348
    let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob)
1349
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?;
1350
    let bytes = app
1351
        .repos
1352
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
1353
        .await
1354
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?;
1355
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
fd3db644 Andy McCall ↶ 1356
}
1357
4d2e44d7 Andy McCall ↶ 1358
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
1359
/// labels and capacity, each list sorted.
1360
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
1361
    let sorted = |v: &[String]| {
1362
        let mut v = v.to_vec();
1363
        v.sort();
1364
        v
1365
    };
1366
    let info = req.info.as_ref();
1367
    let v = serde_json::json!({
1368
        "arch": arch,
1369
        "tiers": sorted(&req.runtimes),
1370
        "images": req.images.as_deref().map(sorted),
1371
        "labels": info.map(|i| sorted(&i.labels)),
1372
        "memory_mib": info.and_then(|i| i.memory_mib),
1373
        "cpus": info.and_then(|i| i.cpus),
1374
    });
1375
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
1376
}
1377
1378
/// A job handed back by this many different runners is unplaceable.
1379
pub const UNPLACEABLE_AFTER: usize = 3;
1380
ab4df6aa Andy McCall ↶ 1381
/// How long a runner holds a job without the hub hearing from it (claims,
1382
/// heartbeats every 30 s, logs) before the job is handed back
1383
/// (oxygenflow#76).
1384
pub const LEASE_MS: i64 = 3 * 60_000;
1385
1386
/// A job handed back more times than this ends as an error rather than
1387
/// bounce from runner to runner.
1388
pub const MAX_HANDBACKS: i64 = 5;
1389
1390
/// Hand job `id` back to the queue, ahead of new work (oxygenflow#76): its
1391
/// runner drained before leaving (`runner` is it), or its lease lapsed
1392
/// (`None`). The log says why, and the next attempt's follows on. False
1393
/// when the job was not running there.
1394
pub async fn hand_back_job(app: &Shared, id: i64, runner: Option<i64>, why: &str) -> sqlx::Result<bool> {
1395
    let Some(before) = fdb::job(&app.db, id).await? else { return Ok(false) };
1396
    let Some(n) = fdb::hand_back(&app.db, id, runner).await? else { return Ok(false) };
1397
    let t = before.started_ms.map(|s| (db::now_ms() - s).max(0) as u64).unwrap_or(0);
1398
    let record = flow_core::api::Record { t, stream: "flow".into(), text: format!("── {why}; queued again ahead of new work") };
1399
    fdb::append_log(&app.db, id, &[record], MAX_RECORDS as i64).await.ok();
1400
    tracing::info!("flow: job {id} handed back ({n}): {why}");
1401
    if n > MAX_HANDBACKS {
1402
        let error = format!("handed back {n} times; the last: {why}");
1403
        let changed =
1404
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1405
                .await?;
1406
        report_changed(app, before.run_id, changed).await?;
1407
    } else if let Some(run) = fdb::run_by_id(&app.db, before.run_id).await? {
1408
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1409
            let summary = format!("{why}; queued again ahead of new work");
1410
            report_check(app, &row, &run.state, run.number, &before.name, "queued", &summary).await;
1411
        }
1412
    }
1413
    Ok(true)
1414
}
1415
1416
/// Hand back the jobs of runners not heard from for [`LEASE_MS`]: a phone
1417
/// that lost every network, a host that died (oxygenflow#76).
1418
pub async fn requeue_lapsed(app: &Shared) -> sqlx::Result<usize> {
1419
    let lapsed = fdb::running_on_silent_runners(&app.db, db::now_ms() - LEASE_MS).await?;
1420
    let mut n = 0;
1421
    for (job, runner) in lapsed {
1422
        let why = format!("the runner {runner} has not been heard from for {} minutes", LEASE_MS / 60_000);
1423
        if hand_back_job(app, job.id, None, &why).await? {
1424
            n += 1;
1425
        }
1426
    }
1427
    Ok(n)
1428
}
1429
1430
/// `POST /api/v1/flow/jobs/{id}/release` (oxygenflow#76): a draining
1431
/// runner hands back a job it holds, as it leaves (off the allowed network,
1432
/// a restart, a spot reclaim). Not a decline: the runner is not held from
1433
/// the job and it does not count towards unplaceable.
1434
pub async fn release(
1435
    State(app): State<Shared>,
1436
    headers: HeaderMap,
1437
    Path(id): Path<i64>,
1438
    Json(d): Json<flow_core::api::Decline>,
1439
) -> Result<StatusCode, ApiError> {
1440
    let me = runner_auth(&app, &headers).await?;
1441
    db::runners::seen(&app.db, me.id, None, None).await?;
1442
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1443
    let why = match reason.trim() {
1444
        "" => format!("handed back by {}", me.name),
1445
        r => format!("handed back by {}: {r}", me.name),
1446
    };
1447
    if !hand_back_job(&app, id, Some(me.id), &why).await? {
1448
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1449
    }
1450
    Ok(StatusCode::NO_CONTENT)
1451
}
1452
4d2e44d7 Andy McCall ↶ 1453
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
1454
/// runner will not run a job it was handed. The job is queued again and
1455
/// not offered to that runner while its profile stays the same (and for
1456
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
1457
/// different runners have declined it, it ends as unplaceable with their
1458
/// reasons. A decline is never a failed check.
e9b9619c Andy McCall ↶ 1459
/// `POST /api/v1/flow/runners/heartbeat` (oxygenhub#243): a runner whose
1460
/// job slots are all full claims nothing, so it says it is there.
1461
pub async fn heartbeat(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
1462
    let me = runner_auth(&app, &headers).await?;
1463
    db::runners::seen(&app.db, me.id, None, None).await?;
1464
    Ok(StatusCode::NO_CONTENT)
1465
}
1466
4d2e44d7 Andy McCall ↶ 1467
pub async fn decline(
1468
    State(app): State<Shared>,
1469
    headers: HeaderMap,
1470
    Path(id): Path<i64>,
1471
    Json(d): Json<flow_core::api::Decline>,
1472
) -> Result<StatusCode, ApiError> {
1473
    let me = runner_auth(&app, &headers).await?;
1474
    db::runners::seen(&app.db, me.id, None, None).await?;
1475
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1476
    let reason = match reason.trim() {
1477
        "" => "no reason given".to_string(),
1478
        r => r.to_string(),
1479
    };
1480
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
1481
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1482
    };
1483
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
1484
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
1485
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
1486
    if runners.len() >= UNPLACEABLE_AFTER {
1487
        let error = format!(
1488
            "unplaceable: declined by {} runners: {}",
1489
            runners.len(),
1490
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
1491
        );
1492
        let changed =
1493
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1494
                .await?;
1495
        report_changed(&app, job.run_id, changed).await?;
1496
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1497
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1498
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
1499
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
1500
            fdb::set_waiting(&app.db, id, &summary).await?;
1501
        }
1502
    }
1503
    Ok(StatusCode::NO_CONTENT)
501d8b26 Andy McCall ↶ 1504
}
1505
1506
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1507
/// now (spec §11.4), from what each runner offered at its last claim:
1508
/// empty when one could (it waits its turn or a free slot), else what
1509
/// each one lacks. `declines` are the job's.
1510
/// What placement knows of the fleet: images' architectures, the images
1511
/// the hub holds, and the time.
1512
pub struct Fleet<'a> {
1513
    pub image_archs: &'a std::collections::HashMap<String, String>,
1514
    pub hub_images: &'a std::collections::HashSet<String>,
1515
    pub now: i64,
1516
}
1517
1518
/// Why runner `r` cannot take `job` of a repository of `owner` (id
1519
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
1520
/// when it can. `declines` are the job's.
1521
pub fn runner_lacks(
1522
    job: &fdb::Job,
1523
    owner: &str,
1524
    repo_id: i64,
1525
    r: &db::runners::Runner,
1526
    fleet: &Fleet,
1527
    declines: &[fdb::DeclineRow],
1528
) -> Option<String> {
1529
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
1530
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
1531
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
1532
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
1533
    if r.state == "draining" {
1534
        return Some("is draining".into());
1535
    }
1536
    let scope = r.scope();
1537
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
1538
        return Some("serves other repositories".into());
1539
    }
1540
    let tiers = r.tier_list();
1541
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
1542
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
1543
    }
1544
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
1545
        return Some(format!("is {}; the job needs {w}", r.arch));
1546
    }
1547
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
1548
    if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
1549
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
1550
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
1551
    }
1552
    let labels = r.label_list();
1553
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
1554
        return Some(format!("lacks label {l}"));
1555
    }
1556
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
1557
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
1558
    }
1559
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
1560
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
1561
    }
1562
    // The disk it learned it needs (oxygenflow#41).
1563
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
1564
        if free < n {
1565
            return Some(format!(
1566
                "has {} free; the job needs ~{} (its recent runs took up to {})",
1567
                mib(free),
1568
                mib(n),
1569
                mib((n - 64) * 4 / 5)
1570
            ));
1571
        }
1572
    }
1573
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
1574
        return Some(format!("declined it: {}", d.reason));
1575
    }
1576
    None
4d2e44d7 Andy McCall ↶ 1577
}
1578
1579
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1580
/// now (spec §11.4), from what each runner offered at its last claim:
1581
/// empty when one could (it waits its turn or a free slot), else what
1582
/// each one lacks. `declines` are the job's.
92a797b2 Andy McCall ↶ 1583
#[allow(clippy::too_many_arguments)]
4d2e44d7 Andy McCall ↶ 1584
pub fn why_waiting(
1585
    job: &fdb::Job,
1586
    owner: &str,
1587
    repo_id: i64,
1588
    runners: &[db::runners::Runner],
1589
    image_archs: &std::collections::HashMap<String, String>,
1590
    declines: &[fdb::DeclineRow],
1591
    now: i64,
92a797b2 Andy McCall ↶ 1592
    hub_images: &std::collections::HashSet<String>,
4d2e44d7 Andy McCall ↶ 1593
) -> String {
501d8b26 Andy McCall ↶ 1594
    let fleet = Fleet { image_archs, hub_images, now };
4d2e44d7 Andy McCall ↶ 1595
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
1596
    if online.is_empty() {
1597
        return "no runner is online".into();
1598
    }
1599
    let mut why = Vec::new();
1600
    for r in &online {
501d8b26 Andy McCall ↶ 1601
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
4d2e44d7 Andy McCall ↶ 1602
            None => return String::new(),
1603
            Some(w) => why.push(format!("{} {w}", r.name)),
1604
        }
1605
    }
1606
    let more = why.len().saturating_sub(4);
1607
    why.truncate(4);
1608
    let mut text = format!("no runner can take it: {}", why.join("; "));
1609
    if more > 0 {
1610
        text.push_str(&format!("; and {more} more"));
1611
    }
1612
    text
501d8b26 Andy McCall ↶ 1613
}
1614
58cbf424 Andy McCall ↶ 1615
/// Why a job that some runner could take still waits (oxygenhub#259): the
1616
/// runners that could take it are all busy, or have a free slot without
1617
/// the room for it now, and how many ready jobs come first (a higher
1618
/// priority, or the same one and older).
1619
#[allow(clippy::too_many_arguments)]
1620
pub fn why_still_waiting(
1621
    job: &fdb::Job,
1622
    owner: &str,
1623
    repo_id: i64,
1624
    runners: &[db::runners::Runner],
1625
    fleet: (&std::collections::HashMap<String, String>, &std::collections::HashSet<String>, i64),
1626
    declines: &[fdb::DeclineRow],
1627
    running: &std::collections::HashMap<i64, i64>,
1628
    ahead: usize,
794ff94f Andy McCall ↶ 1629
    behind_urgent: &[String],
58cbf424 Andy McCall ↶ 1630
) -> String {
1631
    let fleet = Fleet { image_archs: fleet.0, hub_images: fleet.1, now: fleet.2 };
1632
    let mut busy = Vec::new();
1633
    for r in runners.iter().filter(|r| r.state != "disabled" && r.online()) {
1634
        if runner_lacks(job, owner, repo_id, r, &fleet, declines).is_some() {
1635
            continue;
1636
        }
1637
        let n = running.get(&r.id).copied().unwrap_or(0);
1638
        let slots = r.max_jobs.max(1);
1639
        if n >= slots {
1640
            busy.push(format!("{} busy ({n} of {slots})", r.name));
1641
        } else {
1642
            busy.push(format!("{} has a free slot ({n} of {slots}) but not the memory or disk for it now", r.name));
1643
        }
1644
    }
1645
    let mut parts = Vec::new();
1646
    if !busy.is_empty() {
1647
        parts.push(format!("the runners that can take it are busy: {}", busy.join("; ")));
1648
    }
1649
    if ahead > 0 {
794ff94f Andy McCall ↶ 1650
        let after = match behind_urgent {
1651
            [] => String::new(),
1652
            [one] => format!(" (after expedited run {one})"),
1653
            many => format!(" (after expedited runs {})", many.join(", ")),
1654
        };
1655
        parts.push(format!("{ahead} ready {} first{after}", if ahead == 1 { "job comes" } else { "jobs come" }));
58cbf424 Andy McCall ↶ 1656
    }
1657
    parts.join("; ")
1658
}
1659
501d8b26 Andy McCall ↶ 1660
/// How long a job is held for a better runner before any runner that fits
1661
/// takes it (oxygenflow#42).
1662
const BETTER_HOLD_MS: i64 = 15_000;
1663
/// A runner counts as there to take a job when it asked this recently.
1664
const POLLING_MS: i64 = 10_000;
1665
1666
/// When each job was first passed over for a better runner.
c95446a1 Andy McCall ↶ 1667
/// Keyed by hub (tests run several in one process) and job.
1668
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
4fa0da12 Andy McCall ↶ 1669
1670
/// The build cache slots each runner said at its last claim it holds
1671
/// (oxygenflow#51). Keyed by hub (tests run several in one process) and
1672
/// runner; a restart forgets them until each runner's next claim.
c2be148c Andy McCall ↶ 1673
/// Each slot maps to when it last kept a disk (Unix ms, 0 unknown).
1674
type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashMap<String, i64>>;
4fa0da12 Andy McCall ↶ 1675
static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None);
1676
c2be148c Andy McCall ↶ 1677
/// When runner `runner` last kept a disk in build cache slot `slot` (0 when
1678
/// it did not say); `None` when it holds none there.
1679
fn cache_kept(hub: &str, runner: i64, slot: &str) -> Option<i64> {
4fa0da12 Andy McCall ↶ 1680
    CACHES
1681
        .lock()
1682
        .unwrap_or_else(|p| p.into_inner())
1683
        .as_ref()
1684
        .and_then(|c| c.get(&(hub.to_string(), runner)))
c2be148c Andy McCall ↶ 1685
        .and_then(|held| held.get(slot).copied())
1686
}
1687
1688
/// A copy of a build cache kept at least this much later is the fresher
1689
/// (oxygenflow#54): closer than that, the two are as good as each other.
1690
const FRESHER_MS: i64 = 60_000;
1691
1692
/// How well a runner fits a job (oxygenflow#42, #51, #54).
1693
struct Fit {
1694
    /// When it last kept the job's build cache, if it holds it.
1695
    cache: Option<i64>,
1696
    image: bool,
1697
    speed: i64,
1698
}
1699
1700
impl Fit {
1701
    /// Whether this fit is better than `other`: holding the job's build
1702
    /// cache counts most (a warm build beats any image fetch), and of two
1703
    /// that hold it the fresher copy; then holding the image, then speed.
1704
    fn beats(&self, other: &Fit) -> bool {
1705
        match (self.cache, other.cache) {
1706
            (Some(_), None) => return true,
1707
            (None, Some(_)) => return false,
1708
            (Some(a), Some(b)) if a > 0 && b > 0 && (a - b).abs() >= FRESHER_MS => return a > b,
1709
            _ => {}
1710
        }
1711
        (self.image, self.speed) > (other.image, other.speed)
1712
    }
1713
}
1714
1715
/// How runner `r` fits a job on image `rootfs` whose build cache slot is
1716
/// `slot`.
1717
fn fit(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> Fit {
1718
    Fit {
1719
        cache: cache_kept(hub, r.id, slot),
1720
        image: r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)),
1721
        speed: r.speed_class.unwrap_or(0),
1722
    }
4fa0da12 Andy McCall ↶ 1723
}
501d8b26 Andy McCall ↶ 1724
c2be148c Andy McCall ↶ 1725
/// A span as people say it: 40 s, 12 min, 3 h, 2 days.
1726
fn ago(ms: i64) -> String {
1727
    let s = ms.max(0) / 1000;
1728
    match s {
1729
        0..=59 => format!("{s} s"),
1730
        60..=3599 => format!("{} min", s / 60),
1731
        3600..=172_799 => format!("{} h", s / 3600),
1732
        _ => format!("{} days", s / 86_400),
1733
    }
501d8b26 Andy McCall ↶ 1734
}
1735
1736
fn rootfs_of(job: &fdb::Job) -> String {
1737
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
44a3d2eb Andy McCall ↶ 1738
}
1739
1740
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
1741
fn mib(m: i64) -> String {
1742
    if m >= 1024 {
1743
        format!("{:.1} GiB", m as f64 / 1024.0)
1744
    } else {
1745
        format!("{m} MiB")
1746
    }
4d2e44d7 Andy McCall ↶ 1747
}
1748
1749
/// One placement pass: say why each ready queued job waits.
1750
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
1751
    let ready = fdb::ready_queued(&app.db).await?;
1752
    if ready.is_empty() {
1753
        return Ok(());
1754
    }
1755
    let runners = db::runners::list(&app.db).await?;
1756
    let archs = fdb::image_archs(&app.db).await?;
92a797b2 Andy McCall ↶ 1757
    let held = crate::images::held(&app.db).await?;
4d2e44d7 Andy McCall ↶ 1758
    let now = db::now_ms();
58cbf424 Andy McCall ↶ 1759
    let ranks = fdb::queued_ranks(&app.db).await?;
1760
    let running = fdb::running_by_runner(&app.db).await?;
1761
    // Claim order, roughly: a better rank first, then the older job.
1762
    let order: Vec<(i64, i64)> = ready.iter().map(|(j, _, _)| (ranks.get(&j.id).copied().unwrap_or(2), j.id)).collect();
794ff94f Andy McCall ↶ 1763
    let run_of: std::collections::HashMap<i64, i64> = ready.iter().map(|(j, _, _)| (j.id, j.run_id)).collect();
1764
    // Expedited runs (rank 0) by name, for the jobs they moved down (#291).
1765
    let mut urgent: std::collections::BTreeMap<i64, String> = std::collections::BTreeMap::new();
1766
    for (j, _, _) in ready.iter().filter(|(j, _, _)| ranks.get(&j.id) == Some(&0)) {
1767
        if urgent.contains_key(&j.run_id) {
1768
            continue;
1769
        }
1770
        if let Some(run) = fdb::run_by_id(&app.db, j.run_id).await? {
1771
            if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1772
                urgent.insert(j.run_id, format!("{}/{} #{}", row.owner, row.name, run.number));
1773
            }
1774
        }
1775
    }
1776
    // Runs whose waiting notes changed: their pages hear of it now.
1777
    let mut changed = std::collections::BTreeSet::new();
4d2e44d7 Andy McCall ↶ 1778
    for (job, owner, repo_id) in ready {
1779
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
58cbf424 Andy McCall ↶ 1780
        let mut why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1781
        if why.is_empty() {
1782
            let mine = (ranks.get(&job.id).copied().unwrap_or(2), job.id);
1783
            let ahead = order.iter().filter(|o| **o < mine).count();
794ff94f Andy McCall ↶ 1784
            let mut behind: Vec<String> = Vec::new();
1785
            if mine.0 > 0 {
1786
                for o in order.iter().filter(|o| o.0 == 0 && **o < mine) {
1787
                    let Some(r) = run_of.get(&o.1).filter(|r| **r != job.run_id) else { continue };
1788
                    if let Some(label) = urgent.get(r).filter(|l| !behind.contains(l)) {
1789
                        behind.push(label.clone());
1790
                    }
1791
                }
1792
            }
1793
            why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead, &behind);
58cbf424 Andy McCall ↶ 1794
        }
67040b15 Andy McCall ↶ 1795
        if why == job.waiting {
1796
            continue;
1797
        }
4d2e44d7 Andy McCall ↶ 1798
        fdb::set_waiting(&app.db, job.id, &why).await?;
794ff94f Andy McCall ↶ 1799
        changed.insert(job.run_id);
67040b15 Andy McCall ↶ 1800
        // The job's check says it too, so a proposal shows why it is stuck
58cbf424 Andy McCall ↶ 1801
        // (oxygenhub#28); only when the reason changes in kind, not in its
1802
        // counts (#259: a queue position moves every pass, and each check
1803
        // report reaches webhooks).
1804
        let kind = |s: &str| s.chars().filter(|c| !c.is_ascii_digit()).collect::<String>();
1805
        if kind(&why) == kind(&job.waiting) {
1806
            continue;
1807
        }
67040b15 Andy McCall ↶ 1808
        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?) {
1809
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
1810
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
1811
        }
794ff94f Andy McCall ↶ 1812
    }
1813
    for run_id in changed {
1814
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
4d2e44d7 Andy McCall ↶ 1815
    }
1816
    Ok(())
1817
}
1818
1819
/// Run a placement pass every 15 seconds.
1820
pub async fn placement(app: Shared) {
1821
    loop {
1822
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1823
        if let Err(e) = explain_waits(&app).await {
1824
            tracing::warn!("flow: placement pass: {e}");
1825
        }
1826
    }
1827
}
1828
25091e5c Andy McCall ↶ 1829
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
f677d627 Andy McCall ↶ 1830
pub(crate) async fn pins_in(
25091e5c Andy McCall ↶ 1831
    app: &Shared,
1832
    name: crate::repos::RepoName,
1833
    heads: Vec<oxy_core::hash::ChangeHash>,
5d54cf74 Andy McCall ↶ 1834
) -> Result<Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)>, String> {
25091e5c Andy McCall ↶ 1835
    app.repos
fd3db644 Andy McCall ↶ 1836
        .read(&name, move |repo, cache| {
1837
            let loaded = cache.load(repo, &heads)?;
1838
            let mut out = Vec::new();
1839
            for (path, f) in &loaded.snapshot.files {
1840
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
1841
                    continue;
1842
                }
1843
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
1844
                    if let Some(b) = blobs.first() {
1845
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
1846
                            .read_blob(b)
1847
                            .map_err(crate::repos::RepoError::Oxy)
1848
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
1849
                        {
1850
                            out.push((path.clone(), id, heads));
1851
                        }
1852
                    }
1853
                }
1854
            }
1855
            Ok(out)
1856
        })
1857
        .await
5d54cf74 Andy McCall ↶ 1858
        .map_err(|e| e.to_string())
25091e5c Andy McCall ↶ 1859
}
1860
1861
/// How deep subrepos of subrepos are followed.
1862
const MAX_SUBREPO_DEPTH: usize = 3;
1863
1864
/// The Oxygen subrepos the run's state pins, and those their states pin in
1865
/// turn (parents first, paths joined), that live on this hub and that the
1866
/// run's pusher may read: what the job's workspace needs beside its own
afd0be38 Andy McCall ↶ 1867
/// tree. A pin the hub cannot serve is left out, with what it pins, and
1868
/// the third list says why, for the job's log.
1869
async fn subrepo_inputs(
1870
    app: &Shared,
1871
    row: &db::repos::RepoRow,
1872
    run: &fdb::Run,
1873
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
1874
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
25091e5c Andy McCall ↶ 1875
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
fd3db644 Andy McCall ↶ 1876
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
25091e5c Andy McCall ↶ 1877
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
fd3db644 Andy McCall ↶ 1878
    let mut inputs = Vec::new();
1879
    let mut ids = Vec::new();
afd0be38 Andy McCall ↶ 1880
    let mut left_out = Vec::new();
1881
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
25091e5c Andy McCall ↶ 1882
    // (path prefix, repository, heads, depth), parents before children.
1883
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
1884
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
1885
        if depth == MAX_SUBREPO_DEPTH {
1886
            continue;
1887
        }
5d54cf74 Andy McCall ↶ 1888
        for (path, id, pinned) in pins_in(app, name, heads).await.unwrap_or_default() {
25091e5c Andy McCall ↶ 1889
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
afd0be38 Andy McCall ↶ 1890
            let mut hosted = false;
25091e5c Andy McCall ↶ 1891
            for other in &rows {
1892
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
1893
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
1894
                if !matches {
1895
                    continue;
1896
                }
afd0be38 Andy McCall ↶ 1897
                hosted = true;
25091e5c Andy McCall ↶ 1898
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
180dc43a Andy McCall ↶ 1899
                // A Diffusion bump's run reads the upstream it bumps: the one
1900
                // the consumer pins (someone who may write it committed the
1901
                // pin), and no other (#150).
1902
                let diffused = run.actor == crate::diffusion::ACCOUNT
1903
                    && run.bookmark.starts_with("diffusion/")
5d90e0e0 Andy McCall 1904
                    && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1 AND upstream_repo_id = $2")
180dc43a Andy McCall ↶ 1905
                        .bind(run.repo_id)
1906
                        .bind(other.id)
1907
                        .fetch_one(&app.db)
1908
                        .await
1909
                        .unwrap_or(0)
1910
                        > 0;
1911
                if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) {
25091e5c Andy McCall ↶ 1912
                    inputs.push(flow_core::api::SubrepoInput {
1913
                        path: path.clone(),
1914
                        repo: format!("{}/{}", other.owner, other.name),
1915
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
1916
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
1917
                    });
1918
                    if !ids.contains(&other.id) {
1919
                        ids.push(other.id);
1920
                    }
1921
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
afd0be38 Andy McCall ↶ 1922
                } else {
1923
                    left_out.push(format!(
1924
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
1925
                        other.owner, other.name
1926
                    ));
25091e5c Andy McCall ↶ 1927
                }
1928
                break;
afd0be38 Andy McCall ↶ 1929
            }
1930
            if !hosted {
1931
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
fd3db644 Andy McCall ↶ 1932
            }
1933
        }
1934
    }
afd0be38 Andy McCall ↶ 1935
    (inputs, ids, left_out)
42fa9554 Andy McCall ↶ 1936
}
1937
1938
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
1939
    match fdb::job(&app.db, id).await? {
1940
        Some(j) if j.status == "running" => Ok(j),
1941
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
1942
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
1943
    }
1944
}
1945
1946
/// `POST /api/v1/flow/jobs/{id}/log`.
1947
pub async fn log(
1948
    State(app): State<Shared>,
1949
    headers: HeaderMap,
1950
    Path(id): Path<i64>,
1951
    Json(mut batch): Json<LogBatch>,
1952
) -> Result<StatusCode, ApiError> {
d94449e4 Andy McCall ↶ 1953
    let me = runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 1954
    let job = running_job(&app, id).await?;
d94449e4 Andy McCall ↶ 1955
    // A runner busy with its only job claims nothing, so its log and
1956
    // finish calls are what keep it online (oxygenhub#78).
1957
    db::runners::seen(&app.db, me.id, None, None).await?;
745d225d Andy McCall ↶ 1958
    // An empty batch asks only whether the job still runs: a cancelled
1959
    // one answers 409 and its runner stops it (oxygenflow#32).
1960
    if batch.records.is_empty() {
1961
        return Ok(StatusCode::NO_CONTENT);
1962
    }
4ac01a73 Andy McCall ↶ 1963
    // A secret's value never reaches the stored log (#107).
1964
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
1965
        Ok(spec) if !spec.secrets.is_empty() => {
1966
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
1967
            let row = match run {
1968
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
1969
                None => None,
1970
            };
1971
            match row {
1972
                Some(row) => {
1973
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
1974
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
1975
                }
1976
                None => vec![],
1977
            }
1978
        }
1979
        _ => vec![],
1980
    };
d1727c57 Andy McCall ↶ 1981
    let kid = app.oidc.get().map(|i| i.kid.clone());
42fa9554 Andy McCall ↶ 1982
    for r in &mut batch.records {
4ac01a73 Andy McCall ↶ 1983
        if !values.is_empty() {
1984
            r.text = crate::secrets::mask(&r.text, &values);
d1727c57 Andy McCall ↶ 1985
        }
1986
        if let Some(kid) = &kid {
1987
            r.text = crate::oidc::mask(&r.text, kid);
4ac01a73 Andy McCall ↶ 1988
        }
42fa9554 Andy McCall ↶ 1989
        if r.text.len() > MAX_RECORD_TEXT {
1990
            let mut cut = MAX_RECORD_TEXT;
1991
            while !r.text.is_char_boundary(cut) {
1992
                cut -= 1;
1993
            }
1994
            r.text.truncate(cut);
1995
        }
1996
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
1997
            r.stream = "console".into();
1998
        }
1999
    }
2000
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
2001
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
2002
        tracing::info!("flow: job {} reached the log limit", job.id);
2003
    }
2004
    Ok(StatusCode::NO_CONTENT)
2005
}
2006
a3c8288b Andy McCall ↶ 2007
/// Largest artifact the hub keeps; the runner refuses larger ones first.
2008
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
2009
2010
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
2011
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
2012
/// repository. Answers `{"blob"}`.
2013
pub async fn artifact(
2014
    State(app): State<Shared>,
2015
    headers: HeaderMap,
2016
    Path((id, name)): Path<(i64, String)>,
2017
    body: axum::body::Bytes,
2018
) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 2019
    runner_auth(&app, &headers).await?;
a3c8288b Andy McCall ↶ 2020
    let job = running_job(&app, id).await?;
2021
    let spec: flow_core::Job =
2022
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
2023
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
2024
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
2025
    };
2026
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
2027
    let row =
2028
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
2029
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
a36bab6b Andy McCall ↶ 2030
    // A report's artifact is its manifest (#127): checked, and its files
2031
    // found in the store, before it is kept.
2032
    let report = match out.kind {
2033
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
2034
        flow_core::OutputKind::Artifact => None,
2035
    };
a3c8288b Andy McCall ↶ 2036
    let content = blake3::hash(&body).to_hex().to_string();
2037
    let size = body.len() as i64;
a36bab6b Andy McCall ↶ 2038
    let artifact = report.is_none();
2039
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
2040
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
a3c8288b Andy McCall ↶ 2041
    let blob = app
2042
        .repos
2043
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
2044
        .await
2045
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
2046
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
2047
    fdb::add_artifact(&app.db, &a).await?;
a36bab6b Andy McCall ↶ 2048
    if let Some((manifest, declaration)) = report {
2049
        let origin = crate::reports::Origin {
2050
            repo_id: row.id,
2051
            run_id: run.id,
2052
            run_number: run.number,
2053
            job_id: job.id,
2054
            job: &job.name,
2055
            bookmark: &run.bookmark,
2056
            state: &run.state,
2057
        };
2058
        let said = match declaration {
2059
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
c8df33a6 Andy McCall ↶ 2060
                Ok(id) => {
2061
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
a36bab6b Andy McCall ↶ 2062
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
2063
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
2064
                }
2065
                Err(e) => format!("report {}: not indexed: {e}", a.name),
2066
            },
2067
            Err(why) => {
2068
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
2069
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
2070
                format!("report {}: report.toml: {why}", a.name)
2071
            }
2072
        };
2073
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2074
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2075
    }
554ccacf Andy McCall ↶ 2076
    // A coverage report (#113) is read now; what it says, or why it could
2077
    // not be read, goes in the job's log. It never fails the upload.
2078
    if let Some(bytes) = report_bytes {
2079
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
2080
            Ok(text) | Err(text) => text,
2081
        };
2082
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2083
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2084
    }
0b203bbf Andy McCall ↶ 2085
    // A test report (#114) likewise.
2086
    if let Some(bytes) = junit_bytes {
2087
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
2088
            Ok(text) | Err(text) => text,
2089
        };
2090
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2091
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2092
    }
a3c8288b Andy McCall ↶ 2093
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
a36bab6b Andy McCall ↶ 2094
}
2095
c8df33a6 Andy McCall ↶ 2096
/// A check per metric with a threshold (#127, docs/reports.md §5), named
2097
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
2098
/// `fail_above` or `fail_below`, which a proposal may require.
2099
async fn report_threshold_checks(
2100
    app: &Shared,
2101
    row: &db::repos::RepoRow,
2102
    run: &fdb::Run,
2103
    name: &str,
2104
    id: i64,
2105
    metrics: &[crate::reports::Metric],
2106
) {
2107
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
2108
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
2109
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
2110
        let above = m.fail_above.filter(|a| m.value > *a);
2111
        let below = m.fail_below.filter(|b| m.value < *b);
2112
        let (status, summary) = match (above, below) {
2113
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
2114
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
2115
            _ => {
2116
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
2117
                    .into_iter()
2118
                    .flatten()
2119
                    .collect();
2120
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
2121
            }
2122
        };
2123
        let check = format!("report/{name}/{}", m.key);
2124
        if let Ok(c) = db::hooks::upsert_check(
2125
            &app.db,
2126
            row.id,
2127
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
2128
        )
2129
        .await
2130
        {
2131
            let api = crate::api::check_of(&c);
33d98147 Andy McCall 2132
            app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
c8df33a6 Andy McCall ↶ 2133
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2134
        }
2135
    }
2136
}
2137
a36bab6b Andy McCall ↶ 2138
/// Check report manifest `body` (#127) and that the repository holds every
2139
/// file it names; answers it with the report's declaration, or why that
2140
/// could not be read.
2141
async fn report_manifest(
2142
    app: &Shared,
2143
    repo: &RepoName,
2144
    name: &str,
2145
    body: &[u8],
2146
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
2147
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
2148
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
2149
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
2150
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
2151
    let found = app
2152
        .repos
2153
        .read(repo, move |r, _| {
2154
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
2155
                return Ok(Err(path.clone()));
2156
            }
2157
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
2158
        })
2159
        .await
2160
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
2161
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
2162
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
2163
    Ok((manifest, declaration))
2164
}
2165
2166
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
2167
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
2168
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
2169
/// the files follows as the report's artifact.
2170
pub async fn report_file(
2171
    State(app): State<Shared>,
2172
    headers: HeaderMap,
2173
    Path((id, name)): Path<(i64, String)>,
2174
    body: axum::body::Bytes,
2175
) -> Result<Response, ApiError> {
2176
    runner_auth(&app, &headers).await?;
2177
    let job = running_job(&app, id).await?;
2178
    let spec: flow_core::Job =
2179
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
2180
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
2181
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
2182
    }
2183
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
2184
    if !crate::reports::path_ok(path) {
2185
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
2186
    }
2187
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
2188
    let row =
2189
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
2190
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
2191
    let blob = app
2192
        .repos
2193
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
2194
        .await
2195
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
2196
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
0b203bbf Andy McCall ↶ 2197
}
2198
2199
/// Parse and keep a job's JUnit report: what it says, or why not.
2200
async fn read_junit(
2201
    app: &Shared,
2202
    row: &db::repos::RepoRow,
2203
    run: &fdb::Run,
2204
    job: &fdb::Job,
2205
    blob: &str,
2206
    bytes: axum::body::Bytes,
2207
) -> Result<String, String> {
2208
    if bytes.len() > crate::junit::MAX_REPORT {
2209
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
2210
    }
2211
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
2212
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
2213
        .await
2214
        .map_err(|e| format!("tests: {e}"))?
2215
        .map_err(|e| format!("tests: {e}"))?;
2216
    let origin = crate::junit::Origin {
2217
        repo_id: row.id,
2218
        run_id: run.id,
2219
        run_number: run.number,
2220
        job_id: job.id,
2221
        job: &job.name,
2222
        bookmark: &run.bookmark,
2223
        state: &run.state,
2224
        blob,
2225
    };
2226
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
2227
    let r = crate::junit::for_run(&app.db, run.id)
2228
        .await
2229
        .map_err(|e| format!("tests: {e}"))?
2230
        .into_iter()
2231
        .find(|r| r.job_id == job.id)
2232
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
3d5c825a Andy McCall ↶ 2233
    // The `tests` check on the run's state: what fails, and which of those
2234
    // passed in the same job's latest report on the default bookmark, so a
2235
    // proposal names what it broke.
2236
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
2237
    let newly = match &base {
2238
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
2239
        _ => vec![],
2240
    };
2241
    let (status, summary) = if r.broken() == 0 {
2242
        ("passed", format!("{} passed", r.passed))
2243
    } else if let Some(b) = &base {
2244
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
2245
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
2246
        let new = if newly.is_empty() {
2247
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
2248
        } else {
2249
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
2250
        };
2251
        ("failed", format!("{} failing; {new}", r.broken()))
2252
    } else {
2253
        ("failed", format!("{} failing", r.broken()))
2254
    };
2255
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
2256
    if let Ok(c) = db::hooks::upsert_check(
2257
        &app.db,
2258
        row.id,
2259
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
2260
    )
2261
    .await
2262
    {
2263
        let api = crate::api::check_of(&c);
33d98147 Andy McCall 2264
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
3d5c825a Andy McCall ↶ 2265
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2266
    }
0b203bbf Andy McCall ↶ 2267
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
554ccacf Andy McCall ↶ 2268
}
2269
2270
/// Parse and keep a job's coverage report: what it says, or why not.
2271
async fn read_coverage(
2272
    app: &Shared,
2273
    repo: &RepoName,
2274
    row: &db::repos::RepoRow,
2275
    run: &fdb::Run,
2276
    job: &fdb::Job,
2277
    blob: &str,
2278
    bytes: axum::body::Bytes,
2279
) -> Result<String, String> {
2280
    if bytes.len() > crate::coverage::MAX_REPORT {
2281
        return Err(format!(
2282
            "coverage: the report is {} MiB; at most {} MiB is read",
2283
            bytes.len() >> 20,
2284
            crate::coverage::MAX_REPORT >> 20
2285
        ));
2286
    }
7fff30c4 Andy McCall ↶ 2287
    let text = String::from_utf8(bytes.to_vec())
2288
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
2289
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
554ccacf Andy McCall ↶ 2290
        .await
2291
        .map_err(|e| format!("coverage: {e}"))?
2292
        .map_err(|e| format!("coverage: {e}"))?;
2293
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
2294
    let in_state: std::collections::HashSet<String> = app
2295
        .repos
2296
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
2297
        .await
2298
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
2299
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
2300
    let origin = crate::coverage::Origin {
2301
        repo_id: row.id,
40dd3f86 Andy McCall ↶ 2302
        run_id: Some(run.id),
554ccacf Andy McCall ↶ 2303
        run_number: run.number,
40dd3f86 Andy McCall ↶ 2304
        job_id: Some(job.id),
2305
        uploader: "",
554ccacf Andy McCall ↶ 2306
        job: &job.name,
2307
        bookmark: &run.bookmark,
2308
        state: &run.state,
2309
        heads: &heads,
2310
        blob,
7fff30c4 Andy McCall ↶ 2311
        format,
554ccacf Andy McCall ↶ 2312
    };
2313
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
2314
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
2315
        return Err("coverage: the report was not kept".into());
2316
    };
7edaff01 Andy McCall ↶ 2317
    // The `coverage` check on the run's state (#113): against the default
2318
    // bookmark, under the repository's rules.
2319
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
2320
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
2321
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
2322
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
2323
    if let Ok(c) = db::hooks::upsert_check(
2324
        &app.db,
2325
        row.id,
2326
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
2327
    )
2328
    .await
2329
    {
2330
        let api = crate::api::check_of(&c);
33d98147 Andy McCall 2331
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
7edaff01 Andy McCall ↶ 2332
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2333
    }
554ccacf Andy McCall ↶ 2334
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
2335
    let dropped =
2336
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
2337
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
a3c8288b Andy McCall ↶ 2338
}
2339
42fa9554 Andy McCall ↶ 2340
/// `POST /api/v1/flow/jobs/{id}/finish`.
2341
pub async fn finish(
2342
    State(app): State<Shared>,
2343
    headers: HeaderMap,
2344
    Path(id): Path<i64>,
2345
    Json(f): Json<Finish>,
2346
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 2347
    let me = runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 2348
    let job = running_job(&app, id).await?;
d94449e4 Andy McCall ↶ 2349
    db::runners::seen(&app.db, me.id, None, None).await?;
42fa9554 Andy McCall ↶ 2350
    let status = match (f.exit_code, &f.error) {
2351
        (Some(0), None) => "passed",
2352
        (Some(_), _) => "failed",
2353
        (None, _) => "error",
2354
    };
2355
    let (receipt, receipt_hash) = match &f.receipt {
2356
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
d29070fa Andy McCall ↶ 2357
            // A passing job's receipt names exactly the artifacts uploaded,
2358
            // in name order; a failing one's names none, though it may have
2359
            // kept `when "always"` artifacts (oxygenflow#37).
2360
            let kept: Vec<String> =
2361
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
a3c8288b Andy McCall ↶ 2362
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
2363
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
2364
            }
42fa9554 Andy McCall ↶ 2365
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
2366
        }
2367
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
2368
        None => (String::new(), String::new()),
2369
    };
2370
    let error = f.error.clone().unwrap_or_default();
44a3d2eb Andy McCall ↶ 2371
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
42fa9554 Andy McCall ↶ 2372
    let changed = fdb::finish(
2373
        &app.db,
2374
        job.id,
2375
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
2376
    )
2377
    .await?;
2378
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
2379
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
cc19697f Andy McCall ↶ 2380
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
b6b7e4cf Andy McCall ↶ 2381
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
cc19697f Andy McCall ↶ 2382
                    Ok((attest, signer)) => (attest, signer, String::new()),
2383
                    Err(e) => {
2384
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
2385
                        (String::new(), String::new(), e)
2386
                    }
2387
                };
2388
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
2389
            }
42fa9554 Andy McCall ↶ 2390
        }
2391
    }
070a7b64 Andy McCall ↶ 2392
    report_changed(&app, job.run_id, changed).await?;
42fa9554 Andy McCall ↶ 2393
    Ok(StatusCode::NO_CONTENT)
070a7b64 Andy McCall ↶ 2394
}
2395
2396
/// Report the checks of jobs whose status changed.
2397
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
2398
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
2399
        return Ok(());
2400
    };
2401
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
2402
        return Ok(());
2403
    };
4a4400c5 Andy McCall ↶ 2404
    let mut errored_on: Vec<i64> = Vec::new();
dc59d1b5 Andy McCall ↶ 2405
    let some_passed = changed.iter().any(|j| j.status == "passed");
7e01a766 Andy McCall ↶ 2406
    if !changed.is_empty() {
33d98147 Andy McCall 2407
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
7e01a766 Andy McCall ↶ 2408
    }
070a7b64 Andy McCall ↶ 2409
    for j in changed {
2410
        let summary = match j.status.as_str() {
5588bb36 Andy McCall ↶ 2411
            "passed" => match j.reused_from() {
2412
                Some(n) => format!("passed in run #{n} on this state, reused"),
2413
                None => format!("passed in {}", duration(&j)),
2414
            },
070a7b64 Andy McCall ↶ 2415
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
2416
            "skipped" => "skipped: a dependency did not pass".into(),
745d225d Andy McCall ↶ 2417
            "cancelled" => j.error.clone(),
070a7b64 Andy McCall ↶ 2418
            _ => format!("could not run: {}", j.error),
2419
        };
2420
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
4a4400c5 Andy McCall ↶ 2421
        if j.status == "error" {
2422
            if let Some(r) = runner_of(app, j.id).await {
2423
                errored_on.push(r);
2424
            }
2425
        }
2426
    }
2427
    errored_on.dedup();
2428
    for r in errored_on {
2429
        drain_if_erroring(app, r).await;
dc59d1b5 Andy McCall ↶ 2430
    }
2431
    if some_passed {
2432
        tell_approvers(app, &row, &run).await;
4a4400c5 Andy McCall ↶ 2433
    }
2434
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
2435
        if done.status == "failed" && !done.live() {
2436
            tell_failed(app, &row, &done).await;
2437
        }
bff9106e Andy McCall ↶ 2438
        // A pass after a failure on the same bookmark: fixed (#183).
2439
        if done.status == "passed" {
2440
            let before: Option<String> = sqlx::query_scalar(
5d90e0e0 Andy McCall 2441
                "SELECT status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND id < $3 AND status IN ('passed', 'failed')
bff9106e Andy McCall ↶ 2442
                 ORDER BY id DESC LIMIT 1",
2443
            )
2444
            .bind(done.repo_id)
2445
            .bind(&done.bookmark)
2446
            .bind(done.id)
2447
            .fetch_optional(&app.db)
2448
            .await?;
2449
            // Or this very run failed and a job of it was run again.
2450
            let url = format!("/{}/{}/flow/{}", row.owner, row.name, done.number);
5d90e0e0 Andy McCall 2451
            let failed_here: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE kind = 'flow.failed' AND url = $1")
bff9106e Andy McCall ↶ 2452
                .bind(&url)
2453
                .fetch_one(&app.db)
2454
                .await?;
2455
            if before.as_deref() == Some("failed") || failed_here > 0 {
2456
                tell_fixed(app, &row, &done).await;
2457
            }
2458
        }
070a7b64 Andy McCall ↶ 2459
    }
a7e6e8e6 Andy McCall ↶ 2460
    // A passing run of an upstream's default bookmark: consumers following
2461
    // it when green are bumped (Diffusion, #148).
2462
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
2463
        if done.status == "passed" && done.bookmark == row.default_bookmark {
2464
            let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
2465
            let (app, row) = (app.clone(), row.clone());
2466
            tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await });
2467
        }
f4d6ef41 Andy McCall ↶ 2468
        // A bump's run passed: a consumer that lands its bumps itself may
2469
        // land it now (docs/diffusion.md §4.2).
2470
        if let Some(path) = done.bookmark.strip_prefix("diffusion/").filter(|_| done.status == "passed") {
2471
            tokio::spawn(crate::diffusion::try_land(app.clone(), row.clone(), path.to_string()));
2472
        }
a7e6e8e6 Andy McCall ↶ 2473
    }
070a7b64 Andy McCall ↶ 2474
    Ok(())
4a4400c5 Andy McCall ↶ 2475
}
2476
2477
/// The account OxygenFlow's notices come from, made on first use with no
2478
/// way to sign in.
2479
pub const ACCOUNT: &str = "oxygenflow";
2480
2481
async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> {
2482
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
2483
        return Some(a);
2484
    }
2485
    db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok()
2486
}
2487
2488
async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> {
5d90e0e0 Andy McCall 2489
    sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = $1").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten()
4a4400c5 Andy McCall ↶ 2490
}
2491
2492
/// A failed run (oxygenflow#30): its pusher and the repository's watchers
2493
/// are told, in their inboxes and by email as they chose, naming the jobs
2494
/// that did not pass.
2495
async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
2496
    let Some(me) = flow_account(&app.db).await else { return };
2497
    let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default();
2498
    let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect();
2499
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
2500
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
2501
    crate::notify::deliver(
2502
        app,
2503
        crate::notify::Event {
2504
            kind: "flow.failed",
2505
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
bff9106e Andy McCall ↶ 2506
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
2507
            actor: &me,
2508
            repo_id: row.id,
2509
            thread: None,
2510
            mention_text: "",
2511
            direct: pusher.into_iter().map(|a| a.id).collect(),
2512
        },
2513
    )
2514
    .await;
2515
}
2516
2517
/// A run that passes after its bookmark's last one failed (oxygenhub#183):
2518
/// its pusher and the repository's watchers hear it is green again.
2519
async fn tell_fixed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
2520
    let Some(me) = flow_account(&app.db).await else { return };
2521
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
2522
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
2523
    crate::notify::deliver(
2524
        app,
2525
        crate::notify::Event {
2526
            kind: "flow.fixed",
2527
            title: format!("{}/{}: run #{}{on} passed: fixed", row.owner, row.name, run.number),
4a4400c5 Andy McCall ↶ 2528
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
2529
            actor: &me,
2530
            repo_id: row.id,
2531
            thread: None,
2532
            mention_text: "",
2533
            direct: pusher.into_iter().map(|a| a.id).collect(),
2534
        },
2535
    )
2536
    .await;
2537
}
2538
2539
/// A runner whose last jobs all ended in infrastructure errors is drained,
2540
/// so it stops taking jobs it fails before running them, and the
2541
/// administrators are told (oxygenflow#30).
2542
async fn drain_if_erroring(app: &Shared, runner_id: i64) {
2543
    let n = app.config.flow.drain_after_errors;
2544
    let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return };
2545
    let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return };
2546
    if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() {
2547
        return;
2548
    }
2549
    tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name);
2550
    let Some(me) = flow_account(&app.db).await else { return };
ea14c4ec Andy McCall ↶ 2551
    let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = TRUE").fetch_all(&app.db).await.unwrap_or_default();
4a4400c5 Andy McCall ↶ 2552
    crate::notify::deliver(
2553
        app,
2554
        crate::notify::Event {
2555
            kind: "flow.runner_drained",
2556
            title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name),
2557
            url: format!("/admin/runners/{runner_id}"),
2558
            actor: &me,
2559
            repo_id: 0,
2560
            thread: None,
2561
            mention_text: "",
2562
            direct: admins,
2563
        },
2564
    )
2565
    .await;
070a7b64 Andy McCall ↶ 2566
}
2567
5dead1f4 Andy McCall ↶ 2568
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
2569
/// Settings, else from `hub.toml`.
2570
pub struct Limits {
2571
    /// For a job whose pipeline names none.
2572
    pub default_s: u64,
2573
    /// The most a pipeline may ask for.
2574
    pub max_s: u64,
2575
    /// Grace past a job's limit before the hub gives it up: a runner stops
2576
    /// a job at its limit and reports it within moments, so one still
2577
    /// running this long after is one the runner no longer reports (it was
2578
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
2579
    pub grace_ms: i64,
2580
}
2581
2582
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
2583
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
2584
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
2585
2586
pub async fn limits(app: &Shared) -> Limits {
2587
    let minutes = |key: &'static str| async move {
2588
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
2589
    };
2590
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
2591
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
2592
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
2593
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
2594
}
070a7b64 Andy McCall ↶ 2595
5dead1f4 Andy McCall ↶ 2596
/// End every job still running past its time limit plus the grace:
070a7b64 Andy McCall ↶ 2597
/// it is marked an error with the reason, the jobs that depended on it are
2598
/// skipped and its run settled, as when a runner reports an error.
2599
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
5dead1f4 Andy McCall ↶ 2600
    let limits = limits(app).await;
2601
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
070a7b64 Andy McCall ↶ 2602
    let n = lost.len();
2603
    for job in lost {
2604
        let error = format!(
2605
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
2606
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
2607
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
2608
        );
2609
        let changed =
2610
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2611
                .await?;
2612
        tracing::warn!("flow: job {} {error}", job.id);
2613
        report_changed(app, job.run_id, changed).await?;
2614
    }
2615
    Ok(n)
eef677c4 Andy McCall ↶ 2616
}
2617
2618
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
2619
/// it restarted since claiming them, or never received the claim. A
2620
/// runner claims one job at a time, so a job it holds is always listed.
2621
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
2622
    for job in fdb::running_on(&app.db, runner.id).await? {
2623
        if holding.contains(&job.id) {
2624
            continue;
2625
        }
2626
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
2627
        let changed =
2628
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2629
                .await?;
2630
        tracing::warn!("flow: job {} {error}", job.id);
2631
        report_changed(app, job.run_id, changed).await?;
2632
    }
2633
    Ok(())
348f8c4b Andy McCall ↶ 2634
}
2635
2636
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
2637
/// repository that runs Flow, the jobs of its default bookmark's pipeline
2638
/// with a `schedule` line due then. Returns how many runs started.
2639
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
2640
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
2641
    let mut started = 0;
2642
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
2643
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
2644
        let b = row.default_bookmark.clone();
2645
        let found = app
2646
            .repos
2647
            .read(&repo, move |r, _| {
2648
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
2649
                Ok(Some((st, r.store().get_state(&st)?.heads)))
2650
            })
2651
            .await;
2652
        let Ok(Some((state, heads))) = found else { continue };
2653
        if heads.is_empty() {
2654
            continue;
2655
        }
0650d94e Andy McCall ↶ 2656
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
348f8c4b Andy McCall ↶ 2657
            Ok(Some(_)) => started += 1,
2658
            Ok(None) => {}
2659
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
2660
        }
2661
    }
2662
    started
2663
}
2664
2665
/// The hub's clock as a [`When`](flow_core::pipeline::When).
2666
pub fn when_now() -> flow_core::pipeline::When {
2667
    let now = time::OffsetDateTime::now_utc();
2668
    flow_core::pipeline::When {
2669
        minute: now.minute() as u32,
2670
        hour: now.hour() as u32,
2671
        day: now.day() as u32,
2672
        month: u8::from(now.month()) as u32,
2673
        weekday: now.weekday().number_days_from_sunday() as u32,
2674
    }
2675
}
2676
2677
/// Start the scheduled runs due each minute, once a minute.
2678
pub async fn scheduler(app: Shared) {
2679
    let mut last = None;
2680
    loop {
2681
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
2682
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
2683
        if last == Some(minute) {
2684
            continue;
2685
        }
2686
        last = Some(minute);
2687
        let n = run_due(&app, when_now()).await;
2688
        if n > 0 {
2689
            tracing::info!("flow: {n} scheduled run(s) started");
2690
        }
2691
    }
070a7b64 Andy McCall ↶ 2692
}
2693
2694
/// Look for lost jobs once a minute.
2695
pub async fn reaper(app: Shared) {
2696
    loop {
2697
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
ab4df6aa Andy McCall ↶ 2698
        if let Err(e) = requeue_lapsed(&app).await {
2699
            tracing::warn!("flow: looking for lapsed leases: {e}");
2700
        }
070a7b64 Andy McCall ↶ 2701
        if let Err(e) = reap_lost_jobs(&app).await {
2702
            tracing::warn!("flow: looking for lost jobs: {e}");
417184c7 Andy McCall ↶ 2703
        }
2704
        match expire_approvals(&app, db::now_ms()).await {
2705
            Ok(n) if n > 0 => tracing::info!("flow: {n} approvals expired"),
2706
            Ok(_) => {}
2707
            Err(e) => tracing::warn!("flow: expiring approvals: {e}"),
070a7b64 Andy McCall ↶ 2708
        }
2709
    }
cc19697f Andy McCall ↶ 2710
}
2711
2712
/// Check a runner's signed receipt for a job of `run` and keep it in the
2713
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
2714
/// name, or why it was not kept. It must be the job's receipt, for the
2715
/// run's state and repository, signed by the key the runner's token first
2716
/// signed with.
2717
pub async fn keep_receipt(
2718
    app: &Shared,
b6b7e4cf Andy McCall ↶ 2719
    runner: &db::runners::Runner,
cc19697f Andy McCall ↶ 2720
    row: &db::repos::RepoRow,
2721
    run: &fdb::Run,
2722
    receipt: &flow_core::Receipt,
2723
    signed_hex: &str,
2724
) -> Result<(String, String), String> {
2725
    use oxy_core::receipt::SignedReceipt;
2726
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
2727
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
2728
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
2729
    if sr.receipt.hash().0 != receipt.hash() {
2730
        return Err("it signs another receipt than the job's".into());
2731
    }
2732
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
2733
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
2734
    if sr.state() != state {
2735
        return Err("it is for another state than the run's".into());
2736
    }
2c31915f Andy McCall ↶ 2737
    let signer = match &sr.certificate {
2738
        // Signed by a short-lived key this hub certified for this job
2739
        // (keyless, #241): the certificate stands for the runner's key.
2740
        Some(c) => {
2741
            let cert = &c.cert.cert;
2742
            if cert.issuer != app.hub_key.key || c.issuer.hash() != app.hub_key.key {
2743
                return Err("its certificate was not issued by this hub".into());
2744
            }
2745
            let subject = format!("repo:{}/{}:bookmark:{}", row.owner, row.name, run.bookmark);
2746
            if cert.subject != subject {
2747
                return Err(format!("its certificate is for {}, not {subject}", cert.subject));
2748
            }
2749
            if cert.claim("run") != Some(run.number.to_string().as_str()) || cert.claim("job") != Some(sr.receipt.job_name.as_str()) {
2750
                return Err("its certificate is for another run or job".into());
2751
            }
2752
            // Now is when this hub accepts it: a time the runner did not choose.
2753
            if !cert.covers(oxy_core::identity::Scope::SEAL, db::now_ms() as u64) {
2754
                return Err("its certificate does not cover now: certify the key just before signing the receipt".into());
2755
            }
2756
            format!("{} (short-lived key)", runner.name)
2757
        }
2758
        None => {
2759
            let key = sr.runner.hash().to_hex();
2760
            let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
2761
            if bound != key {
2762
                return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
2763
            }
2764
            sr.runner.name.clone()
2765
        }
2766
    };
cc19697f Andy McCall ↶ 2767
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
965b950f Andy McCall ↶ 2768
    let (rid, signers) = (sr.attest.repo, sr.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
2769
    let cert = sr.certificate.as_ref().map(|c| (c.cert.cert.hash(), c.cert.cert.issuer));
cc19697f Andy McCall ↶ 2770
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
965b950f Andy McCall ↶ 2771
    // The transparency log (#238): the receipt, and a short-lived key's
2772
    // certificate beside it.
2773
    let mut entries = vec![crate::tlog::Pending { kind: "receipt", repo: rid, repo_row: Some(row.id), object: attest, signers }];
2774
    if let Some((c, issuer)) = cert {
2775
        entries.push(crate::tlog::Pending { kind: "certificate", repo: rid, repo_row: Some(row.id), object: c, signers: vec![issuer] });
2776
    }
2777
    crate::tlog::log(app, entries).await;
cc19697f Andy McCall ↶ 2778
    Ok((attest.to_hex(), signer))
42fa9554 Andy McCall ↶ 2779
}
2780
2781
/// A job's wall time, as the pages show it.
2782
pub fn duration(j: &fdb::Job) -> String {
2783
    match (j.started_ms, j.finished_ms) {
2784
        (Some(a), Some(b)) => human_ms(b - a),
2785
        (Some(a), None) => human_ms(db::now_ms() - a),
2786
        _ => "–".into(),
2787
    }
2788
}
2789
2790
pub fn human_ms(ms: i64) -> String {
2791
    let ms = ms.max(0);
2792
    if ms < 1000 {
2793
        format!("{ms} ms")
2794
    } else if ms < 60_000 {
2795
        format!("{:.1} s", ms as f64 / 1000.0)
2796
    } else {
2797
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
2798
    }
2799
}
bf3d0754 Andy McCall ↶ 2800
2801
/// Where a live run stands among the runners' work: what its viewers may
2802
/// know of it. Jobs of other repositories are named only when those are
2803
/// public; the rest are counted.
2804
#[derive(Clone, Debug, Default, PartialEq, Eq)]
2805
pub struct QueueInfo {
2806
    /// 1-based place of the run's first queued job among all queued jobs.
2807
    pub position: Option<usize>,
2808
    /// Jobs running now, and queued jobs ahead of the run's first one.
2809
    pub running: Vec<String>,
2810
    pub ahead: Vec<String>,
2811
    /// Of those, jobs in repositories the page may not name.
2812
    pub hidden: usize,
2813
}
2814
2815
impl QueueInfo {
2816
    /// "3rd in line" and the like.
2817
    pub fn place(&self) -> Option<String> {
2818
        self.position.map(|n| {
2819
            let suffix = match (n % 10, n % 100) {
2820
                (1, 11) | (2, 12) | (3, 13) => "th",
2821
                (1, _) => "st",
2822
                (2, _) => "nd",
2823
                (3, _) => "rd",
2824
                _ => "th",
2825
            };
2826
            format!("{n}{suffix} in line")
2827
        })
2828
    }
2829
}
2830
2831
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
2832
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
2833
    let mut info = QueueInfo::default();
2834
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
2835
    info.position = first.map(|i| i + 1);
2836
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
2837
    let mut queued_seen = 0;
2838
    for e in queue {
2839
        if e.run_id == run_id {
2840
            continue;
2841
        }
2842
        let ahead = match e.status.as_str() {
2843
            "running" => true,
2844
            _ => {
2845
                queued_seen += 1;
2846
                first.is_some_and(|f| queued_seen <= f)
2847
            }
2848
        };
2849
        if !ahead {
2850
            continue;
2851
        }
2852
        if e.repo_id != repo_id && e.visibility != "public" {
2853
            info.hidden += 1;
2854
        } else if e.status == "running" {
2855
            info.running.push(label(e));
2856
        } else {
2857
            info.ahead.push(label(e));
2858
        }
2859
    }
2860
    info
2861
}
2862
2863
#[cfg(test)]
2864
mod queue_tests {
2865
    use super::*;
2866
2867
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
2868
        fdb::QueueEntry {
2869
            job_id,
2870
            run_id,
2871
            repo_id,
2872
            owner: "o".into(),
2873
            name: format!("r{repo_id}"),
2874
            visibility: vis.into(),
2875
            run_number: run_id,
2876
            job: job.into(),
2877
            status: status.into(),
b66e70b7 Andy McCall ↶ 2878
            runner: String::new(),
c4c87f48 Andy McCall ↶ 2879
            spec: String::new(),
2880
            sandbox: String::new(),
e9b9619c Andy McCall ↶ 2881
            started_ms: None,
107eaba5 Andy McCall ↶ 2882
            priority_rank: 2,
2883
            priority_note: String::new(),
bf3d0754 Andy McCall ↶ 2884
        }
2885
    }
2886
2887
    #[test]
2888
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
2889
        // Running first, then queued in claim order (as `fdb::queue` returns them).
2890
        let q = vec![
2891
            e(1, 10, 1, "public", "build", "running"),
2892
            e(2, 11, 2, "private", "test", "running"),
2893
            e(3, 10, 1, "public", "test", "queued"),
2894
            e(4, 12, 3, "private", "lint", "queued"),
2895
            e(5, 13, 4, "public", "build", "queued"),
2896
            e(6, 13, 4, "public", "test", "queued"),
2897
            e(7, 14, 5, "public", "late", "queued"),
2898
        ];
2899
        let info = queue_info(&q, 4, 13);
2900
        assert_eq!(info.position, Some(3));
2901
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
2902
        assert_eq!(info.running, ["o/r1 #10 build"]);
2903
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
2904
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
2905
        // A private repository's own jobs are named to its viewers.
2906
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
2907
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
2908
        // A run with nothing queued has no place.
2909
        assert_eq!(queue_info(&q, 1, 99).position, None);
2910
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
2911
        assert_eq!(
2912
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
2913
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
4d2e44d7 Andy McCall ↶ 2914
        );
2915
    }
2916
}
2917
2918
#[cfg(test)]
2919
mod placement_tests {
2920
    use super::*;
2921
2922
    #[test]
2923
    fn a_waiting_job_says_what_each_runner_lacks() {
2924
        use crate::db::runners::Runner;
2925
        let rootfs = "ab".repeat(32);
2926
        let job = fdb::Job {
2927
            id: 7,
2928
            runtime: "syscall".into(),
2929
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
2930
            spec: serde_json::json!({ "name": "build" }).to_string(),
2931
            ..Default::default()
2932
        };
2933
        let now = db::now_ms();
2934
        let runner = |id: i64, name: &str| Runner {
2935
            id,
2936
            name: name.into(),
2937
            state: "active".into(),
2938
            last_seen_ms: Some(now),
2939
            arch: "x86_64".into(),
2940
            tiers: "microvm,syscall".into(),
2941
            images: serde_json::to_string(&[&rootfs]).unwrap(),
2942
            memory_mib: Some(16384),
2943
            cpus: Some(8),
2944
            ..Default::default()
2945
        };
2946
        let archs = std::collections::HashMap::new();
92a797b2 Andy McCall ↶ 2947
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
2948
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
2949
        };
4d2e44d7 Andy McCall ↶ 2950
        assert_eq!(why(&[], &[]), "no runner is online");
2951
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
2952
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
2953
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
2954
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
2955
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
2956
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
2957
        assert_eq!(
2958
            why(&[microvm_only, small, other, offline], &[]),
2959
            "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"
2960
        );
2961
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
2962
        assert_eq!(
92a797b2 Andy McCall ↶ 2963
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
4d2e44d7 Andy McCall ↶ 2964
            "no runner can take it: narvi is riscv64; the job needs x86_64"
2965
        );
2966
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
2967
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
2968
        let d = fdb::DeclineRow {
2969
            job_id: 7,
2970
            runner_id: 1,
2971
            runner: "earth-1".into(),
2972
            reason: "only 512 MiB of memory is free here".into(),
2973
            ms: now,
2974
        };
2975
        assert_eq!(
2976
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
2977
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
bf3d0754 Andy McCall ↶ 2978
        );
4d2e44d7 Andy McCall ↶ 2979
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
2980
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
bf3d0754 Andy McCall ↶ 2981
    }
2982
}