OXYGENHUB
# a36bab6b9ffc oxygenhub / crates / hub / src / flow.rs
71.2 KiB · Rust · last changed Reports: keep a job's report file by file, index its declaration, metrics and files 27 days ago source blame history raw
42fa9554 Andy McCall ↶ 1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
9008bf6c Andy McCall ↶ 22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        "on" => true,
27
        "off" => false,
28
        _ => app.config.flow.allows(&row.owner, &row.name),
29
    }
30
}
31
42fa9554 Andy McCall ↶ 32
/// A bookmark moved by a push: start a run when the repository is allowed
33
/// and the new state has a pipeline.
34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
c889911b Andy McCall ↶ 35
    if heads.is_empty() || !enabled(app, row) {
42fa9554 Andy McCall ↶ 36
        return;
37
    }
c889911b Andy McCall ↶ 38
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
39
    let started = match bookmark.strip_prefix("tags/") {
40
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
41
        None => {
42
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
745d225d Andy McCall ↶ 43
            let started = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await;
44
            if let Ok(Some(n)) = started {
45
                supersede(app, row, bookmark, n).await;
46
            }
47
            started
c889911b Andy McCall ↶ 48
        }
49
    };
50
    if let Err(e) = started {
1edac4b6 Andy McCall ↶ 51
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
52
    }
53
}
54
55
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
56
/// moving it. Returns the new run's number.
57
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
58
    if !enabled(app, row) {
59
        return Err("OxygenFlow is not enabled for this repository".into());
60
    }
61
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
62
    let b = bookmark.to_string();
63
    let found = app
64
        .repos
65
        .read(&repo, move |r, _| {
66
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
67
            Ok(Some((st, r.store().get_state(&st)?.heads)))
68
        })
69
        .await
70
        .map_err(|e| e.to_string())?;
71
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
72
    if heads.is_empty() {
73
        return Err(format!("{bookmark} has no changes yet"));
74
    }
75
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
76
        .await?
77
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
78
}
79
4f692533 Andy McCall ↶ 80
/// "Run pipeline" on one committed change: the state whose only head is
81
/// `change`, whatever bookmarks hold it. Returns the new run's number.
82
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
83
    if !enabled(app, row) {
84
        return Err("OxygenFlow is not enabled for this repository".into());
85
    }
86
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
87
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
df15b747 Andy McCall ↶ 88
    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 ↶ 89
    if !known {
90
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
91
    }
92
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
93
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
94
        .await?
95
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
96
}
97
1edac4b6 Andy McCall ↶ 98
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
99
/// run whose checks replace the old ones. Returns the new run's number.
100
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
101
    if !enabled(app, row) {
102
        return Err("OxygenFlow is not enabled for this repository".into());
103
    }
104
    if run.live() {
105
        return Err(format!("run #{} is still {}", run.number, run.status));
106
    }
107
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
108
    if heads.is_empty() {
109
        return Err("the run names no changes".into());
110
    }
c889911b Andy McCall ↶ 111
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
112
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
113
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
1edac4b6 Andy McCall ↶ 114
        .await?
115
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
745d225d Andy McCall ↶ 116
}
117
118
/// Cancel a run still going (oxygenflow#32), its checks with it.
119
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
120
    if !run.live() {
121
        return Err(format!("run #{} is already {}", run.number, run.status));
122
    }
123
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
124
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
125
        "the run could not be cancelled".to_string()
126
    })
127
}
128
129
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
130
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
131
    report_changed(app, run.id, changed).await
c889911b Andy McCall ↶ 132
}
133
745d225d Andy McCall ↶ 134
/// A push makes the push runs still going on its bookmark moot: they are
135
/// cancelled, and the new run's scope reaches back past them.
136
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, number: i64) {
137
    let Ok(older) = fdb::live_push_runs_before(&app.db, row.id, bookmark, number).await else { return };
138
    for run in older {
139
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
140
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
141
        }
142
    }
143
}
144
c889911b Andy McCall ↶ 145
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
146
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
147
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
148
    use flow_core::pipeline::Event;
149
    let t = match trigger.strip_prefix("rerun:") {
150
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
151
        None => trigger,
152
    };
153
    if let Some(tag) = t.strip_prefix("tag:") {
154
        Event::Tag(tag)
155
    } else if t == "push" {
156
        Event::Push(bookmark)
157
    } else if t == "schedule" {
158
        Event::Schedule
159
    } else {
160
        Event::Manual
161
    }
1edac4b6 Andy McCall ↶ 162
}
163
c889911b Andy McCall ↶ 164
/// The event to repeat when rerunning `run`.
165
fn original_event(run: &fdb::Run) -> String {
166
    let t = run.trigger.as_str();
167
    match t.strip_prefix("rerun:") {
168
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
169
        None => t.to_string(),
170
    }
171
}
172
d7ce41ac Andy McCall ↶ 173
/// The paths the changes a push brought touch: the changes in `heads`'
174
/// state that the bookmark's previous run did not have, and the files their
175
/// edits and tree operations name, as the new state places them. `None`
176
/// (run every job) for the bookmark's first run, or when it cannot be told.
177
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
178
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
179
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
180
    if old.is_empty() {
181
        return None;
182
    }
183
    let repo = RepoName::new(&row.owner, &row.name)?;
184
    let new = heads.to_vec();
185
    app.repos
186
        .read(&repo, move |r, cache| {
187
            let fresh = r.negotiate(&old, &new)?;
188
            let loaded = cache.load(r, &new)?;
189
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
190
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
191
            let mut paths = std::collections::BTreeSet::new();
192
            for h in fresh {
193
                let (c, _) = r.store().get_change_lazy(&h)?;
194
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
195
                for n in nodes {
196
                    if let Some(p) = by_node.get(&n) {
197
                        paths.insert((*p).clone());
198
                    }
199
                }
200
            }
201
            Ok(paths.into_iter().collect::<Vec<String>>())
202
        })
203
        .await
204
        .ok()
205
}
206
207
/// Whether a job runs for a push that touched `touched`: always without a
208
/// scope or without knowing what was touched, else when a path matches.
209
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
210
    let Some(touched) = touched else { return true };
211
    if scope.is_empty() {
212
        return true;
213
    }
214
    let mut b = globset::GlobSetBuilder::new();
215
    for g in scope {
216
        match globset::Glob::new(g) {
217
            Ok(g) => {
218
                b.add(g);
219
            }
220
            Err(_) => return true,
221
        }
222
    }
223
    let Ok(set) = b.build() else { return true };
224
    touched.iter().any(|p| set.is_match(p))
