OXYGENHUB
◆ v0.2.0 oxygenhub / crates / hub / src / flow.rs
31.6 KiB · Rust · last changed Flow: jobs that ask for runner labels go to runners with them (refs #41, part 3) 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?;
253
    // A draining runner finishes what it has and takes nothing new.
254
    if me.state == "draining" {
255
        return Ok(StatusCode::NO_CONTENT.into_response());
256
    }
257
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
42fa9554 Andy McCall ↶ 258
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
76bc5a05 Andy McCall ↶ 259
    let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus };
c4c87f48 Andy McCall ↶ 260
    // The labels it reports now, or those it registered with.
261
    let labels: Vec<String> = match &req.info {
262
        Some(i) => i.labels.clone(),
263
        None => me.label_list().into_iter().map(String::from).collect(),
264
    };
265
    let Some(job) =
266
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
267
    else {
42fa9554 Andy McCall ↶ 268
        return Ok(StatusCode::NO_CONTENT.into_response());
269
    };
b6b7e4cf Andy McCall ↶ 270
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
42fa9554 Andy McCall ↶ 271
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
272
    let row =
273
        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 ↶ 274
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
42fa9554 Andy McCall ↶ 275
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
276
    let claim = Claim {
277
        job_id: job.id,
278
        run_id: run.id,
279
        repo: format!("{}/{}", row.owner, row.name),
280
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
281
        token: secret,
282
        heads: run.head_list().into_iter().map(String::from).collect(),
283
        pipeline_hash: run.pipeline_hash.clone(),
284
        job: serde_json::from_str(&job.spec).map_err(bad)?,
285
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
286
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
fd3db644 Andy McCall ↶ 287
        subrepos: vec![],
9a065bc0 Andy McCall ↶ 288
        state: run.state.clone(),
289
        bookmark: run.bookmark.clone(),
290
        run_number: run.number,
42fa9554 Andy McCall ↶ 291
    };
fd3db644 Andy McCall ↶ 292
    let mut claim = claim;
afd0be38 Andy McCall ↶ 293
    let (inputs, ids, left_out) = subrepo_inputs(&app, &row, &run).await;
fd3db644 Andy McCall ↶ 294
    if !ids.is_empty() {
295
        fdb::set_also_read(&app.db, job.id, &ids).await?;
296
    }
afd0be38 Andy McCall ↶ 297
    // Say at the top of the job's log what it will not have (oxygenhub#56),
298
    // rather than let it fail later on missing files.
299
    if !left_out.is_empty() {
300
        let records: Vec<flow_core::api::Record> =
301
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
302
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
303
    }
fd3db644 Andy McCall ↶ 304
    claim.subrepos = inputs;
42fa9554 Andy McCall ↶ 305
    Ok(Json(claim).into_response())
fd3db644 Andy McCall ↶ 306
}
307
25091e5c Andy McCall ↶ 308
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
309
async fn pins_in(
310
    app: &Shared,
311
    name: crate::repos::RepoName,
312
    heads: Vec<oxy_core::hash::ChangeHash>,
313
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
314
    app.repos
fd3db644 Andy McCall ↶ 315
        .read(&name, move |repo, cache| {
316
            let loaded = cache.load(repo, &heads)?;
317
            let mut out = Vec::new();
318
            for (path, f) in &loaded.snapshot.files {
319
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
320
                    continue;
321
                }
322
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
323
                    if let Some(b) = blobs.first() {
324
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
325
                            .read_blob(b)
326
                            .map_err(crate::repos::RepoError::Oxy)
327
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
328
                        {
329
                            out.push((path.clone(), id, heads));
330
                        }
331
                    }
332
                }
333
            }
334
            Ok(out)
335
        })
336
        .await
25091e5c Andy McCall ↶ 337
        .unwrap_or_default()
