OXYGENHUB
◆ v0.3.0 oxygenhub / crates / hub / src / flow.rs
34.7 KiB · Rust · last changed End the jobs a runner no longer holds at its next claim 1 month 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) {
9008bf6c Andy McCall ↶ 35
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
42fa9554 Andy McCall ↶ 36
        return;
37
    }
1edac4b6 Andy McCall ↶ 38
    if let Err(e) = start_run(app, row, bookmark, state, heads, actor, "push").await {
39
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
40
    }
41
}
42
43
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
44
/// moving it. Returns the new run's number.
45
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
46
    if !enabled(app, row) {
47
        return Err("OxygenFlow is not enabled for this repository".into());
48
    }
49
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
50
    let b = bookmark.to_string();
51
    let found = app
52
        .repos
53
        .read(&repo, move |r, _| {
54
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
55
            Ok(Some((st, r.store().get_state(&st)?.heads)))
56
        })
57
        .await
58
        .map_err(|e| e.to_string())?;
59
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
60
    if heads.is_empty() {
61
        return Err(format!("{bookmark} has no changes yet"));
62
    }
63
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
64
        .await?
65
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
66
}
67
4f692533 Andy McCall ↶ 68
/// "Run pipeline" on one committed change: the state whose only head is
69
/// `change`, whatever bookmarks hold it. Returns the new run's number.
70
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
71
    if !enabled(app, row) {
72
        return Err("OxygenFlow is not enabled for this repository".into());
73
    }
74
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
75
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
df15b747 Andy McCall ↶ 76
    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 ↶ 77
    if !known {
78
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
79
    }
80
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
81
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
82
        .await?
83
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
84
}
85
1edac4b6 Andy McCall ↶ 86
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
87
/// run whose checks replace the old ones. Returns the new run's number.
88
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
89
    if !enabled(app, row) {
90
        return Err("OxygenFlow is not enabled for this repository".into());
91
    }
92
    if run.live() {
93
        return Err(format!("run #{} is still {}", run.number, run.status));
94
    }
95
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
96
    if heads.is_empty() {
97
        return Err("the run names no changes".into());
98
    }
99
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &format!("rerun:{}", run.number))
100
        .await?
101
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
102
}
103
104
/// Start a run of the pipeline in the state `heads` make (whose state
105
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
106
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
107
/// whose access the run's subrepo reads are checked against. Returns the
108
/// run's number, or `None` when the state has no pipeline. The caller
109
/// checks that Flow is enabled.
110
pub async fn start_run(
111
    app: &Shared,
112
    row: &db::repos::RepoRow,
113
    bookmark: &str,
114
    state: &str,
115
    heads: &[ChangeHash],
116
    actor: &str,
117
    trigger: &str,
118
) -> Result<Option<i64>, String> {
119
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
42fa9554 Andy McCall ↶ 120
    let h = heads.to_vec();
121
    let text = app
122
        .repos
123
        .read(&repo, move |r, cache| {
124
            let loaded = cache.load(r, &h)?;
125
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
126
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
127
                _ => None,
128
            })
129
        })
1edac4b6 Andy McCall ↶ 130
        .await
131
        .map_err(|e| format!("reading the pipeline: {e}"))?;
132
    let Some(text) = text else { return Ok(None) };
42fa9554 Andy McCall ↶ 133
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
134
    hex.sort();
135
    let heads_s = hex.join("+");
1edac4b6 Andy McCall ↶ 136
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
137
    let id = match Pipeline::parse(&text) {
42fa9554 Andy McCall ↶ 138
        Err(problems) => {
139
            let msg = problems.to_string();
1edac4b6 Andy McCall ↶ 140
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
42fa9554 Andy McCall ↶ 141
        }
142
        Ok(p) => {
143
            let depths = p.depths();
144
            let jobs: Vec<fdb::NewJob> = p
145
                .order()
146
                .into_iter()
147
                .map(|j| {
148
                    let sb = &p.sandboxes[&j.sandbox];
149
                    fdb::NewJob {
150
                        name: j.name.clone(),
151
                        depth: depths[j.name.as_str()] as i64,
152
                        depends: j.depends_on.clone(),
153
                        spec: serde_json::to_string(j).unwrap_or_default(),
154
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
155
                        runtime: sb.runtime.as_str().into(),
156
                    }
157
                })
158
                .collect();
159
            let hash = hex::encode(p.hash());
1edac4b6 Andy McCall ↶ 160
            let id =
161
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
162
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
42fa9554 Andy McCall ↶ 163
                for j in &jobs {
a0d4bbfb Andy McCall ↶ 164
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
42fa9554 Andy McCall ↶ 165
                }
166
            }
167
            id
168
        }