225
}
226
227
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
228
/// left out of the run, unless a job that runs depends on them.
229
#[allow(clippy::too_many_arguments)]
230
pub async fn start_run_scoped(
231
    app: &Shared,
232
    row: &db::repos::RepoRow,
233
    bookmark: &str,
234
    state: &str,
235
    heads: &[ChangeHash],
236
    actor: &str,
237
    trigger: &str,
238
    touched: Option<&[String]>,
239
) -> Result<Option<i64>, String> {
240
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
241
}
242
1edac4b6 Andy McCall ↶ 243
/// Start a run of the pipeline in the state `heads` make (whose state
244
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
245
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
246
/// whose access the run's subrepo reads are checked against. Returns the
247
/// run's number, or `None` when the state has no pipeline. The caller
248
/// checks that Flow is enabled.
249
pub async fn start_run(
250
    app: &Shared,
251
    row: &db::repos::RepoRow,
252
    bookmark: &str,
253
    state: &str,
254
    heads: &[ChangeHash],
255
    actor: &str,
256
    trigger: &str,
257
) -> Result<Option<i64>, String> {
d7ce41ac Andy McCall ↶ 258
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
259
}
260
261
#[allow(clippy::too_many_arguments)]
262
async fn start_run_with(
263
    app: &Shared,
264
    row: &db::repos::RepoRow,
265
    bookmark: &str,
266
    state: &str,
267
    heads: &[ChangeHash],
268
    actor: &str,
269
    trigger: &str,
270
    touched: Option<&[String]>,
271
) -> Result<Option<i64>, String> {
348f8c4b Andy McCall ↶ 272
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None).await
273
}
274
275
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock.
276
#[allow(clippy::too_many_arguments)]
277
async fn start_run_at(
278
    app: &Shared,
279
    row: &db::repos::RepoRow,
280
    bookmark: &str,
281
    state: &str,
282
    heads: &[ChangeHash],
283
    actor: &str,
284
    trigger: &str,
285
    touched: Option<&[String]>,
286
    at: Option<flow_core::pipeline::When>,
287
) -> Result<Option<i64>, String> {
1edac4b6 Andy McCall ↶ 288
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
42fa9554 Andy McCall ↶ 289
    let h = heads.to_vec();
290
    let text = app
291
        .repos
292
        .read(&repo, move |r, cache| {
293
            let loaded = cache.load(r, &h)?;
294
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
295
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
296
                _ => None,
297
            })
298
        })
1edac4b6 Andy McCall ↶ 299
        .await
300
        .map_err(|e| format!("reading the pipeline: {e}"))?;
301
    let Some(text) = text else { return Ok(None) };
42fa9554 Andy McCall ↶ 302
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
303
    hex.sort();
304
    let heads_s = hex.join("+");
1edac4b6 Andy McCall ↶ 305
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
306
    let id = match Pipeline::parse(&text) {
42fa9554 Andy McCall ↶ 307
        Err(problems) => {
308
            let msg = problems.to_string();
1edac4b6 Andy McCall ↶ 309
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
42fa9554 Andy McCall ↶ 310
        }
311
        Ok(p) => {
312
            let depths = p.depths();
d7ce41ac Andy McCall ↶ 313
            // Jobs out of the push's scope stay out, unless a kept job
314
            // needs them.
c889911b Andy McCall ↶ 315
            // The jobs this run's event starts (`on`), out of those its
316
            // push touched (`scope`); then what they depend on.
348f8c4b Andy McCall ↶ 317
            let event = match at {
318
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
319
                None => event_of(trigger, bookmark),
320
            };
c889911b Andy McCall ↶ 321
            let mut keep: std::collections::HashSet<String> = p
322
                .order()
323
                .into_iter()
324
                .filter(|j| p.on_of(j).matches(event) && in_scope(&j.scope, touched))
325
                .map(|j| j.name.clone())
326
                .collect();
d7ce41ac Andy McCall ↶ 327
            loop {
328
                let needed: Vec<String> = p
329
                    .order()
330
                    .into_iter()
331
                    .filter(|j| keep.contains(&j.name))
332
                    .flat_map(|j| j.depends_on.iter().cloned())
333
                    .filter(|d| !keep.contains(d))
334
                    .collect();
335
                if needed.is_empty() {
336
                    break;
337
                }
338
                keep.extend(needed);
339
            }
42fa9554 Andy McCall ↶ 340
            let jobs: Vec<fdb::NewJob> = p
341
                .order()
342
                .into_iter()
d7ce41ac Andy McCall ↶ 343
                .filter(|j| keep.contains(&j.name))
42fa9554 Andy McCall ↶ 344
                .map(|j| {
345
                    let sb = &p.sandboxes[&j.sandbox];
346
                    fdb::NewJob {
347
                        name: j.name.clone(),
348
                        depth: depths[j.name.as_str()] as i64,
349
                        depends: j.depends_on.clone(),
350
                        spec: serde_json::to_string(j).unwrap_or_default(),
351
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
352
                        runtime: sb.runtime.as_str().into(),
353
                    }
354
                })
355
                .collect();
c889911b Andy McCall ↶ 356
            if jobs.is_empty() {
357
                // Nothing this event starts (or this push touched).
d7ce41ac Andy McCall ↶ 358
                return Ok(None);
359
            }
42fa9554 Andy McCall ↶ 360
            let hash = hex::encode(p.hash());
1edac4b6 Andy McCall ↶ 361
            let id =
362
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
363
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
42fa9554 Andy McCall ↶ 364
                for j in &jobs {
a0d4bbfb Andy McCall ↶ 365
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
42fa9554 Andy McCall ↶ 366
                }
367
            }
368
            id
369
        }
370
    };
1edac4b6 Andy McCall ↶ 371
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
372
    Ok(Some(run.number))
42fa9554 Andy McCall ↶ 373
}
374
a0d4bbfb Andy McCall ↶ 375
/// Record a job's check; it links to the run's page by the run's number.
376
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
377
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
42fa9554 Andy McCall ↶ 378
    let name = format!("flow/{job}");
379
    let c = db::hooks::upsert_check(
380
        &app.db,
381
        row.id,
382
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
383
    )
384
    .await;
385
    if let Ok(c) = c {
386
        let api = crate::api::check_of(&c);
387
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
388
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
389
    }
390
}
391
392
/// The check status a job's status shows as.
393
fn check_status(job_status: &str) -> &'static str {
394
    match job_status {
395
        "queued" => "queued",
396
        "running" => "running",
397
        "passed" => "passed",
398
        "skipped" => "skipped",
745d225d Andy McCall ↶ 399
        "cancelled" => "cancelled",
42fa9554 Andy McCall ↶ 400
        _ => "failed",
401
    }
402
}
403
b6b7e4cf Andy McCall ↶ 404
/// The runner a bearer token belongs to: a registered runner, or one
405
/// configured with a hub.toml token (tracked from its first request). A
406
/// disabled runner is refused.
407
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
42fa9554 Andy McCall ↶ 408
    let Some(t) = oxy_server::bearer_token(headers) else {
409
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
b6b7e4cf Andy McCall ↶ 410
    };
411
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
412
    let runner = match db::runners::by_token(&app.db, &hash).await? {
413
        Some(r) => r,
414
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
415
            db::runners::configured(&app.db, &hash, "runner").await?
416
        }
417
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
42fa9554 Andy McCall ↶ 418
    };