338
}
339
340
/// How deep subrepos of subrepos are followed.
341
const MAX_SUBREPO_DEPTH: usize = 3;
342
343
/// The Oxygen subrepos the run's state pins, and those their states pin in
344
/// turn (parents first, paths joined), that live on this hub and that the
345
/// run's pusher may read: what the job's workspace needs beside its own
afd0be38 Andy McCall ↶ 346
/// tree. A pin the hub cannot serve is left out, with what it pins, and
347
/// the third list says why, for the job's log.
348
async fn subrepo_inputs(
349
    app: &Shared,
350
    row: &db::repos::RepoRow,
351
    run: &fdb::Run,
352
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
353
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
25091e5c Andy McCall ↶ 354
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
fd3db644 Andy McCall ↶ 355
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
25091e5c Andy McCall ↶ 356
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
fd3db644 Andy McCall ↶ 357
    let mut inputs = Vec::new();
358
    let mut ids = Vec::new();
afd0be38 Andy McCall ↶ 359
    let mut left_out = Vec::new();
360
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
25091e5c Andy McCall ↶ 361
    // (path prefix, repository, heads, depth), parents before children.
362
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
363
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
364
        if depth == MAX_SUBREPO_DEPTH {
365
            continue;
366
        }
367
        for (path, id, pinned) in pins_in(app, name, heads).await {
368
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
afd0be38 Andy McCall ↶ 369
            let mut hosted = false;
25091e5c Andy McCall ↶ 370
            for other in &rows {
371
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
372
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
373
                if !matches {
374
                    continue;
375
                }
afd0be38 Andy McCall ↶ 376
                hosted = true;
25091e5c Andy McCall ↶ 377
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
378
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
379
                    inputs.push(flow_core::api::SubrepoInput {
380
                        path: path.clone(),
381
                        repo: format!("{}/{}", other.owner, other.name),
382
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
383
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
384
                    });
385
                    if !ids.contains(&other.id) {
386
                        ids.push(other.id);
387
                    }
388
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
afd0be38 Andy McCall ↶ 389
                } else {
390
                    left_out.push(format!(
391
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
392
                        other.owner, other.name
393
                    ));
25091e5c Andy McCall ↶ 394
                }
395
                break;
afd0be38 Andy McCall ↶ 396
            }
397
            if !hosted {
398
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
fd3db644 Andy McCall ↶ 399
            }
400
        }
401
    }
afd0be38 Andy McCall ↶ 402
    (inputs, ids, left_out)
42fa9554 Andy McCall ↶ 403
}
404
405
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
406
    match fdb::job(&app.db, id).await? {
407
        Some(j) if j.status == "running" => Ok(j),
408
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
409
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
410
    }
411
}
412
413
/// `POST /api/v1/flow/jobs/{id}/log`.
414
pub async fn log(
415
    State(app): State<Shared>,
416
    headers: HeaderMap,
417
    Path(id): Path<i64>,
418
    Json(mut batch): Json<LogBatch>,
419
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 420
    runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 421
    let job = running_job(&app, id).await?;
422
    for r in &mut batch.records {
423
        if r.text.len() > MAX_RECORD_TEXT {
424
            let mut cut = MAX_RECORD_TEXT;
425
            while !r.text.is_char_boundary(cut) {
426
                cut -= 1;
427
            }
428
            r.text.truncate(cut);
429
        }
430
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
431
            r.stream = "console".into();
432
        }
433
    }
434
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
435
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
436
        tracing::info!("flow: job {} reached the log limit", job.id);
437
    }
438
    Ok(StatusCode::NO_CONTENT)
439
}
440
a3c8288b Andy McCall ↶ 441
/// Largest artifact the hub keeps; the runner refuses larger ones first.
442
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
443
444
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
445
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
446
/// repository. Answers `{"blob"}`.
447
pub async fn artifact(
448
    State(app): State<Shared>,
449
    headers: HeaderMap,
450
    Path((id, name)): Path<(i64, String)>,
451
    body: axum::body::Bytes,
452
) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 453
    runner_auth(&app, &headers).await?;
a3c8288b Andy McCall ↶ 454
    let job = running_job(&app, id).await?;
455
    let spec: flow_core::Job =
456
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
457
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
458
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
459
    };
460
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
461
    let row =
462
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
463
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
464
    let content = blake3::hash(&body).to_hex().to_string();
