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