169
    };
1edac4b6 Andy McCall ↶ 170
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
171
    Ok(Some(run.number))
42fa9554 Andy McCall ↶ 172
}
173
a0d4bbfb Andy McCall ↶ 174
/// Record a job's check; it links to the run's page by the run's number.
175
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
176
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
42fa9554 Andy McCall ↶ 177
    let name = format!("flow/{job}");
178
    let c = db::hooks::upsert_check(
179
        &app.db,
180
        row.id,
181
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
182
    )
183
    .await;
184
    if let Ok(c) = c {
185
        let api = crate::api::check_of(&c);
186
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
187
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
188
    }
189
}
190
191
/// The check status a job's status shows as.
192
fn check_status(job_status: &str) -> &'static str {
193
    match job_status {
194
        "queued" => "queued",
195
        "running" => "running",
196
        "passed" => "passed",
197
        "skipped" => "skipped",
198
        _ => "failed",
199
    }
200
}
201
b6b7e4cf Andy McCall ↶ 202
/// The runner a bearer token belongs to: a registered runner, or one
203
/// configured with a hub.toml token (tracked from its first request). A
204
/// disabled runner is refused.
205
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
42fa9554 Andy McCall ↶ 206
    let Some(t) = oxy_server::bearer_token(headers) else {
207
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
b6b7e4cf Andy McCall ↶ 208
    };
209
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
210
    let runner = match db::runners::by_token(&app.db, &hash).await? {
211
        Some(r) => r,
212
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
213
            db::runners::configured(&app.db, &hash, "runner").await?
214
        }
215
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
42fa9554 Andy McCall ↶ 216
    };
b6b7e4cf Andy McCall ↶ 217
    if runner.state == "disabled" {
218
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
42fa9554 Andy McCall ↶ 219
    }
b6b7e4cf Andy McCall ↶ 220
    Ok(runner)
42fa9554 Andy McCall ↶ 221
}
222
b6b7e4cf Andy McCall ↶ 223
/// `POST /api/v1/flow/runners/register`: a one-time registration token
224
/// from Admin → Runners buys a runner credential, binding the runner's
225
/// signing identity (hub spec §13.2).
226
pub async fn register(
227
    State(app): State<Shared>,
228
    Json(req): Json<flow_core::api::Register>,
229
) -> Result<Json<flow_core::api::Registered>, ApiError> {
230
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
231
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
232
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
233
    if name.is_empty() {
234
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
235
    }
236
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
237
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
238
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
239
    let runner =
240
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
241
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
242
        })?;
243
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
244
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
245
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
246
}
247
42fa9554 Andy McCall ↶ 248
/// `POST /api/v1/flow/claim`.
249
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 250
    let me = runner_auth(&app, &headers).await?;
251
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
252
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
eef677c4 Andy McCall ↶ 253
    if let Some(holding) = &req.holding {
254
        end_jobs_not_held(&app, &me, holding).await?;
255
    }
b6b7e4cf Andy McCall ↶ 256
    // A draining runner finishes what it has and takes nothing new.
257
    if me.state == "draining" {
258
        return Ok(StatusCode::NO_CONTENT.into_response());
259
    }
260
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
42fa9554 Andy McCall ↶ 261
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
b0612e7d Andy McCall ↶ 262
    let room = fdb::Room {
263
        memory_mib: req.free_memory_mib,
264
        cpus: req.free_cpus,
265
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
266
    };
c4c87f48 Andy McCall ↶ 267
    // The labels it reports now, or those it registered with.
268
    let labels: Vec<String> = match &req.info {
269
        Some(i) => i.labels.clone(),
270
        None => me.label_list().into_iter().map(String::from).collect(),
271
    };
272
    let Some(job) =
273
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
274
    else {
42fa9554 Andy McCall ↶ 275
        return Ok(StatusCode::NO_CONTENT.into_response());
276
    };
b6b7e4cf Andy McCall ↶ 277
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
42fa9554 Andy McCall ↶ 278
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
279
    let row =
