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