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