280
        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 ↶ 281
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
42fa9554 Andy McCall ↶ 282
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
283
    let claim = Claim {
284
        job_id: job.id,
285
        run_id: run.id,
286
        repo: format!("{}/{}", row.owner, row.name),
287
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
288
        token: secret,
289
        heads: run.head_list().into_iter().map(String::from).collect(),
290
        pipeline_hash: run.pipeline_hash.clone(),
291
        job: serde_json::from_str(&job.spec).map_err(bad)?,
292
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
293
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
fd3db644 Andy McCall ↶ 294
        subrepos: vec![],
9a065bc0 Andy McCall ↶ 295
        state: run.state.clone(),
296
        bookmark: run.bookmark.clone(),
297
        run_number: run.number,
42fa9554 Andy McCall ↶ 298
    };
fd3db644 Andy McCall ↶ 299
    let mut claim = claim;
afd0be38 Andy McCall ↶ 300
    let (inputs, ids, left_out) = subrepo_inputs(&app, &row, &run).await;
fd3db644 Andy McCall ↶ 301
    if !ids.is_empty() {
302
        fdb::set_also_read(&app.db, job.id, &ids).await?;
303
    }
afd0be38 Andy McCall ↶ 304
    // Say at the top of the job's log what it will not have (oxygenhub#56),
305
    // rather than let it fail later on missing files.
306
    if !left_out.is_empty() {
307
        let records: Vec<flow_core::api::Record> =
308
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
309
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
310
    }
fd3db644 Andy McCall ↶ 311
    claim.subrepos = inputs;
42fa9554 Andy McCall ↶ 312
    Ok(Json(claim).into_response())
fd3db644 Andy McCall ↶ 313
}
314
25091e5c Andy McCall ↶ 315
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
316
async fn pins_in(
317
    app: &Shared,
318
    name: crate::repos::RepoName,
319
    heads: Vec<oxy_core::hash::ChangeHash>,
320
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
321
    app.repos
fd3db644 Andy McCall ↶ 322
        .read(&name, move |repo, cache| {
323
            let loaded = cache.load(repo, &heads)?;
324
            let mut out = Vec::new();
325
            for (path, f) in &loaded.snapshot.files {
326
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
327
                    continue;
328
                }
329
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
330
                    if let Some(b) = blobs.first() {
331
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
332
                            .read_blob(b)
333
                            .map_err(crate::repos::RepoError::Oxy)
334
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
335
                        {
336
                            out.push((path.clone(), id, heads));
337
                        }
338
                    }
339
                }
340
            }
341
            Ok(out)
342
        })
343
        .await
25091e5c Andy McCall ↶ 344
        .unwrap_or_default()
345
}
346
347
/// How deep subrepos of subrepos are followed.
348
const MAX_SUBREPO_DEPTH: usize = 3;
349
350
/// The Oxygen subrepos the run's state pins, and those their states pin in
351
/// turn (parents first, paths joined), that live on this hub and that the
352
/// run's pusher may read: what the job's workspace needs beside its own
afd0be38 Andy McCall ↶ 353
/// tree. A pin the hub cannot serve is left out, with what it pins, and
354
/// the third list says why, for the job's log.
355
async fn subrepo_inputs(
356
    app: &Shared,
357
    row: &db::repos::RepoRow,
358
    run: &fdb::Run,
359
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
360
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
25091e5c Andy McCall ↶ 361
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
fd3db644 Andy McCall ↶ 362
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
25091e5c Andy McCall ↶ 363
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
fd3db644 Andy McCall ↶ 364
    let mut inputs = Vec::new();
365
    let mut ids = Vec::new();
afd0be38 Andy McCall ↶ 366
    let mut left_out = Vec::new();
367
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
25091e5c Andy McCall ↶ 368
    // (path prefix, repository, heads, depth), parents before children.
369
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
370
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
371
        if depth == MAX_SUBREPO_DEPTH {
372
            continue;
373
        }
374
        for (path, id, pinned) in pins_in(app, name, heads).await {
375
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
afd0be38 Andy McCall ↶ 376
            let mut hosted = false;
25091e5c Andy McCall ↶ 377
            for other in &rows {
378
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
379
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
380
                if !matches {
381
                    continue;
382
                }
afd0be38 Andy McCall ↶ 383
                hosted = true;
25091e5c Andy McCall ↶ 384
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
385
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
386
                    inputs.push(flow_core::api::SubrepoInput {
387
                        path: path.clone(),
388
                        repo: format!("{}/{}", other.owner, other.name),
389
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
390
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
391
                    });
392
                    if !ids.contains(&other.id) {
393
                        ids.push(other.id);
394
                    }
395
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
afd0be38 Andy McCall ↶ 396
                } else {
397
                    left_out.push(format!(
398
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
399
                        other.owner, other.name
400
                    ));
25091e5c Andy McCall ↶ 401
                }
402
                break;
afd0be38 Andy McCall ↶ 403
            }
404
            if !hosted {
405
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
fd3db644 Andy McCall ↶ 406
            }
407
        }
