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