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