465
    let size = body.len() as i64;
466
    let blob = app
467
        .repos
468
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
469
        .await
470
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
471
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
472
    fdb::add_artifact(&app.db, &a).await?;
473
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
474
}
475
42fa9554 Andy McCall ↶ 476
/// `POST /api/v1/flow/jobs/{id}/finish`.
477
pub async fn finish(
478
    State(app): State<Shared>,
479
    headers: HeaderMap,
480
    Path(id): Path<i64>,
481
    Json(f): Json<Finish>,
482
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 483
    let me = runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 484
    let job = running_job(&app, id).await?;
485
    let status = match (f.exit_code, &f.error) {
486
        (Some(0), None) => "passed",
487
        (Some(_), _) => "failed",
488
        (None, _) => "error",
489
    };
490
    let (receipt, receipt_hash) = match &f.receipt {
491
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
a3c8288b Andy McCall ↶ 492
            // The receipt names exactly the artifacts uploaded, in name order.
493
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
494
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
495
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
496
            }
42fa9554 Andy McCall ↶ 497
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
498
        }
499
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
500
        None => (String::new(), String::new()),
501
    };
502
    let error = f.error.clone().unwrap_or_default();
503
    let changed = fdb::finish(
504
        &app.db,
505
        job.id,
506
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
507
    )
508
    .await?;
509
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
510
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
cc19697f Andy McCall ↶ 511
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
b6b7e4cf Andy McCall ↶ 512
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
cc19697f Andy McCall ↶ 513
                    Ok((attest, signer)) => (attest, signer, String::new()),
514
                    Err(e) => {
515
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
516
                        (String::new(), String::new(), e)
517
                    }
518
                };
519
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
520
            }
42fa9554 Andy McCall ↶ 521
            for j in changed {
522
                let summary = match j.status.as_str() {
523
                    "passed" => format!("passed in {}", duration(&j)),
524
                    "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
525
                    "skipped" => "skipped: a dependency did not pass".into(),
526
                    _ => format!("could not run: {}", j.error),
527
                };
a0d4bbfb Andy McCall ↶ 528
                report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
42fa9554 Andy McCall ↶ 529
            }
530
        }
531
    }
532
    Ok(StatusCode::NO_CONTENT)
cc19697f Andy McCall ↶ 533
}
534
535
/// Check a runner's signed receipt for a job of `run` and keep it in the
536
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
537
/// name, or why it was not kept. It must be the job's receipt, for the
538
/// run's state and repository, signed by the key the runner's token first
539
/// signed with.
540
pub async fn keep_receipt(
541
    app: &Shared,
b6b7e4cf Andy McCall ↶ 542
    runner: &db::runners::Runner,
cc19697f Andy McCall ↶ 543
    row: &db::repos::RepoRow,
544
    run: &fdb::Run,
545
    receipt: &flow_core::Receipt,
546
    signed_hex: &str,
547
) -> Result<(String, String), String> {
548
    use oxy_core::receipt::SignedReceipt;
549
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
550
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
551
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
552
    if sr.receipt.hash().0 != receipt.hash() {
553
        return Err("it signs another receipt than the job's".into());
554
    }
555
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
556
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
557
    if sr.state() != state {
558
        return Err("it is for another state than the run's".into());
559
    }
560
    let key = sr.runner.hash().to_hex();
b6b7e4cf Andy McCall ↶ 561
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
cc19697f Andy McCall ↶ 562
    if bound != key {
563
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
564
    }
565
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
566
    let signer = sr.runner.name.clone();
567
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
568
    Ok((attest.to_hex(), signer))
42fa9554 Andy McCall ↶ 569
}
570
571
/// A job's wall time, as the pages show it.
572
pub fn duration(j: &fdb::Job) -> String {
573
    match (j.started_ms, j.finished_ms) {
574
        (Some(a), Some(b)) => human_ms(b - a),
575
        (Some(a), None) => human_ms(db::now_ms() - a),
576
        _ => "–".into(),
577
    }
578
}
579
580
pub fn human_ms(ms: i64) -> String {
581
    let ms = ms.max(0);
582
    if ms < 1000 {
583
        format!("{ms} ms")
584
    } else if ms < 60_000 {
585
        format!("{:.1} s", ms as f64 / 1000.0)
586
    } else {
587
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
588
    }
589
}
bf3d0754 Andy McCall ↶ 590
591
/// Where a live run stands among the runners' work: what its viewers may
592
/// know of it. Jobs of other repositories are named only when those are
593
/// public; the rest are counted.
594
#[derive(Clone, Debug, Default, PartialEq, Eq)]
595
pub struct QueueInfo {
596
    /// 1-based place of the run's first queued job among all queued jobs.
597
    pub position: Option<usize>,
598
    /// Jobs running now, and queued jobs ahead of the run's first one.
599
    pub running: Vec<String>,
600
    pub ahead: Vec<String>,
601
    /// Of those, jobs in repositories the page may not name.
602
    pub hidden: usize,
603
}
604
605
impl QueueInfo {
606
    /// "3rd in line" and the like.
607
    pub fn place(&self) -> Option<String> {
608
        self.position.map(|n| {
609
            let suffix = match (n % 10, n % 100) {
610
                (1, 11) | (2, 12) | (3, 13) => "th",
611
                (1, _) => "st",
612
                (2, _) => "nd",
613
                (3, _) => "rd",
614
                _ => "th",
615
            };
616
            format!("{n}{suffix} in line")
617
        })
618
    }
619
}
620
621
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
622
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
623
    let mut info = QueueInfo::default();
