OXYGENHUB
# 67b24adbf35a oxygenhub / crates / hub / src / flow.rs
10.2 KiB · Rust · last changed OxygenFlow runs: pushes start pipeline runs, a runner API with job tokens, jobs as checks, and a Flow tab with the job graph and live logs 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
/// A bookmark moved by a push: start a run when the repository is allowed
23
/// and the new state has a pipeline.
24
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
25
    if bookmark.starts_with("tags/") || heads.is_empty() || !app.config.flow.allows(&row.owner, &row.name) {
26
        return;
27
    }
28
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
29
    let h = heads.to_vec();
30
    let text = app
31
        .repos
32
        .read(&repo, move |r, cache| {
33
            let loaded = cache.load(r, &h)?;
34
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
35
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
36
                _ => None,
37
            })
38
        })
39
        .await;
40
    let text = match text {
41
        Ok(Some(t)) => t,
42
        Ok(None) => return,
43
        Err(e) => {
44
            tracing::warn!("flow: reading the pipeline of {repo}: {e}");
45
            return;
46
        }
47
    };
48
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
49
    hex.sort();
50
    let heads_s = hex.join("+");
51
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, error: None };
52
    let result = match Pipeline::parse(&text) {
53
        Err(problems) => {
54
            let msg = problems.to_string();
55
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await
56
        }
57
        Ok(p) => {
58
            let depths = p.depths();
59
            let jobs: Vec<fdb::NewJob> = p
60
                .order()
61
                .into_iter()
62
                .map(|j| {
63
                    let sb = &p.sandboxes[&j.sandbox];
64
                    fdb::NewJob {
65
                        name: j.name.clone(),
66
                        depth: depths[j.name.as_str()] as i64,
67
                        depends: j.depends_on.clone(),
68
                        spec: serde_json::to_string(j).unwrap_or_default(),
69
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
70
                        runtime: sb.runtime.as_str().into(),
71
                    }
72
                })
73
                .collect();
74
            let hash = hex::encode(p.hash());
75
            let id = db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await;
76
            if let Ok(id) = id {
77
                for j in &jobs {
78
                    report_check(app, row, state, id, &j.name, "queued", "waiting for a runner").await;
79
                }
80
            }
81
            id
82
        }
83
    };
84
    if let Err(e) = result {
85
        tracing::warn!("flow: recording a run of {repo}: {e}");
86
    }
87
}
88
89
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_id: i64, job: &str, status: &str, summary: &str) {
90
    let url = format!("{}/{}/{}/flow/{run_id}", app.config.base_url(), row.owner, row.name);
91
    let name = format!("flow/{job}");
92
    let c = db::hooks::upsert_check(
93
        &app.db,
94
        row.id,
95
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
96
    )
97
    .await;
98
    if let Ok(c) = c {
99
        let api = crate::api::check_of(&c);
100
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
101
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
102
    }
103
}
104
105
/// The check status a job's status shows as.
106
fn check_status(job_status: &str) -> &'static str {
107
    match job_status {
108
        "queued" => "queued",
109
        "running" => "running",
110
        "passed" => "passed",
111
        "skipped" => "skipped",
112
        _ => "failed",
113
    }
114
}
115
116
fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
117
    let Some(t) = oxy_server::bearer_token(headers) else {
118
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
119
    };
120
    if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
121
        Ok(())
122
    } else {
123
        Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into()))
124
    }
125
}
126
127
/// `POST /api/v1/flow/claim`.
128
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
129
    runner_auth(&app, &headers)?;
130
    let runner: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
131
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
132
    let Some(job) = fdb::claim(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret)).await? else {
133
        return Ok(StatusCode::NO_CONTENT.into_response());
134
    };
135
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
136
    let row =
137
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
138
    report_check(&app, &row, &run.state, run.id, &job.name, "running", &format!("running on {runner}")).await;
139
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
140
    let claim = Claim {
141
        job_id: job.id,
142
        run_id: run.id,
143
        repo: format!("{}/{}", row.owner, row.name),
144
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
145
        token: secret,
146
        heads: run.head_list().into_iter().map(String::from).collect(),
147
        pipeline_hash: run.pipeline_hash.clone(),
148
        job: serde_json::from_str(&job.spec).map_err(bad)?,
149
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
150
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
151
    };
152
    Ok(Json(claim).into_response())
153
}
154
155
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
156
    match fdb::job(&app.db, id).await? {
157
        Some(j) if j.status == "running" => Ok(j),
158
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
159
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
160
    }
161
}
162
163
/// `POST /api/v1/flow/jobs/{id}/log`.
164
pub async fn log(
165
    State(app): State<Shared>,
166
    headers: HeaderMap,
167
    Path(id): Path<i64>,
168
    Json(mut batch): Json<LogBatch>,
169
) -> Result<StatusCode, ApiError> {
170
    runner_auth(&app, &headers)?;
171
    let job = running_job(&app, id).await?;
172
    for r in &mut batch.records {
173
        if r.text.len() > MAX_RECORD_TEXT {
174
            let mut cut = MAX_RECORD_TEXT;
175
            while !r.text.is_char_boundary(cut) {
176
                cut -= 1;
177
            }
178
            r.text.truncate(cut);
179
        }
180
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
181
            r.stream = "console".into();
182
        }
183
    }
184
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
185
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
186
        tracing::info!("flow: job {} reached the log limit", job.id);
187
    }
188
    Ok(StatusCode::NO_CONTENT)
189
}
190
191
/// `POST /api/v1/flow/jobs/{id}/finish`.
192
pub async fn finish(
193
    State(app): State<Shared>,
194
    headers: HeaderMap,
195
    Path(id): Path<i64>,
196
    Json(f): Json<Finish>,
197
) -> Result<StatusCode, ApiError> {
198
    runner_auth(&app, &headers)?;
199
    let job = running_job(&app, id).await?;
200
    let status = match (f.exit_code, &f.error) {
201
        (Some(0), None) => "passed",
202
        (Some(_), _) => "failed",
203
        (None, _) => "error",
204
    };
205
    let (receipt, receipt_hash) = match &f.receipt {
206
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
207
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
208
        }
209
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
210
        None => (String::new(), String::new()),
211
    };
212
    let error = f.error.clone().unwrap_or_default();
213
    let changed = fdb::finish(
214
        &app.db,
215
        job.id,
216
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
217
    )
218
    .await?;
219
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
220
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
221
            for j in changed {
222
                let summary = match j.status.as_str() {
223
                    "passed" => format!("passed in {}", duration(&j)),
224
                    "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
225
                    "skipped" => "skipped: a dependency did not pass".into(),
226
                    _ => format!("could not run: {}", j.error),
227
                };
228
                report_check(&app, &row, &run.state, run.id, &j.name, check_status(&j.status), &summary).await;
229
            }
230
        }
231
    }
232
    Ok(StatusCode::NO_CONTENT)
233
}
234
235
/// A job's wall time, as the pages show it.
236
pub fn duration(j: &fdb::Job) -> String {
237
    match (j.started_ms, j.finished_ms) {
238
        (Some(a), Some(b)) => human_ms(b - a),
239
        (Some(a), None) => human_ms(db::now_ms() - a),
240
        _ => "–".into(),
241
    }
242
}
243
244
pub fn human_ms(ms: i64) -> String {
245
    let ms = ms.max(0);
246
    if ms < 1000 {
247
        format!("{ms} ms")
248
    } else if ms < 60_000 {
249
        format!("{:.1} s", ms as f64 / 1000.0)
250
    } else {
251
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
252
    }
253
}