OXYGENHUB
# a0d4bbfbedd1 oxygenhub / crates / hub / src / flow.rs
15.0 KiB · Rust · last changed Number Flow runs per repository 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
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
39
    let h = heads.to_vec();
40
    let text = app
41
        .repos
42
        .read(&repo, move |r, cache| {
43
            let loaded = cache.load(r, &h)?;
44
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
45
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
46
                _ => None,
47
            })
48
        })
49
        .await;
50
    let text = match text {
51
        Ok(Some(t)) => t,
52
        Ok(None) => return,
53
        Err(e) => {
54
            tracing::warn!("flow: reading the pipeline of {repo}: {e}");
55
            return;
56
        }
57
    };
58
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
59
    hex.sort();
60
    let heads_s = hex.join("+");
61
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, error: None };
62
    let result = match Pipeline::parse(&text) {
63
        Err(problems) => {
64
            let msg = problems.to_string();
65
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await
66
        }
67
        Ok(p) => {
68
            let depths = p.depths();
69
            let jobs: Vec<fdb::NewJob> = p
70
                .order()
71
                .into_iter()
72
                .map(|j| {
73
                    let sb = &p.sandboxes[&j.sandbox];
74
                    fdb::NewJob {
75
                        name: j.name.clone(),
76
                        depth: depths[j.name.as_str()] as i64,
77
                        depends: j.depends_on.clone(),
78
                        spec: serde_json::to_string(j).unwrap_or_default(),
79
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
80
                        runtime: sb.runtime.as_str().into(),
81
                    }
82
                })
83
                .collect();
84
            let hash = hex::encode(p.hash());
85
            let id = db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await;
86
            if let Ok(Some(run)) = match id {
87
                Ok(id) => fdb::run_by_id(&app.db, id).await,
88
                Err(_) => Ok(None),
89
            } {
90
                for j in &jobs {
91
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
92
                }
93
            }
94
            id
95
        }
96
    };
97
    if let Err(e) = result {
98
        tracing::warn!("flow: recording a run of {repo}: {e}");
99
    }
100
}
101
102
/// Record a job's check; it links to the run's page by the run's number.
103
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
104
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
105
    let name = format!("flow/{job}");
106
    let c = db::hooks::upsert_check(
107
        &app.db,
108
        row.id,
109
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
110
    )
111
    .await;
112
    if let Ok(c) = c {
113
        let api = crate::api::check_of(&c);
114
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
115
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
116
    }
117
}
118
119
/// The check status a job's status shows as.
120
fn check_status(job_status: &str) -> &'static str {
121
    match job_status {
122
        "queued" => "queued",
123
        "running" => "running",
124
        "passed" => "passed",
125
        "skipped" => "skipped",
126
        _ => "failed",
127
    }
128
}
129
130
fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
131
    let Some(t) = oxy_server::bearer_token(headers) else {
132
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
133
    };
134
    if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
135
        Ok(())
136
    } else {
137
        Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into()))
138
    }
139
}
140
141
/// `POST /api/v1/flow/claim`.
142
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
143
    runner_auth(&app, &headers)?;
144
    let runner: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
145
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
146
    let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus };
147
    let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room).await? else {
148
        return Ok(StatusCode::NO_CONTENT.into_response());
149
    };
150
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
151
    let row =
152
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
153
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
154
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
155
    let claim = Claim {
156
        job_id: job.id,
157
        run_id: run.id,
158
        repo: format!("{}/{}", row.owner, row.name),
159
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
160
        token: secret,
161
        heads: run.head_list().into_iter().map(String::from).collect(),
162
        pipeline_hash: run.pipeline_hash.clone(),
163
        job: serde_json::from_str(&job.spec).map_err(bad)?,
164
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
165
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
166
        subrepos: vec![],
167
    };
168
    let mut claim = claim;
169
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
170
    if !ids.is_empty() {
171
        fdb::set_also_read(&app.db, job.id, &ids).await?;
172
    }
173
    claim.subrepos = inputs;
174
    Ok(Json(claim).into_response())
175
}
176
177
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
178
async fn pins_in(
179
    app: &Shared,
180
    name: crate::repos::RepoName,
181
    heads: Vec<oxy_core::hash::ChangeHash>,
182
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
183
    app.repos
184
        .read(&name, move |repo, cache| {
185
            let loaded = cache.load(repo, &heads)?;
186
            let mut out = Vec::new();
187
            for (path, f) in &loaded.snapshot.files {
188
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
189
                    continue;
190
                }
191
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
192
                    if let Some(b) = blobs.first() {
193
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
194
                            .read_blob(b)
195
                            .map_err(crate::repos::RepoError::Oxy)
196
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
197
                        {
198
                            out.push((path.clone(), id, heads));
199
                        }
200
                    }
201
                }
202
            }
203
            Ok(out)
204
        })