b6b7e4cf Andy McCall ↶ 419
    if runner.state == "disabled" {
420
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
42fa9554 Andy McCall ↶ 421
    }
b6b7e4cf Andy McCall ↶ 422
    Ok(runner)
42fa9554 Andy McCall ↶ 423
}
424
b6b7e4cf Andy McCall ↶ 425
/// `POST /api/v1/flow/runners/register`: a one-time registration token
426
/// from Admin → Runners buys a runner credential, binding the runner's
427
/// signing identity (hub spec §13.2).
428
pub async fn register(
429
    State(app): State<Shared>,
430
    Json(req): Json<flow_core::api::Register>,
431
) -> Result<Json<flow_core::api::Registered>, ApiError> {
432
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
433
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
434
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
435
    if name.is_empty() {
436
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
437
    }
438
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
439
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
440
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
441
    let runner =
442
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
443
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
444
        })?;
445
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
446
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
447
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
448
}
449
42fa9554 Andy McCall ↶ 450
/// `POST /api/v1/flow/claim`.
451
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 452
    let me = runner_auth(&app, &headers).await?;
453
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
454
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
eef677c4 Andy McCall ↶ 455
    if let Some(holding) = &req.holding {
456
        end_jobs_not_held(&app, &me, holding).await?;
457
    }
b6b7e4cf Andy McCall ↶ 458
    // A draining runner finishes what it has and takes nothing new.
459
    if me.state == "draining" {
460
        return Ok(StatusCode::NO_CONTENT.into_response());
461
    }
462
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
42fa9554 Andy McCall ↶ 463
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
efd29a00 Andy McCall ↶ 464
    // What it holds and what it is (OxygenFlow spec §11): the images'
465
    // architectures it reports are remembered for every runner's claims.
466
    if let Some(archs) = &req.image_archs {
467
        fdb::record_image_archs(&app.db, archs).await?;
468
    }
469
    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 ↶ 470
    // What it offers, kept for the placement pass; a job it declined is
471
    // not offered to it again while this stays the same (spec §11.5).
472
    let profile = profile_of(&req, &arch);
473
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), &profile).await?;
b0612e7d Andy McCall ↶ 474
    let room = fdb::Room {
4d2e44d7 Andy McCall ↶ 475
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
b0612e7d Andy McCall ↶ 476
        memory_mib: req.free_memory_mib,
477
        cpus: req.free_cpus,
478
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
efd29a00 Andy McCall ↶ 479
        arch,
480
        image_archs: fdb::image_archs(&app.db).await?,
b0612e7d Andy McCall ↶ 481
    };
7f5f6c05 Andy McCall ↶ 482
    // The labels it reports now, or those it registered with, and those
483
    // an administrator gave it (#98).
484
    let mut labels: Vec<String> = match &req.info {
c4c87f48 Andy McCall ↶ 485
        Some(i) => i.labels.clone(),
7f5f6c05 Andy McCall ↶ 486
        None => me.reported_label_list().into_iter().map(String::from).collect(),
c4c87f48 Andy McCall ↶ 487
    };
7f5f6c05 Andy McCall ↶ 488
    for l in me.admin_label_list() {
489
        if !labels.iter().any(|x| x == l) {
490
            labels.push(l.to_string());
491
        }
492
    }
c4c87f48 Andy McCall ↶ 493
    let Some(job) =
494
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
495
    else {
42fa9554 Andy McCall ↶ 496
        return Ok(StatusCode::NO_CONTENT.into_response());
497
    };
b6b7e4cf Andy McCall ↶ 498
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
42fa9554 Andy McCall ↶ 499
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
500
    let row =
501
        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 ↶ 502
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
42fa9554 Andy McCall ↶ 503
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
5dead1f4 Andy McCall ↶ 504
    // The job's limit: its own timeout, else its sandbox's, else the site
505
    // default, never past the site maximum; recorded for the reaper.
506
    let limits = limits(&app).await;
507
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
508
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
7f5f6c05 Andy McCall ↶ 509
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
510
    // ...and never past the runner's own limit, when it has one (#98).
511
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
512
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
513
    }
5dead1f4 Andy McCall ↶ 514
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
42fa9554 Andy McCall ↶ 515
    let claim = Claim {
516
        job_id: job.id,
517
        run_id: run.id,
518
        repo: format!("{}/{}", row.owner, row.name),
519
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
520
        token: secret,
521
        heads: run.head_list().into_iter().map(String::from).collect(),
522
        pipeline_hash: run.pipeline_hash.clone(),
5dead1f4 Andy McCall ↶ 523
        job: spec,
524
        sandbox,
525
        timeout_s,
fd3db644 Andy McCall ↶ 526
        subrepos: vec![],
9a065bc0 Andy McCall ↶ 527
        state: run.state.clone(),
528
        bookmark: run.bookmark.clone(),
529
        run_number: run.number,
2a83f530 Andy McCall ↶ 530
        // The site's, the owner's and the repository's variables (#102).
531
        env: crate::variables::env_for_repo(&app.db, &row).await?,
4ac01a73 Andy McCall ↶ 532
        secrets: vec![],
42fa9554 Andy McCall ↶ 533
    };
fd3db644 Andy McCall ↶ 534
    let mut claim = claim;
4ac01a73 Andy McCall ↶ 535
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
536
    // The secrets the job declares, as files (#107); one not set is said
537
    // at the top of the log and left out, so the job fails on the file.
538
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
539
    let found = crate::secrets::for_job(&app, &row, &names).await?;
d1727c57 Andy McCall ↶ 540
    // The identities it asks for (#103): a token each, as a file.
541
    if !claim.job.identities.is_empty() {
542
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
543
        for i in &claim.job.identities {
544
            let token = issuer.mint(&crate::oidc::JobClaims {
545
                issuer: app.config.base_url(),
546
                audience: &i.audience,
547
                owner: &row.owner,
548
                name: &row.name,
549
                visibility: &row.visibility,
550
                bookmark: &run.bookmark,
551
                run_number: run.number,
552
                job: &job.name,
553
                state: &run.state,
554
                runner: &runner,
555
                actor: &run.actor,
556
                now_ms: db::now_ms(),
557
                ttl_s: timeout_s,
558
            });
559
            claim.secrets.push(flow_core::api::SecretInput {
560
                name: format!("identity:{}", i.audience),
561
                path: i.mount_path.clone(),
562
                value: token,
563
            });
564
        }
565
    }
4ac01a73 Andy McCall ↶ 566
    for s in &claim.job.secrets {
567
        match found.iter().find(|(n, _)| *n == s.name) {
568
            Some((name, value)) => {
569
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
570
            }
571
            None => left_out.push(format!(
572
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
573
                s.name, s.mount_path
574
            )),
575
        }
576
    }
fd3db644 Andy McCall ↶ 577
    if !ids.is_empty() {
578
        fdb::set_also_read(&app.db, job.id, &ids).await?;
579
    }
afd0be38 Andy McCall ↶ 580
    // Say at the top of the job's log what it will not have (oxygenhub#56),
581
    // rather than let it fail later on missing files.