408
    }
afd0be38 Andy McCall ↶ 409
    (inputs, ids, left_out)
42fa9554 Andy McCall ↶ 410
}
411
412
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
413
    match fdb::job(&app.db, id).await? {
414
        Some(j) if j.status == "running" => Ok(j),
415
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
416
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
417
    }
418
}
419
420
/// `POST /api/v1/flow/jobs/{id}/log`.
421
pub async fn log(
422
    State(app): State<Shared>,
423
    headers: HeaderMap,
424
    Path(id): Path<i64>,
425
    Json(mut batch): Json<LogBatch>,
426
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 427
    runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 428
    let job = running_job(&app, id).await?;
429
    for r in &mut batch.records {
430
        if r.text.len() > MAX_RECORD_TEXT {
431
            let mut cut = MAX_RECORD_TEXT;
432
            while !r.text.is_char_boundary(cut) {
433
                cut -= 1;
434
            }
435
            r.text.truncate(cut);
436
        }
437
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
438
            r.stream = "console".into();
439
        }
440
    }
441
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
442
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
443
        tracing::info!("flow: job {} reached the log limit", job.id);
444
    }
445
    Ok(StatusCode::NO_CONTENT)
446
}
447
a3c8288b Andy McCall ↶ 448
/// Largest artifact the hub keeps; the runner refuses larger ones first.
449
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
450
451
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
452
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
453
/// repository. Answers `{"blob"}`.
454
pub async fn artifact(
455
    State(app): State<Shared>,
456
    headers: HeaderMap,
457
    Path((id, name)): Path<(i64, String)>,
458
    body: axum::body::Bytes,
459
) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 460
    runner_auth(&app, &headers).await?;
a3c8288b Andy McCall ↶ 461
    let job = running_job(&app, id).await?;
462
    let spec: flow_core::Job =
463
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
464
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
465
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
466
    };
467
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
468
    let row =
469
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
470
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
471
    let content = blake3::hash(&body).to_hex().to_string();
472
    let size = body.len() as i64;
473
    let blob = app
474
        .repos
475
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
476
        .await
477
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
478
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
479
    fdb::add_artifact(&app.db, &a).await?;
480
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
481
}
482
42fa9554 Andy McCall ↶ 483
/// `POST /api/v1/flow/jobs/{id}/finish`.
484
pub async fn finish(
485
    State(app): State<Shared>,
486
    headers: HeaderMap,
487
    Path(id): Path<i64>,
488
    Json(f): Json<Finish>,
489
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 490
    let me = runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 491
    let job = running_job(&app, id).await?;
492
    let status = match (f.exit_code, &f.error) {
493
        (Some(0), None) => "passed",
494
        (Some(_), _) => "failed",
495
        (None, _) => "error",
496
    };
497
    let (receipt, receipt_hash) = match &f.receipt {
498
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
a3c8288b Andy McCall ↶ 499
            // The receipt names exactly the artifacts uploaded, in name order.
500
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
501
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
502
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
503
            }
42fa9554 Andy McCall ↶ 504
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
505
        }
506
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
507
        None => (String::new(), String::new()),
508
    };
509
    let error = f.error.clone().unwrap_or_default();
510
    let changed = fdb::finish(
511
        &app.db,
512
        job.id,
513
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
514
    )
515
    .await?;
516
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
517
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
cc19697f Andy McCall ↶ 518
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
b6b7e4cf Andy McCall ↶ 519
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
cc19697f Andy McCall ↶ 520
                    Ok((attest, signer)) => (attest, signer, String::new()),
521
                    Err(e) => {
522
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
523
                        (String::new(), String::new(), e)
524
                    }
525
                };