205
        .await
206
        .unwrap_or_default()
207
}
208
209
/// How deep subrepos of subrepos are followed.
210
const MAX_SUBREPO_DEPTH: usize = 3;
211
212
/// The Oxygen subrepos the run's state pins, and those their states pin in
213
/// turn (parents first, paths joined), that live on this hub and that the
214
/// run's pusher may read: what the job's workspace needs beside its own
215
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
216
/// then fails on the missing files with an ordinary error.
217
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
218
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
219
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
220
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
221
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
222
    let mut inputs = Vec::new();
223
    let mut ids = Vec::new();
224
    // (path prefix, repository, heads, depth), parents before children.
225
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
226
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
227
        if depth == MAX_SUBREPO_DEPTH {
228
            continue;
229
        }
230
        for (path, id, pinned) in pins_in(app, name, heads).await {
231
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
232
            for other in &rows {
233
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
234
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
235
                if !matches {
236
                    continue;
237
                }
238
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
239
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
240
                    inputs.push(flow_core::api::SubrepoInput {
241
                        path: path.clone(),
242
                        repo: format!("{}/{}", other.owner, other.name),
243
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
244
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
245
                    });
246
                    if !ids.contains(&other.id) {
247
                        ids.push(other.id);
248
                    }
249
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
250
                }
251
                break;
252
            }
253
        }
254
    }
255
    (inputs, ids)
256
}
257
258
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
259
    match fdb::job(&app.db, id).await? {
260
        Some(j) if j.status == "running" => Ok(j),
261
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
262
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
263
    }
264
}
265
266
/// `POST /api/v1/flow/jobs/{id}/log`.
267
pub async fn log(
268
    State(app): State<Shared>,
269
    headers: HeaderMap,
270
    Path(id): Path<i64>,
271
    Json(mut batch): Json<LogBatch>,
272
) -> Result<StatusCode, ApiError> {
273
    runner_auth(&app, &headers)?;
274
    let job = running_job(&app, id).await?;
275
    for r in &mut batch.records {
276
        if r.text.len() > MAX_RECORD_TEXT {
277
            let mut cut = MAX_RECORD_TEXT;
278
            while !r.text.is_char_boundary(cut) {
279
                cut -= 1;
280
            }
281
            r.text.truncate(cut);
282
        }
283
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
284
            r.stream = "console".into();
285
        }
286
    }
287
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
288
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
289
        tracing::info!("flow: job {} reached the log limit", job.id);
290
    }
291
    Ok(StatusCode::NO_CONTENT)
292
}
293
294
/// `POST /api/v1/flow/jobs/{id}/finish`.
295
pub async fn finish(
296
    State(app): State<Shared>,
297
    headers: HeaderMap,
298
    Path(id): Path<i64>,
299
    Json(f): Json<Finish>,
300
) -> Result<StatusCode, ApiError> {
301
    runner_auth(&app, &headers)?;
302
    let job = running_job(&app, id).await?;
303
    let status = match (f.exit_code, &f.error) {
304
        (Some(0), None) => "passed",
305
        (Some(_), _) => "failed",
306
        (None, _) => "error",
307
    };
308
    let (receipt, receipt_hash) = match &f.receipt {
309
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
310
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
311
        }
312
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
313
        None => (String::new(), String::new()),
314
    };
315
    let error = f.error.clone().unwrap_or_default();
316
    let changed = fdb::finish(
317
        &app.db,
318
        job.id,
319
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
320
    )
321
    .await?;
322
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
323
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
324
            for j in changed {
325
                let summary = match j.status.as_str() {
326
                    "passed" => format!("passed in {}", duration(&j)),
327
                    "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
328
                    "skipped" => "skipped: a dependency did not pass".into(),
329
                    _ => format!("could not run: {}", j.error),
330
                };
331
                report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
332
            }
333
        }
334
    }
335
    Ok(StatusCode::NO_CONTENT)
336
}
337
338
/// A job's wall time, as the pages show it.
339
pub fn duration(j: &fdb::Job) -> String {
340
    match (j.started_ms, j.finished_ms) {
341
        (Some(a), Some(b)) => human_ms(b - a),
342
        (Some(a), None) => human_ms(db::now_ms() - a),
343
        _ => "–".into(),
344
    }
345
}
346
347
pub fn human_ms(ms: i64) -> String {
348
    let ms = ms.max(0);
349
    if ms < 1000 {
350
        format!("{ms} ms")
351
    } else if ms < 60_000 {
352
        format!("{:.1} s", ms as f64 / 1000.0)
353
    } else {
354
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
355
    }
356
}