624
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
625
    info.position = first.map(|i| i + 1);
626
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
627
    let mut queued_seen = 0;
628
    for e in queue {
629
        if e.run_id == run_id {
630
            continue;
631
        }
632
        let ahead = match e.status.as_str() {
633
            "running" => true,
634
            _ => {
635
                queued_seen += 1;
636
                first.is_some_and(|f| queued_seen <= f)
637
            }
638
        };
639
        if !ahead {
640
            continue;
641
        }
642
        if e.repo_id != repo_id && e.visibility != "public" {
643
            info.hidden += 1;
644
        } else if e.status == "running" {
645
            info.running.push(label(e));
646
        } else {
647
            info.ahead.push(label(e));
648
        }
649
    }
650
    info
651
}
652
653
#[cfg(test)]
654
mod queue_tests {
655
    use super::*;
656
657
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
658
        fdb::QueueEntry {
659
            job_id,
660
            run_id,
661
            repo_id,
662
            owner: "o".into(),
663
            name: format!("r{repo_id}"),
664
            visibility: vis.into(),
665
            run_number: run_id,
666
            job: job.into(),
667
            status: status.into(),
b66e70b7 Andy McCall ↶ 668
            runner: String::new(),
c4c87f48 Andy McCall ↶ 669
            spec: String::new(),
670
            sandbox: String::new(),
bf3d0754 Andy McCall ↶ 671
        }
672
    }
673
674
    #[test]
675
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
676
        // Running first, then queued in claim order (as `fdb::queue` returns them).
677
        let q = vec![
678
            e(1, 10, 1, "public", "build", "running"),
679
            e(2, 11, 2, "private", "test", "running"),
680
            e(3, 10, 1, "public", "test", "queued"),
681
            e(4, 12, 3, "private", "lint", "queued"),
682
            e(5, 13, 4, "public", "build", "queued"),
683
            e(6, 13, 4, "public", "test", "queued"),
684
            e(7, 14, 5, "public", "late", "queued"),
685
        ];
686
        let info = queue_info(&q, 4, 13);
687
        assert_eq!(info.position, Some(3));
688
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
689
        assert_eq!(info.running, ["o/r1 #10 build"]);
690
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
691
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
692
        // A private repository's own jobs are named to its viewers.
693
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
694
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
695
        // A run with nothing queued has no place.
696
        assert_eq!(queue_info(&q, 1, 99).position, None);
697
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
698
        assert_eq!(
699
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
700
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
701
        );
702
    }
703
}