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