526
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
527
            }
42fa9554 Andy McCall ↶ 528
        }
529
    }
070a7b64 Andy McCall ↶ 530
    report_changed(&app, job.run_id, changed).await?;
42fa9554 Andy McCall ↶ 531
    Ok(StatusCode::NO_CONTENT)
070a7b64 Andy McCall ↶ 532
}
533
534
/// Report the checks of jobs whose status changed.
535
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
536
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
537
        return Ok(());
538
    };
539
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
540
        return Ok(());
541
    };
542
    for j in changed {
543
        let summary = match j.status.as_str() {
544
            "passed" => format!("passed in {}", duration(&j)),
545
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
546
            "skipped" => "skipped: a dependency did not pass".into(),
547
            _ => format!("could not run: {}", j.error),
548
        };
549
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
550
    }
551
    Ok(())
552
}
553
554
/// Grace past the job time limit before the hub gives a job up: a runner
555
/// stops a job at the limit and reports it within moments, so one still
556
/// running this long after is one the runner no longer reports (it was
557
/// restarted, or lost the hub while the job ended, oxygenhub#71).
558
const LOST_GRACE_MS: i64 = 5 * 60 * 1000;
559
560
/// End every job still running past the job time limit plus the grace:
561
/// it is marked an error with the reason, the jobs that depended on it are
562
/// skipped and its run settled, as when a runner reports an error.
563
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
564
    let limit_ms = app.config.flow.job_timeout_minutes.max(1) as i64 * 60_000 + LOST_GRACE_MS;
565
    let lost = fdb::running_since_before(&app.db, db::now_ms() - limit_ms).await?;
566
    let n = lost.len();
567
    for job in lost {
568
        let error = format!(
569
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
570
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
571
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
572
        );
573
        let changed =
574
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
575
                .await?;
576
        tracing::warn!("flow: job {} {error}", job.id);
577
        report_changed(app, job.run_id, changed).await?;
578
    }
579
    Ok(n)
eef677c4 Andy McCall ↶ 580
}
581
582
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
583
/// it restarted since claiming them, or never received the claim. A
584
/// runner claims one job at a time, so a job it holds is always listed.
585
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
586
    for job in fdb::running_on(&app.db, runner.id).await? {
587
        if holding.contains(&job.id) {
588
            continue;
589
        }
590
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
591
        let changed =
592
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
593
                .await?;
594
        tracing::warn!("flow: job {} {error}", job.id);
595
        report_changed(app, job.run_id, changed).await?;
596
    }
597
    Ok(())