582
    if !left_out.is_empty() {
583
        let records: Vec<flow_core::api::Record> =
584
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
585
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
586
    }
fd3db644 Andy McCall ↶ 587
    claim.subrepos = inputs;
42fa9554 Andy McCall ↶ 588
    Ok(Json(claim).into_response())
fd3db644 Andy McCall ↶ 589
}
590
4d2e44d7 Andy McCall ↶ 591
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
592
/// labels and capacity, each list sorted.
593
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
594
    let sorted = |v: &[String]| {
595
        let mut v = v.to_vec();
596
        v.sort();
597
        v
598
    };
599
    let info = req.info.as_ref();
600
    let v = serde_json::json!({
601
        "arch": arch,
602
        "tiers": sorted(&req.runtimes),
603
        "images": req.images.as_deref().map(sorted),
604
        "labels": info.map(|i| sorted(&i.labels)),
605
        "memory_mib": info.and_then(|i| i.memory_mib),
606
        "cpus": info.and_then(|i| i.cpus),
607
    });
608
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
609
}
610
611
/// A job handed back by this many different runners is unplaceable.
612
pub const UNPLACEABLE_AFTER: usize = 3;
613
614
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
615
/// runner will not run a job it was handed. The job is queued again and
616
/// not offered to that runner while its profile stays the same (and for
617
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
618
/// different runners have declined it, it ends as unplaceable with their
619
/// reasons. A decline is never a failed check.
620
pub async fn decline(
621
    State(app): State<Shared>,
622
    headers: HeaderMap,
623
    Path(id): Path<i64>,
624
    Json(d): Json<flow_core::api::Decline>,
625
) -> Result<StatusCode, ApiError> {
626
    let me = runner_auth(&app, &headers).await?;
627
    db::runners::seen(&app.db, me.id, None, None).await?;
628
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
629
    let reason = match reason.trim() {
630
        "" => "no reason given".to_string(),
631
        r => r.to_string(),
632
    };
633
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
634
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
635
    };
636
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
637
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
638
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
639
    if runners.len() >= UNPLACEABLE_AFTER {
640
        let error = format!(
641
            "unplaceable: declined by {} runners: {}",
642
            runners.len(),
643
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
644
        );
645
        let changed =
646
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
647
                .await?;
648
        report_changed(&app, job.run_id, changed).await?;
649
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
650
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
651
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
652
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
653
            fdb::set_waiting(&app.db, id, &summary).await?;
654
        }
655
    }
656
    Ok(StatusCode::NO_CONTENT)
657
}
658
659
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
660
/// now (spec §11.4), from what each runner offered at its last claim:
661
/// empty when one could (it waits its turn or a free slot), else what
662
/// each one lacks. `declines` are the job's.
663
pub fn why_waiting(
664
    job: &fdb::Job,
665
    owner: &str,
666
    repo_id: i64,
667
    runners: &[db::runners::Runner],
668
    image_archs: &std::collections::HashMap<String, String>,
669
    declines: &[fdb::DeclineRow],
670
    now: i64,
671
) -> String {
672
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
673
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
674
    let wanted_arch = job.arch().or_else(|| image_archs.get(rootfs).cloned());
675
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
676
    let needs = job.needs();
677
    let lacks = |r: &db::runners::Runner| -> Option<String> {
678
        if r.state == "draining" {
679
            return Some("is draining".into());
680
        }
681
        let scope = r.scope();
682
        if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
683
            return Some("serves other repositories".into());
684
        }
685
        let tiers = r.tier_list();
686
        if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
687
            return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
688
        }
689
        if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
690
            return Some(format!("is {}; the job needs {w}", r.arch));
691
        }
692
        if r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
693
            return Some(format!("does not hold image {}", &rootfs[..12.min(rootfs.len())]));
694
        }
695
        let labels = r.label_list();
696
        if let Some(l) = needs.iter().find(|l| !labels.contains(&l.as_str())) {
697
            return Some(format!("lacks label {l}"));
698
        }
699
        if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
700
            return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
701
        }
702
        if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
703
            return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
704
        }
705
        if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && now - d.ms < fdb::DECLINE_HOLDS_MS) {
706
            return Some(format!("declined it: {}", d.reason));
707
        }
708
        None
709
    };
710
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
711
    if online.is_empty() {
712
        return "no runner is online".into();
713
    }
714
    let mut why = Vec::new();
715
    for r in &online {
716
        match lacks(r) {
717
            None => return String::new(),
718
            Some(w) => why.push(format!("{} {w}", r.name)),
719
        }
720
    }
721
    let more = why.len().saturating_sub(4);
722
    why.truncate(4);
723
    let mut text = format!("no runner can take it: {}", why.join("; "));
724
    if more > 0 {
725
        text.push_str(&format!("; and {more} more"));
726
    }
727
    text
728
}
729
730
/// One placement pass: say why each ready queued job waits.
731
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
732
    let ready = fdb::ready_queued(&app.db).await?;
733
    if ready.is_empty() {
734
        return Ok(());
735
    }
736
    let runners = db::runners::list(&app.db).await?;
737
    let archs = fdb::image_archs(&app.db).await?;
738
    let now = db::now_ms();
739
    for (job, owner, repo_id) in ready {
740
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
741
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now);
742
        fdb::set_waiting(&app.db, job.id, &why).await?;
743
    }
744
    Ok(())
745
}
746
747
/// Run a placement pass every 15 seconds.
748
pub async fn placement(app: Shared) {
749
    loop {
750
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
751
        if let Err(e) = explain_waits(&app).await {
752
            tracing::warn!("flow: placement pass: {e}");
753
        }
754
    }
755
}
756
25091e5c Andy McCall ↶ 757
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
758
async fn pins_in(
759
    app: &Shared,
760
    name: crate::repos::RepoName,
761
    heads: Vec<oxy_core::hash::ChangeHash>,
762
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
763
    app.repos
fd3db644 Andy McCall ↶ 764
        .read(&name, move |repo, cache| {
765
            let loaded = cache.load(repo, &heads)?;
766
            let mut out = Vec::new();
767
            for (path, f) in &loaded.snapshot.files {
768
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
769
                    continue;
770
                }
771
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
772
                    if let Some(b) = blobs.first() {
773
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
774
                            .read_blob(b)
775
                            .map_err(crate::repos::RepoError::Oxy)
776
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
777
                        {
778
                            out.push((path.clone(), id, heads));
779
                        }
780
                    }
781
                }
782
            }
783
            Ok(out)
784
        })
785
        .await
25091e5c Andy McCall ↶ 786
        .unwrap_or_default()
