OXYGENHUB
# d8b86dc23809 oxygenhub / crates / hub / src / flow.rs
17.7 KiB · Rust · last changed Claims tell the runner the run's state, bookmark and number 1 month ago source blame history raw
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
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
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) {
35
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
36
        return;
37
    }
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")?;
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
        })
112
        .await
113
        .map_err(|e| format!("reading the pipeline: {e}"))?;
114
    let Some(text) = text else { return Ok(None) };
115
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
116
    hex.sort();
117
    let heads_s = hex.join("+");
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) {
120
        Err(problems) => {
121
            let msg = problems.to_string();
122
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
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());
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 {
145
                for j in &jobs {
146
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
147
                }
148
            }
149
            id
150
        }
151
    };
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))
154
}
155
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);
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
184
fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
185
    let Some(t) = oxy_server::bearer_token(headers) else {
186
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
187
    };
188
    if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
189
        Ok(())
190
    } else {
191
        Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into()))
192
    }
193
}
194
195
/// `POST /api/v1/flow/claim`.
196
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
197
    runner_auth(&app, &headers)?;
198
    let runner: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
199
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
200
    let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus };
201
    let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room).await? else {
202
        return Ok(StatusCode::NO_CONTENT.into_response());
203
    };
204
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
205
    let row =
206
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
207
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
208
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
209
    let claim = Claim {
210
        job_id: job.id,
211
        run_id: run.id,
212
        repo: format!("{}/{}", row.owner, row.name),
213
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
214
        token: secret,
215
        heads: run.head_list().into_iter().map(String::from).collect(),
216
        pipeline_hash: run.pipeline_hash.clone(),
217
        job: serde_json::from_str(&job.spec).map_err(bad)?,
218
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
219
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
220
        subrepos: vec![],
221
        state: run.state.clone(),
222
        bookmark: run.bookmark.clone(),
223
        run_number: run.number,
224
    };
225
    let mut claim = claim;
226
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
227
    if !ids.is_empty() {
228
        fdb::set_also_read(&app.db, job.id, &ids).await?;
229
    }
230
    claim.subrepos = inputs;
231
    Ok(Json(claim).into_response())
232
}
233
234
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
235
async fn pins_in(
236
    app: &Shared,
237
    name: crate::repos::RepoName,
238
    heads: Vec<oxy_core::hash::ChangeHash>,
239
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
240
    app.repos
241
        .read(&name, move |repo, cache| {
242
            let loaded = cache.load(repo, &heads)?;
243
            let mut out = Vec::new();
244
            for (path, f) in &loaded.snapshot.files {
245
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
246
                    continue;
247
                }
248
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
249
                    if let Some(b) = blobs.first() {
250
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
251
                            .read_blob(b)
252
                            .map_err(crate::repos::RepoError::Oxy)
253
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
254
                        {
255
                            out.push((path.clone(), id, heads));
256
                        }
257
                    }
258
                }
259
            }
260
            Ok(out)
261
        })
262
        .await
263
        .unwrap_or_default()
264
}
265
266
/// How deep subrepos of subrepos are followed.
267
const MAX_SUBREPO_DEPTH: usize = 3;
268
269
/// The Oxygen subrepos the run's state pins, and those their states pin in
270
/// turn (parents first, paths joined), that live on this hub and that the
271
/// run's pusher may read: what the job's workspace needs beside its own
272
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
273
/// then fails on the missing files with an ordinary error.
274
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
275
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
276
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
277
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
278
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
279
    let mut inputs = Vec::new();
280
    let mut ids = Vec::new();
281
    // (path prefix, repository, heads, depth), parents before children.
282
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
283
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
284
        if depth == MAX_SUBREPO_DEPTH {
285
            continue;
286
        }
287
        for (path, id, pinned) in pins_in(app, name, heads).await {
288
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
289
            for other in &rows {
290
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
291
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
292
                if !matches {
293
                    continue;
294
                }
295
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
296
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
297
                    inputs.push(flow_core::api::SubrepoInput {
298
                        path: path.clone(),
299
                        repo: format!("{}/{}", other.owner, other.name),
300
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
301
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
302
                    });
303
                    if !ids.contains(&other.id) {
304
                        ids.push(other.id);
305
                    }
306
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
307
                }
308
                break;
309
            }
310
        }
311
    }
312
    (inputs, ids)
313
}
314
315
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
316
    match fdb::job(&app.db, id).await? {
317
        Some(j) if j.status == "running" => Ok(j),
318
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
319
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
320
    }
321
}
322
323
/// `POST /api/v1/flow/jobs/{id}/log`.
324
pub async fn log(
325
    State(app): State<Shared>,
326
    headers: HeaderMap,
327
    Path(id): Path<i64>,
328
    Json(mut batch): Json<LogBatch>,
329
) -> Result<StatusCode, ApiError> {
330
    runner_auth(&app, &headers)?;
331
    let job = running_job(&app, id).await?;
332
    for r in &mut batch.records {
333
        if r.text.len() > MAX_RECORD_TEXT {
334
            let mut cut = MAX_RECORD_TEXT;
335
            while !r.text.is_char_boundary(cut) {
336
                cut -= 1;
337
            }
338
            r.text.truncate(cut);
339
        }
340
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
341
            r.stream = "console".into();
342
        }
343
    }
344
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
345
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
346
        tracing::info!("flow: job {} reached the log limit", job.id);
347
    }
348
    Ok(StatusCode::NO_CONTENT)
349
}
350
351
/// `POST /api/v1/flow/jobs/{id}/finish`.
352
pub async fn finish(
353
    State(app): State<Shared>,
354
    headers: HeaderMap,
355
    Path(id): Path<i64>,
356
    Json(f): Json<Finish>,
357
) -> Result<StatusCode, ApiError> {
358
    runner_auth(&app, &headers)?;
359
    let job = running_job(&app, id).await?;
360
    let status = match (f.exit_code, &f.error) {
361
        (Some(0), None) => "passed",
362
        (Some(_), _) => "failed",
363
        (None, _) => "error",
364
    };
365
    let (receipt, receipt_hash) = match &f.receipt {
366
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
367
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
368
        }
369
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
370
        None => (String::new(), String::new()),
371
    };
372
    let error = f.error.clone().unwrap_or_default();
373
    let changed = fdb::finish(
374
        &app.db,
375
        job.id,
376
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
377
    )
378
    .await?;
379
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
380
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
381
            for j in changed {
382
                let summary = match j.status.as_str() {
383
                    "passed" => format!("passed in {}", duration(&j)),
384
                    "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
385
                    "skipped" => "skipped: a dependency did not pass".into(),
386
                    _ => format!("could not run: {}", j.error),
387
                };
388
                report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
389
            }
390
        }
391
    }
392
    Ok(StatusCode::NO_CONTENT)
393
}
394
395
/// A job's wall time, as the pages show it.
396
pub fn duration(j: &fdb::Job) -> String {
397
    match (j.started_ms, j.finished_ms) {
398
        (Some(a), Some(b)) => human_ms(b - a),
399
        (Some(a), None) => human_ms(db::now_ms() - a),
400
        _ => "–".into(),
401
    }
402
}
403
404
pub fn human_ms(ms: i64) -> String {
405
    let ms = ms.max(0);
406
    if ms < 1000 {
407
        format!("{ms} ms")
408
    } else if ms < 60_000 {
409
        format!("{:.1} s", ms as f64 / 1000.0)
410
    } else {
411
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
412
    }
413
}