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