787
}
788
789
/// How deep subrepos of subrepos are followed.
790
const MAX_SUBREPO_DEPTH: usize = 3;
791
792
/// The Oxygen subrepos the run's state pins, and those their states pin in
793
/// turn (parents first, paths joined), that live on this hub and that the
794
/// run's pusher may read: what the job's workspace needs beside its own
afd0be38 Andy McCall ↶ 795
/// tree. A pin the hub cannot serve is left out, with what it pins, and
796
/// the third list says why, for the job's log.
797
async fn subrepo_inputs(
798
    app: &Shared,
799
    row: &db::repos::RepoRow,
800
    run: &fdb::Run,
801
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
802
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
25091e5c Andy McCall ↶ 803
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
fd3db644 Andy McCall ↶ 804
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
25091e5c Andy McCall ↶ 805
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
fd3db644 Andy McCall ↶ 806
    let mut inputs = Vec::new();
807
    let mut ids = Vec::new();
afd0be38 Andy McCall ↶ 808
    let mut left_out = Vec::new();
809
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
25091e5c Andy McCall ↶ 810
    // (path prefix, repository, heads, depth), parents before children.
811
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
812
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
813
        if depth == MAX_SUBREPO_DEPTH {
814
            continue;
815
        }
816
        for (path, id, pinned) in pins_in(app, name, heads).await {
817
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
afd0be38 Andy McCall ↶ 818
            let mut hosted = false;
25091e5c Andy McCall ↶ 819
            for other in &rows {
820
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
821
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
822
                if !matches {
823
                    continue;
824
                }
afd0be38 Andy McCall ↶ 825
                hosted = true;
25091e5c Andy McCall ↶ 826
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
827
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
828
                    inputs.push(flow_core::api::SubrepoInput {
829
                        path: path.clone(),
830
                        repo: format!("{}/{}", other.owner, other.name),
831
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
832
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
833
                    });
834
                    if !ids.contains(&other.id) {
835
                        ids.push(other.id);
836
                    }
837
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
afd0be38 Andy McCall ↶ 838
                } else {
839
                    left_out.push(format!(
840
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
841
                        other.owner, other.name
842
                    ));
25091e5c Andy McCall ↶ 843
                }
844
                break;
afd0be38 Andy McCall ↶ 845
            }
846
            if !hosted {
847
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
fd3db644 Andy McCall ↶ 848
            }
849
        }
850
    }
afd0be38 Andy McCall ↶ 851
    (inputs, ids, left_out)
42fa9554 Andy McCall ↶ 852
}
853
854
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
855
    match fdb::job(&app.db, id).await? {
856
        Some(j) if j.status == "running" => Ok(j),
857
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
858
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
859
    }
860
}
861
862
/// `POST /api/v1/flow/jobs/{id}/log`.
863
pub async fn log(
864
    State(app): State<Shared>,
865
    headers: HeaderMap,
866
    Path(id): Path<i64>,
867
    Json(mut batch): Json<LogBatch>,
868
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 869
    runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 870
    let job = running_job(&app, id).await?;
745d225d Andy McCall ↶ 871
    // An empty batch asks only whether the job still runs: a cancelled
872
    // one answers 409 and its runner stops it (oxygenflow#32).
873
    if batch.records.is_empty() {
874
        return Ok(StatusCode::NO_CONTENT);
875
    }
4ac01a73 Andy McCall ↶ 876
    // A secret's value never reaches the stored log (#107).
877
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
878
        Ok(spec) if !spec.secrets.is_empty() => {
879
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
880
            let row = match run {
881
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
882
                None => None,
883
            };
884
            match row {
885
                Some(row) => {
886
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
887
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
888
                }
889
                None => vec![],
890
            }
891
        }
892
        _ => vec![],
893
    };
d1727c57 Andy McCall ↶ 894
    let kid = app.oidc.get().map(|i| i.kid.clone());
42fa9554 Andy McCall ↶ 895
    for r in &mut batch.records {
4ac01a73 Andy McCall ↶ 896
        if !values.is_empty() {
897
            r.text = crate::secrets::mask(&r.text, &values);
d1727c57 Andy McCall ↶ 898
        }
899
        if let Some(kid) = &kid {
900
            r.text = crate::oidc::mask(&r.text, kid);
4ac01a73 Andy McCall ↶ 901
        }
42fa9554 Andy McCall ↶ 902
        if r.text.len() > MAX_RECORD_TEXT {
903
            let mut cut = MAX_RECORD_TEXT;
904
            while !r.text.is_char_boundary(cut) {
905
                cut -= 1;
906
            }
907
            r.text.truncate(cut);
908
        }
909
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
910
            r.stream = "console".into();
911
        }
912
    }
913
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
914
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
915
        tracing::info!("flow: job {} reached the log limit", job.id);
916
    }
917
    Ok(StatusCode::NO_CONTENT)
918
}
919
a3c8288b Andy McCall ↶ 920
/// Largest artifact the hub keeps; the runner refuses larger ones first.
921
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
922
923
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
924
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
925
/// repository. Answers `{"blob"}`.
926
pub async fn artifact(
927
    State(app): State<Shared>,
928
    headers: HeaderMap,
929
    Path((id, name)): Path<(i64, String)>,
930
    body: axum::body::Bytes,
931
) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 932
    runner_auth(&app, &headers).await?;
a3c8288b Andy McCall ↶ 933
    let job = running_job(&app, id).await?;
934
    let spec: flow_core::Job =
935
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
936
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
937
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
938
    };
939
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
940
    let row =
941
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
942
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
a36bab6b Andy McCall ↶ 943
    // A report's artifact is its manifest (#127): checked, and its files
944
    // found in the store, before it is kept.
945
    let report = match out.kind {
946
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
947
        flow_core::OutputKind::Artifact => None,
948
    };
a3c8288b Andy McCall ↶ 949
    let content = blake3::hash(&body).to_hex().to_string();
950
    let size = body.len() as i64;
a36bab6b Andy McCall ↶ 951
    let artifact = report.is_none();
952
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
953
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
a3c8288b Andy McCall ↶ 954
    let blob = app
955
        .repos
956
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
957
        .await
958
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
959
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
960
    fdb::add_artifact(&app.db, &a).await?;
a36bab6b Andy McCall ↶ 961
    if let Some((manifest, declaration)) = report {
962
        let origin = crate::reports::Origin {
963
            repo_id: row.id,
964
            run_id: run.id,
965
            run_number: run.number,
966
            job_id: job.id,
967
            job: &job.name,
968
            bookmark: &run.bookmark,
969
            state: &run.state,
970
        };
971
        let said = match declaration {
972
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
973
                Ok(_) => {
974
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
975
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
976
                }
977
                Err(e) => format!("report {}: not indexed: {e}", a.name),
978
            },
979
            Err(why) => {
980
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
981
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
982
                format!("report {}: report.toml: {why}", a.name)
983
            }
984
        };
985
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
986
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
987
    }
554ccacf Andy McCall ↶ 988
    // A coverage report (#113) is read now; what it says, or why it could
989
    // not be read, goes in the job's log. It never fails the upload.
990
    if let Some(bytes) = report_bytes {
991
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
992
            Ok(text) | Err(text) => text,
993
        };
994
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
995
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
996
    }
0b203bbf Andy McCall ↶ 997
    // A test report (#114) likewise.
998
    if let Some(bytes) = junit_bytes {
999
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
1000
            Ok(text) | Err(text) => text,
1001
        };
