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