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