1002
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1003
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1004
    }
a3c8288b Andy McCall ↶ 1005
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
a36bab6b Andy McCall ↶ 1006
}
1007
1008
/// Check report manifest `body` (#127) and that the repository holds every
1009
/// file it names; answers it with the report's declaration, or why that
1010
/// could not be read.
1011
async fn report_manifest(
1012
    app: &Shared,
1013
    repo: &RepoName,
1014
    name: &str,
1015
    body: &[u8],
1016
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
1017
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
1018
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
1019
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
1020
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
1021
    let found = app
1022
        .repos
1023
        .read(repo, move |r, _| {
1024
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
1025
                return Ok(Err(path.clone()));
1026
            }
1027
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
1028
        })
1029
        .await
1030
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
1031
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
1032
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
1033
    Ok((manifest, declaration))
1034
}
1035
1036
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
1037
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
1038
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
1039
/// the files follows as the report's artifact.
1040
pub async fn report_file(
1041
    State(app): State<Shared>,
1042
    headers: HeaderMap,
1043
    Path((id, name)): Path<(i64, String)>,
1044
    body: axum::body::Bytes,
1045
) -> Result<Response, ApiError> {
1046
    runner_auth(&app, &headers).await?;
1047
    let job = running_job(&app, id).await?;
1048
    let spec: flow_core::Job =
1049
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1050
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
1051
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
1052
    }
1053
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
1054
    if !crate::reports::path_ok(path) {
1055
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
1056
    }
1057
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1058
    let row =
1059
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1060
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1061
    let blob = app
1062
        .repos
1063
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1064
        .await
1065
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
1066
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
0b203bbf Andy McCall ↶ 1067
}
1068
1069
/// Parse and keep a job's JUnit report: what it says, or why not.
1070
async fn read_junit(
1071
    app: &Shared,
1072
    row: &db::repos::RepoRow,
1073
    run: &fdb::Run,
1074
    job: &fdb::Job,
1075
    blob: &str,
1076
    bytes: axum::body::Bytes,
1077
) -> Result<String, String> {
1078
    if bytes.len() > crate::junit::MAX_REPORT {
1079
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
1080
    }
1081
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
1082
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
1083
        .await
1084
        .map_err(|e| format!("tests: {e}"))?
1085
        .map_err(|e| format!("tests: {e}"))?;
1086
    let origin = crate::junit::Origin {
1087
        repo_id: row.id,
1088
        run_id: run.id,
1089
        run_number: run.number,
1090
        job_id: job.id,
1091
        job: &job.name,
1092
        bookmark: &run.bookmark,
1093
        state: &run.state,
1094
        blob,
1095
    };
1096
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
1097
    let r = crate::junit::for_run(&app.db, run.id)
1098
        .await
1099
        .map_err(|e| format!("tests: {e}"))?
1100
        .into_iter()
1101
        .find(|r| r.job_id == job.id)
1102
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
1103
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
554ccacf Andy McCall ↶ 1104
}
1105
1106
/// Parse and keep a job's coverage report: what it says, or why not.
1107
async fn read_coverage(
1108
    app: &Shared,
1109
    repo: &RepoName,
1110
    row: &db::repos::RepoRow,
1111
    run: &fdb::Run,
1112
    job: &fdb::Job,
1113
    blob: &str,
1114
    bytes: axum::body::Bytes,
1115
) -> Result<String, String> {
1116
    if bytes.len() > crate::coverage::MAX_REPORT {
1117
        return Err(format!(
1118
            "coverage: the report is {} MiB; at most {} MiB is read",
1119
            bytes.len() >> 20,
1120
            crate::coverage::MAX_REPORT >> 20
1121
        ));
1122
    }
7fff30c4 Andy McCall ↶ 1123
    let text = String::from_utf8(bytes.to_vec())
1124
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
1125
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
554ccacf Andy McCall ↶ 1126
        .await
1127
        .map_err(|e| format!("coverage: {e}"))?
1128
        .map_err(|e| format!("coverage: {e}"))?;
1129
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
1130
    let in_state: std::collections::HashSet<String> = app
1131
        .repos
1132
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
1133
        .await
1134
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
1135
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
1136
    let origin = crate::coverage::Origin {
1137
        repo_id: row.id,
1138
        run_id: run.id,
1139
        run_number: run.number,
1140
        job_id: job.id,
1141
        job: &job.name,
1142
        bookmark: &run.bookmark,
1143
        state: &run.state,
1144
        heads: &heads,
1145
        blob,
7fff30c4 Andy McCall ↶ 1146
        format,
554ccacf Andy McCall ↶ 1147
    };
1148
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
1149
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
1150
        return Err("coverage: the report was not kept".into());
1151
    };
7edaff01 Andy McCall ↶ 1152
    // The `coverage` check on the run's state (#113): against the default
1153
    // bookmark, under the repository's rules.
1154
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
1155
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
1156
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
1157
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
1158
    if let Ok(c) = db::hooks::upsert_check(
1159
        &app.db,
1160
        row.id,
1161
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
1162
    )
1163
    .await
1164
    {
1165
        let api = crate::api::check_of(&c);
1166
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1167
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1168
    }
554ccacf Andy McCall ↶ 1169
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
1170
    let dropped =
1171
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
1172
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
a3c8288b Andy McCall ↶ 1173
}
1174
42fa9554 Andy McCall ↶ 1175
/// `POST /api/v1/flow/jobs/{id}/finish`.
1176
pub async fn finish(
1177
    State(app): State<Shared>,
1178
    headers: HeaderMap,
1179
    Path(id): Path<i64>,
1180
    Json(f): Json<Finish>,
1181
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 1182
    let me = runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 1183
    let job = running_job(&app, id).await?;
1184
    let status = match (f.exit_code, &f.error) {
1185
        (Some(0), None) => "passed",
1186
        (Some(_), _) => "failed",
1187
        (None, _) => "error",
1188
    };
1189
    let (receipt, receipt_hash) = match &f.receipt {
1190
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
d29070fa Andy McCall ↶ 1191
            // A passing job's receipt names exactly the artifacts uploaded,
1192
            // in name order; a failing one's names none, though it may have
1193
            // kept `when "always"` artifacts (oxygenflow#37).
1194
            let kept: Vec<String> =
1195
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
a3c8288b Andy McCall ↶ 1196
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
1197
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
1198
            }
42fa9554 Andy McCall ↶ 1199
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
1200
        }
1201
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
1202
        None => (String::new(), String::new()),
1203
    };
1204
    let error = f.error.clone().unwrap_or_default();
1205
    let changed = fdb::finish(
1206
        &app.db,
1207
        job.id,
1208
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
1209
    )
1210
    .await?;
1211
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1212
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
cc19697f Andy McCall ↶ 1213
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
b6b7e4cf Andy McCall ↶ 1214
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
cc19697f Andy McCall ↶ 1215
                    Ok((attest, signer)) => (attest, signer, String::new()),
1216
                    Err(e) => {
1217
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
1218
                        (String::new(), String::new(), e)
1219
                    }