070a7b64 Andy McCall ↶ 598
}
599
600
/// Look for lost jobs once a minute.
601
pub async fn reaper(app: Shared) {
602
    loop {
603
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
604
        if let Err(e) = reap_lost_jobs(&app).await {
605
            tracing::warn!("flow: looking for lost jobs: {e}");
606
        }
607
    }
cc19697f Andy McCall ↶ 608
}
609
610
/// Check a runner's signed receipt for a job of `run` and keep it in the
611
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
612
/// name, or why it was not kept. It must be the job's receipt, for the
613
/// run's state and repository, signed by the key the runner's token first
614
/// signed with.
615
pub async fn keep_receipt(
616
    app: &Shared,
b6b7e4cf Andy McCall ↶ 617
    runner: &db::runners::Runner,
cc19697f Andy McCall ↶ 618
    row: &db::repos::RepoRow,
619
    run: &fdb::Run,
620
    receipt: &flow_core::Receipt,
621
    signed_hex: &str,
622
) -> Result<(String, String), String> {
623
    use oxy_core::receipt::SignedReceipt;
624
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
625
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
626
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
627
    if sr.receipt.hash().0 != receipt.hash() {
628
        return Err("it signs another receipt than the job's".into());
629
    }
630
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
631
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
632
    if sr.state() != state {
633
        return Err("it is for another state than the run's".into());
634
    }
635
    let key = sr.runner.hash().to_hex();
b6b7e4cf Andy McCall ↶ 636
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
cc19697f Andy McCall ↶ 637
    if bound != key {
638
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
639
    }
640
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
641
    let signer = sr.runner.name.clone();
642
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
643
    Ok((attest.to_hex(), signer))
42fa9554 Andy McCall ↶ 644
}
645
646
/// A job's wall time, as the pages show it.
647
pub fn duration(j: &fdb::Job) -> String {
648
    match (j.started_ms, j.finished_ms) {
649
        (Some(a), Some(b)) => human_ms(b - a),
650
        (Some(a), None) => human_ms(db::now_ms() - a),
651
        _ => "–".into(),
652
    }
653
}
654
655
pub fn human_ms(ms: i64) -> String {
656
    let ms = ms.max(0);
657
    if ms < 1000 {
658
        format!("{ms} ms")
659
    } else if ms < 60_000 {
660
        format!("{:.1} s", ms as f64 / 1000.0)
661
    } else {
662
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
663
    }
664
}
bf3d0754 Andy McCall ↶ 665
666
/// Where a live run stands among the runners' work: what its viewers may
667
/// know of it. Jobs of other repositories are named only when those are
668
/// public; the rest are counted.
669
#[derive(Clone, Debug, Default, PartialEq, Eq)]
670
pub struct QueueInfo {
671
    /// 1-based place of the run's first queued job among all queued jobs.
672
    pub position: Option<usize>,
673
    /// Jobs running now, and queued jobs ahead of the run's first one.
674
    pub running: Vec<String>,
675
    pub ahead: Vec<String>,
676
    /// Of those, jobs in repositories the page may not name.
677
    pub hidden: usize,
678
}
679
680
impl QueueInfo {
681
    /// "3rd in line" and the like.
682
    pub fn place(&self) -> Option<String> {
683
        self.position.map(|n| {
684
            let suffix = match (n % 10, n % 100) {
685
                (1, 11) | (2, 12) | (3, 13) => "th",
686
                (1, _) => "st",
687
                (2, _) => "nd",
688
                (3, _) => "rd",
689
                _ => "th",
690
            };
691
            format!("{n}{suffix} in line")
692
        })
693
    }
694
}
695
696
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
697
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
698
    let mut info = QueueInfo::default();
699
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
700
    info.position = first.map(|i| i + 1);
701
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
702
    let mut queued_seen = 0;
703
    for e in queue {
704
        if e.run_id == run_id {
705
            continue;
706
        }
707
        let ahead = match e.status.as_str() {
708
            "running" => true,
709
            _ => {
710
                queued_seen += 1;
711
                first.is_some_and(|f| queued_seen <= f)
712
            }
713
        };
714
        if !ahead {
715
            continue;
716
        }
717
        if e.repo_id != repo_id && e.visibility != "public" {
718
            info.hidden += 1;
719
        } else if e.status == "running" {
720
            info.running.push(label(e));
721
        } else {
722
            info.ahead.push(label(e));
723
        }
724
    }
725
    info
726
}
727
728
#[cfg(test)]
729
mod queue_tests {
730
    use super::*;
731
732
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
733
        fdb::QueueEntry {
734
            job_id,
735
            run_id,
736
            repo_id,
737
            owner: "o".into(),
738
            name: format!("r{repo_id}"),
739
            visibility: vis.into(),
740
            run_number: run_id,
741
            job: job.into(),
742
            status: status.into(),
b66e70b7 Andy McCall ↶ 743
            runner: String::new(),
c4c87f48 Andy McCall ↶ 744
            spec: String::new(),
745
            sandbox: String::new(),
bf3d0754 Andy McCall ↶ 746
        }
747
    }
748
749
    #[test]
750
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
751
        // Running first, then queued in claim order (as `fdb::queue` returns them).
752
        let q = vec![
753
            e(1, 10, 1, "public", "build", "running"),
754
            e(2, 11, 2, "private", "test", "running"),
755
            e(3, 10, 1, "public", "test", "queued"),
756
            e(4, 12, 3, "private", "lint", "queued"),
757
            e(5, 13, 4, "public", "build", "queued"),
758
            e(6, 13, 4, "public", "test", "queued"),
759
            e(7, 14, 5, "public", "late", "queued"),
760
        ];
761
        let info = queue_info(&q, 4, 13);
762
        assert_eq!(info.position, Some(3));
763
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
764
        assert_eq!(info.running, ["o/r1 #10 build"]);
765
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
766
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
767
        // A private repository's own jobs are named to its viewers.
768
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
769
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
770
        // A run with nothing queued has no place.
771
        assert_eq!(queue_info(&q, 1, 99).position, None);
772
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
773
        assert_eq!(
774
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
775
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
776
        );
777
    }
778
}