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