1220
                };
1221
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
1222
            }
42fa9554 Andy McCall ↶ 1223
        }
1224
    }
070a7b64 Andy McCall ↶ 1225
    report_changed(&app, job.run_id, changed).await?;
42fa9554 Andy McCall ↶ 1226
    Ok(StatusCode::NO_CONTENT)
070a7b64 Andy McCall ↶ 1227
}
1228
1229
/// Report the checks of jobs whose status changed.
1230
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
1231
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
1232
        return Ok(());
1233
    };
1234
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
1235
        return Ok(());
1236
    };
1237
    for j in changed {
1238
        let summary = match j.status.as_str() {
1239
            "passed" => format!("passed in {}", duration(&j)),
1240
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
1241
            "skipped" => "skipped: a dependency did not pass".into(),
745d225d Andy McCall ↶ 1242
            "cancelled" => j.error.clone(),
070a7b64 Andy McCall ↶ 1243
            _ => format!("could not run: {}", j.error),
1244
        };
1245
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
1246
    }
1247
    Ok(())
1248
}
1249
5dead1f4 Andy McCall ↶ 1250
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
1251
/// Settings, else from `hub.toml`.
1252
pub struct Limits {
1253
    /// For a job whose pipeline names none.
1254
    pub default_s: u64,
1255
    /// The most a pipeline may ask for.
1256
    pub max_s: u64,
1257
    /// Grace past a job's limit before the hub gives it up: a runner stops
1258
    /// a job at its limit and reports it within moments, so one still
1259
    /// running this long after is one the runner no longer reports (it was
1260
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
1261
    pub grace_ms: i64,
1262
}
1263
1264
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
1265
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
1266
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
1267
1268
pub async fn limits(app: &Shared) -> Limits {
1269
    let minutes = |key: &'static str| async move {
1270
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
1271
    };
1272
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
1273
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
1274
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
1275
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
1276
}
070a7b64 Andy McCall ↶ 1277
5dead1f4 Andy McCall ↶ 1278
/// End every job still running past its time limit plus the grace:
070a7b64 Andy McCall ↶ 1279
/// it is marked an error with the reason, the jobs that depended on it are
1280
/// skipped and its run settled, as when a runner reports an error.
1281
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
5dead1f4 Andy McCall ↶ 1282
    let limits = limits(app).await;
1283
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
070a7b64 Andy McCall ↶ 1284
    let n = lost.len();
1285
    for job in lost {
1286
        let error = format!(
1287
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
1288
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
1289
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
1290
        );
1291
        let changed =
1292
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1293
                .await?;
1294
        tracing::warn!("flow: job {} {error}", job.id);
1295
        report_changed(app, job.run_id, changed).await?;
1296
    }
1297
    Ok(n)
eef677c4 Andy McCall ↶ 1298
}
1299
1300
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
1301
/// it restarted since claiming them, or never received the claim. A
1302
/// runner claims one job at a time, so a job it holds is always listed.
1303
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
1304
    for job in fdb::running_on(&app.db, runner.id).await? {
1305
        if holding.contains(&job.id) {
1306
            continue;
1307
        }
1308
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
1309
        let changed =
1310
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1311
                .await?;
1312
        tracing::warn!("flow: job {} {error}", job.id);
1313
        report_changed(app, job.run_id, changed).await?;
1314
    }
1315
    Ok(())
348f8c4b Andy McCall ↶ 1316
}
1317
1318
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
1319
/// repository that runs Flow, the jobs of its default bookmark's pipeline
1320
/// with a `schedule` line due then. Returns how many runs started.
1321
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
1322
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
1323
    let mut started = 0;
1324
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
1325
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
1326
        let b = row.default_bookmark.clone();
1327
        let found = app
1328
            .repos
1329
            .read(&repo, move |r, _| {
1330
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
1331
                Ok(Some((st, r.store().get_state(&st)?.heads)))
1332
            })
1333
            .await;
1334
        let Ok(Some((state, heads))) = found else { continue };
1335
        if heads.is_empty() {
1336
            continue;
1337
        }
1338
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when)).await {
1339
            Ok(Some(_)) => started += 1,
1340
            Ok(None) => {}
1341
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
1342
        }
1343
    }
1344
    started
1345
}
1346
1347
/// The hub's clock as a [`When`](flow_core::pipeline::When).
1348
pub fn when_now() -> flow_core::pipeline::When {
1349
    let now = time::OffsetDateTime::now_utc();
1350
    flow_core::pipeline::When {
1351
        minute: now.minute() as u32,
1352
        hour: now.hour() as u32,
1353
        day: now.day() as u32,
1354
        month: u8::from(now.month()) as u32,
1355
        weekday: now.weekday().number_days_from_sunday() as u32,
1356
    }
1357
}
1358
1359
/// Start the scheduled runs due each minute, once a minute.
1360
pub async fn scheduler(app: Shared) {
1361
    let mut last = None;
1362
    loop {
1363
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1364
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
1365
        if last == Some(minute) {
1366
            continue;
1367
        }
1368
        last = Some(minute);
1369
        let n = run_due(&app, when_now()).await;
1370
        if n > 0 {
1371
            tracing::info!("flow: {n} scheduled run(s) started");
1372
        }
1373
    }
070a7b64 Andy McCall ↶ 1374
}
1375
1376
/// Look for lost jobs once a minute.
1377
pub async fn reaper(app: Shared) {
1378
    loop {
1379
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1380
        if let Err(e) = reap_lost_jobs(&app).await {
1381
            tracing::warn!("flow: looking for lost jobs: {e}");
1382
        }
1383
    }
cc19697f Andy McCall ↶ 1384
}
1385
1386
/// Check a runner's signed receipt for a job of `run` and keep it in the
1387
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
1388
/// name, or why it was not kept. It must be the job's receipt, for the
1389
/// run's state and repository, signed by the key the runner's token first
1390
/// signed with.
1391
pub async fn keep_receipt(
1392
    app: &Shared,
b6b7e4cf Andy McCall ↶ 1393
    runner: &db::runners::Runner,
cc19697f Andy McCall ↶ 1394
    row: &db::repos::RepoRow,
1395
    run: &fdb::Run,
1396
    receipt: &flow_core::Receipt,
1397
    signed_hex: &str,
1398
) -> Result<(String, String), String> {
1399
    use oxy_core::receipt::SignedReceipt;
1400
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
1401
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
1402
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
1403
    if sr.receipt.hash().0 != receipt.hash() {
1404
        return Err("it signs another receipt than the job's".into());
1405
    }
1406
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1407
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
1408
    if sr.state() != state {
1409
        return Err("it is for another state than the run's".into());
1410
    }
1411
    let key = sr.runner.hash().to_hex();
b6b7e4cf Andy McCall ↶ 1412
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
cc19697f Andy McCall ↶ 1413
    if bound != key {
1414
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
1415
    }
1416
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
1417
    let signer = sr.runner.name.clone();
1418
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
1419
    Ok((attest.to_hex(), signer))
42fa9554 Andy McCall ↶ 1420
}
1421
1422
/// A job's wall time, as the pages show it.
1423
pub fn duration(j: &fdb::Job) -> String {
1424
    match (j.started_ms, j.finished_ms) {
1425
        (Some(a), Some(b)) => human_ms(b - a),
1426
        (Some(a), None) => human_ms(db::now_ms() - a),
1427
        _ => "–".into(),
1428
    }
1429
}
1430
1431
pub fn human_ms(ms: i64) -> String {
1432
    let ms = ms.max(0);
1433
    if ms < 1000 {
1434
        format!("{ms} ms")
1435
    } else if ms < 60_000 {
1436
        format!("{:.1} s", ms as f64 / 1000.0)
1437
    } else {
1438
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
1439
    }
1440
}
bf3d0754 Andy McCall ↶ 1441
1442
/// Where a live run stands among the runners' work: what its viewers may
1443
/// know of it. Jobs of other repositories are named only when those are
1444
/// public; the rest are counted.
1445
#[derive(Clone, Debug, Default, PartialEq, Eq)]
1446
pub struct QueueInfo {
1447
    /// 1-based place of the run's first queued job among all queued jobs.
1448
    pub position: Option<usize>,
1449
    /// Jobs running now, and queued jobs ahead of the run's first one.
1450
    pub running: Vec<String>,
1451
    pub ahead: Vec<String>,
1452
    /// Of those, jobs in repositories the page may not name.
1453
    pub hidden: usize,
1454
}
1455
1456
impl QueueInfo {
1457
    /// "3rd in line" and the like.
1458
    pub fn place(&self) -> Option<String> {
1459
        self.position.map(|n| {
1460
            let suffix = match (n % 10, n % 100) {
1461
                (1, 11) | (2, 12) | (3, 13) => "th",
1462
                (1, _) => "st",
1463
                (2, _) => "nd",
1464
                (3, _) => "rd",
1465
                _ => "th",
1466
            };
1467
            format!("{n}{suffix} in line")
1468
        })
1469
    }
1470
}
1471
1472
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
1473
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
1474
    let mut info = QueueInfo::default();
1475
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
1476
    info.position = first.map(|i| i + 1);
1477
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
1478
    let mut queued_seen = 0;
1479
    for e in queue {
1480
        if e.run_id == run_id {
1481
            continue;
1482
        }
1483
        let ahead = match e.status.as_str() {
1484
            "running" => true,
1485
            _ => {
1486
                queued_seen += 1;
1487
                first.is_some_and(|f| queued_seen <= f)
1488
            }
1489
        };
1490
        if !ahead {
1491
            continue;
1492
        }
1493
        if e.repo_id != repo_id && e.visibility != "public" {
1494
            info.hidden += 1;
1495
        } else if e.status == "running" {
1496
            info.running.push(label(e));
1497
        } else {
1498
            info.ahead.push(label(e));
1499
        }
1500
    }
1501
    info
1502
}
1503
1504
#[cfg(test)]
1505
mod queue_tests {
1506
    use super::*;
1507
1508
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
1509
        fdb::QueueEntry {
1510
            job_id,
1511
            run_id,
1512
            repo_id,
1513
            owner: "o".into(),
1514
            name: format!("r{repo_id}"),
1515
            visibility: vis.into(),
1516
            run_number: run_id,
1517
            job: job.into(),
1518
            status: status.into(),
b66e70b7 Andy McCall ↶ 1519
            runner: String::new(),
c4c87f48 Andy McCall ↶ 1520
            spec: String::new(),
1521
            sandbox: String::new(),
bf3d0754 Andy McCall ↶ 1522
        }
1523
    }
1524
1525
    #[test]
1526
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
1527
        // Running first, then queued in claim order (as `fdb::queue` returns them).
1528
        let q = vec![
1529
            e(1, 10, 1, "public", "build", "running"),
1530
            e(2, 11, 2, "private", "test", "running"),
1531
            e(3, 10, 1, "public", "test", "queued"),
1532
            e(4, 12, 3, "private", "lint", "queued"),
1533
            e(5, 13, 4, "public", "build", "queued"),
1534
            e(6, 13, 4, "public", "test", "queued"),
1535
            e(7, 14, 5, "public", "late", "queued"),
1536
        ];
1537
        let info = queue_info(&q, 4, 13);
1538
        assert_eq!(info.position, Some(3));
1539
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
1540
        assert_eq!(info.running, ["o/r1 #10 build"]);
1541
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
1542
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
1543
        // A private repository's own jobs are named to its viewers.
1544
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
1545
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
1546
        // A run with nothing queued has no place.
1547
        assert_eq!(queue_info(&q, 1, 99).position, None);
1548
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
1549
        assert_eq!(
1550
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
1551
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
4d2e44d7 Andy McCall ↶ 1552
        );
1553
    }
1554
}
1555
1556
#[cfg(test)]
1557
mod placement_tests {
1558
    use super::*;
1559
1560
    #[test]
1561
    fn a_waiting_job_says_what_each_runner_lacks() {
1562
        use crate::db::runners::Runner;
1563
        let rootfs = "ab".repeat(32);
1564
        let job = fdb::Job {
1565
            id: 7,
1566
            runtime: "syscall".into(),
1567
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
1568
            spec: serde_json::json!({ "name": "build" }).to_string(),
1569
            ..Default::default()
1570
        };
1571
        let now = db::now_ms();
1572
        let runner = |id: i64, name: &str| Runner {
1573
            id,
1574
            name: name.into(),
1575
            state: "active".into(),
1576
            last_seen_ms: Some(now),
1577
            arch: "x86_64".into(),
1578
            tiers: "microvm,syscall".into(),
1579
            images: serde_json::to_string(&[&rootfs]).unwrap(),
1580
            memory_mib: Some(16384),
1581
            cpus: Some(8),
1582
            ..Default::default()
1583
        };
1584
        let archs = std::collections::HashMap::new();
1585
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| why_waiting(&job, "andy", 1, runners, &archs, declines, now);
1586
        assert_eq!(why(&[], &[]), "no runner is online");
1587
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
1588
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
1589
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
1590
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
1591
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
1592
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
1593
        assert_eq!(
1594
            why(&[microvm_only, small, other, offline], &[]),
1595
            "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"
1596
        );
1597
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
1598
        assert_eq!(
1599
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now),
1600
            "no runner can take it: narvi is riscv64; the job needs x86_64"
1601
        );
1602
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
1603
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
1604
        let d = fdb::DeclineRow {
1605
            job_id: 7,
1606
            runner_id: 1,
1607
            runner: "earth-1".into(),
1608
            reason: "only 512 MiB of memory is free here".into(),
1609
            ms: now,
1610
        };
1611
        assert_eq!(
1612
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
1613
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
bf3d0754 Andy McCall ↶ 1614
        );
4d2e44d7 Andy McCall ↶ 1615
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
1616
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
bf3d0754 Andy McCall ↶ 1617
    }
1618
}