| 1 | |
|
| 2 | |
|
| 3 | |
|
| 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 | |
|
| 18 | | pub const JOB_TOKEN_PREFIX: &str = "flowjob_"; |
| 19 | |
|
| 20 | | pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000; |
| 21 | |
|
| 22 | |
|
| 23 | |
|
| 24 | 806× | pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool { |
| 25 | | |
| 26 | 806× | if row.is_drop() { |
| 27 | 1× | return false; |
| 28 | 805× | } |
| 29 | 805× | match row.flow.as_str() { |
| 30 | | |
| 31 | 805× | "on" | "own" => true, |
| 32 | 792× | "off" => false, |
| 33 | 784× | _ => app.config.flow.allows(&row.owner, &row.name), |
| 34 | | } |
| 35 | 806× | } |
| 36 | |
|
| 37 | |
|
| 38 | |
|
| 39 | 286× | pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) { |
| 40 | 286× | if heads.is_empty() || !enabled(app, row) { |
| 41 | 133× | return; |
| 42 | 153× | } |
| 43 | | |
| 44 | 153× | let started = match bookmark.strip_prefix("tags/") { |
| 45 | 6× | Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await, |
| 46 | | None => { |
| 47 | | |
| 48 | | |
| 49 | 147× | if let Some((change, marker)) = skip_asked(app, row, bookmark, heads).await { |
| 50 | 1× | skip_run(app, row, bookmark, state, heads, actor, &change, marker).await; |
| 51 | 1× | return; |
| 52 | 146× | } |
| 53 | | |
| 54 | 146× | let touched = touched_since_last_run(app, row, bookmark, heads).await; |
| 55 | 146× | start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await |
| 56 | | } |
| 57 | | }; |
| 58 | 152× | if let Err(e) = started { |
| 59 | 0× | tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name); |
| 60 | 152× | } |
| 61 | 286× | } |
| 62 | |
|
| 63 | |
|
| 64 | |
|
| 65 | |
|
| 66 | 301× | fn reusable(job: &flow_core::Job) -> bool { |
| 67 | 301× | job.approval.is_none() && job.environment.is_none() && job.secrets.is_empty() && job.identities.is_empty() |
| 68 | 301× | } |
| 69 | |
|
| 70 | |
|
| 71 | |
|
| 72 | | pub const SKIP_MARKERS: [&str; 3] = ["[skip flow]", "[skip ci]", "[ci skip]"]; |
| 73 | |
|
| 74 | |
|
| 75 | |
|
| 76 | |
|
| 77 | 154× | pub fn skip_marker(message: &str) -> Option<&'static str> { |
| 78 | 154× | let m = outside_code(message).to_lowercase(); |
| 79 | 456× | SKIP_MARKERS.into_iter().find(|k| m.contains(k)) |
| 80 | 154× | } |
| 81 | |
|
| 82 | |
|
| 83 | |
|
| 84 | 154× | fn outside_code(text: &str) -> String { |
| 85 | 154× | let mut out = String::new(); |
| 86 | 154× | let mut fenced = false; |
| 87 | 182× | for line in text.lines() { |
| 88 | 182× | if line.trim_start().starts_with("```") { |
| 89 | 2× | fenced = !fenced; |
| 90 | 2× | continue; |
| 91 | 180× | } |
| 92 | 180× | if fenced { |
| 93 | 1× | continue; |
| 94 | 179× | } |
| 95 | | |
| 96 | | |
| 97 | 179× | let pieces: Vec<&str> = line.split('`').collect(); |
| 98 | 179× | let closed = pieces.len() % 2 == 1; |
| 99 | 184× | for (i, piece) in pieces.iter().enumerate() { |
| 100 | 184× | let in_code = i % 2 == 1 && (closed || i + 1 < pieces.len()); |
| 101 | 184× | if !in_code { |
| 102 | 182× | out.push_str(piece); |
| 103 | 182× | out.push(' '); |
| 104 | 182× | } |
| 105 | | } |
| 106 | 179× | out.push('\n'); |
| 107 | | } |
| 108 | 154× | out |
| 109 | 154× | } |
| 110 | |
|
| 111 | |
|
| 112 | |
|
| 113 | 150× | pub fn skip_allowed(row: &db::repos::RepoRow, bookmark: &str) -> bool { |
| 114 | 150× | match row.flow_skip.as_str() { |
| 115 | 150× | "never" => false, |
| 116 | 149× | "others" => bookmark != row.default_bookmark, |
| 117 | 146× | _ => true, |
| 118 | | } |
| 119 | 150× | } |
| 120 | |
|
| 121 | |
|
| 122 | |
|
| 123 | 147× | async fn skip_asked(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<(ChangeHash, &'static str)> { |
| 124 | 147× | if !skip_allowed(row, bookmark) { |
| 125 | 1× | return None; |
| 126 | 146× | } |
| 127 | 146× | let repo = RepoName::new(&row.owner, &row.name)?; |
| 128 | 146× | let h = heads.to_vec(); |
| 129 | 146× | let newest = app |
| 130 | 146× | .repos |
| 131 | 146× | .read(&repo, move |r, _| { |
| 132 | 146× | let mut best: Option<(oxy_core::objects::Hlc, ChangeHash, String)> = None; |
| 133 | 191× | for x in &h { |
| 134 | 191× | let (c, _) = r.store().get_change_lazy(x)?; |
| 135 | 191× | if best.as_ref().is_none_or(|(t, _, _)| c.hlc > *t) { |
| 136 | 171× | best = Some((c.hlc, *x, c.message.clone())); |
| 137 | 171× | } |
| 138 | | } |
| 139 | 146× | Ok(best) |
| 140 | 146× | }) |
| 141 | 146× | .await |
| 142 | 146× | .ok()??; |
| 143 | 146× | skip_marker(&newest.2).map(|m| (newest.1, m)) |
| 144 | 147× | } |
| 145 | |
|
| 146 | |
|
| 147 | |
|
| 148 | |
|
| 149 | | #[allow(clippy::too_many_arguments)] |
| 150 | 1× | async fn skip_run( |
| 151 | 1× | app: &Shared, |
| 152 | 1× | row: &db::repos::RepoRow, |
| 153 | 1× | bookmark: &str, |
| 154 | 1× | state: &str, |
| 155 | 1× | heads: &[ChangeHash], |
| 156 | 1× | actor: &str, |
| 157 | 1× | change: &ChangeHash, |
| 158 | 1× | marker: &str, |
| 159 | 1× | ) { |
| 160 | 1× | let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect(); |
| 161 | 1× | hex.sort(); |
| 162 | 1× | let heads_s = hex.join("+"); |
| 163 | 1× | let why = format!("{marker} in change {}, pushed by {actor}", change.short(12)); |
| 164 | 1× | let base = |
| 165 | 1× | db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger: "push", error: None }; |
| 166 | 1× | if let Err(e) = db::flow::create_skipped_run(&app.db, base, &why).await { |
| 167 | 0× | tracing::warn!("flow: recording a skipped run of {}/{}: {e}", row.owner, row.name); |
| 168 | 1× | } |
| 169 | 1× | } |
| 170 | |
|
| 171 | |
|
| 172 | |
|
| 173 | |
|
| 174 | |
|
| 175 | 11× | pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> { |
| 176 | 11× | if !enabled(app, row) { |
| 177 | 1× | return Err("OxygenFlow is not enabled for this repository".into()); |
| 178 | 10× | } |
| 179 | 10× | let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
| 180 | 10× | let b = bookmark.to_string(); |
| 181 | 10× | let found = app |
| 182 | 10× | .repos |
| 183 | 10× | .read(&repo, move |r, _| { |
| 184 | 10× | let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) }; |
| 185 | 9× | Ok(Some((st, r.store().get_state(&st)?.heads))) |
| 186 | 10× | }) |
| 187 | 10× | .await |
| 188 | 10× | .map_err(|e| e.to_string())?; |
| 189 | 10× | let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) }; |
| 190 | 9× | if heads.is_empty() { |
| 191 | 0× | return Err(format!("{bookmark} has no changes yet")); |
| 192 | 9× | } |
| 193 | 9× | start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs) |
| 194 | 9× | .await? |
| 195 | 8× | .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}")) |
| 196 | 11× | } |
| 197 | |
|
| 198 | |
|
| 199 | |
|
| 200 | 2× | pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> { |
| 201 | 2× | if !enabled(app, row) { |
| 202 | 0× | return Err("OxygenFlow is not enabled for this repository".into()); |
| 203 | 2× | } |
| 204 | 2× | let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?; |
| 205 | 2× | let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
| 206 | 2× | let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?; |
| 207 | 2× | if !known { |
| 208 | 1× | return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())])); |
| 209 | 1× | } |
| 210 | 1× | let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode()); |
| 211 | 1× | start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs) |
| 212 | 1× | .await? |
| 213 | 1× | .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}")) |
| 214 | 2× | } |
| 215 | |
|
| 216 | |
|
| 217 | |
|
| 218 | 3× | pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> { |
| 219 | 3× | if !enabled(app, row) { |
| 220 | 0× | return Err("OxygenFlow is not enabled for this repository".into()); |
| 221 | 3× | } |
| 222 | 3× | if run.live() { |
| 223 | 1× | return Err(format!("run #{} is still {}", run.number, run.status)); |
| 224 | 2× | } |
| 225 | 2× | let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect(); |
| 226 | 2× | if heads.is_empty() { |
| 227 | 0× | return Err("the run names no changes".into()); |
| 228 | 2× | } |
| 229 | | |
| 230 | 2× | let trigger = format!("rerun:{}:{}", run.number, original_event(run)); |
| 231 | 2× | start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger) |
| 232 | 2× | .await? |
| 233 | 2× | .ok_or_else(|| format!("that state has no {PIPELINE_PATH}")) |
| 234 | 3× | } |
| 235 | |
|
| 236 | |
|
| 237 | |
|
| 238 | |
|
| 239 | |
|
| 240 | 4× | pub async fn rerun_job(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, job: &str, actor: &str) -> Result<usize, String> { |
| 241 | 4× | if !enabled(app, row) { |
| 242 | 0× | return Err("OxygenFlow is not enabled for this repository".into()); |
| 243 | 4× | } |
| 244 | 4× | if run.live() { |
| 245 | 2× | return Err(format!("run #{} is still {}: wait for it, or cancel it, first", run.number, run.status)); |
| 246 | 2× | } |
| 247 | 2× | let changed = fdb::rerun_job(&app.db, run.id, job).await.map_err(|e| { |
| 248 | 0× | tracing::warn!("flow: rerunning {job} of run #{} of {}/{}: {e}", run.number, row.owner, row.name); |
| 249 | 0× | "the job could not be queued again".to_string() |
| 250 | 0× | })?; |
| 251 | 2× | if changed.is_empty() { |
| 252 | 0× | return Err(format!("run #{} has no job {job:?}", run.number)); |
| 253 | 2× | } |
| 254 | 3× | for j in &changed { |
| 255 | 3× | report_check(app, row, &run.state, run.number, &j.name, "queued", &format!("queued again by {actor}")).await; |
| 256 | | } |
| 257 | 2× | Ok(changed.len()) |
| 258 | 4× | } |
| 259 | |
|
| 260 | |
|
| 261 | 2× | pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> { |
| 262 | 2× | if !run.live() { |
| 263 | 1× | return Err(format!("run #{} is already {}", run.number, run.status)); |
| 264 | 1× | } |
| 265 | 1× | cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| { |
| 266 | 0× | tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name); |
| 267 | 0× | "the run could not be cancelled".to_string() |
| 268 | 0× | }) |
| 269 | 2× | } |
| 270 | |
|
| 271 | 16× | async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> { |
| 272 | 16× | let changed = fdb::cancel_run(&app.db, run.id, why).await?; |
| 273 | 16× | report_changed(app, run.id, changed).await |
| 274 | 16× | } |
| 275 | |
|
| 276 | |
|
| 277 | |
|
| 278 | |
|
| 279 | |
|
| 280 | 22× | pub async fn may_decide( |
| 281 | 22× | app: &Shared, |
| 282 | 22× | row: &db::repos::RepoRow, |
| 283 | 22× | run: &fdb::Run, |
| 284 | 22× | job: &fdb::Job, |
| 285 | 22× | who: &db::accounts::Account, |
| 286 | 22× | ) -> Result<flow_core::Approval, String> { |
| 287 | 22× | let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?; |
| 288 | 22× | let Some(approval) = spec.approval else { return Err(format!("{} does not wait for approval", job.name)) }; |
| 289 | 22× | if job.status != "queued" || job.approval != "waiting" { |
| 290 | 0× | return Err(format!("{} is not waiting for approval", job.name)); |
| 291 | 22× | } |
| 292 | 22× | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?; |
| 293 | 25× | if let Some(d) = job.depends_on().iter().find(|d| !jobs.iter().any(|j| &j.name == *d && j.status == "passed")) { |
| 294 | 1× | return Err(format!("{} waits for {d} first", job.name)); |
| 295 | 21× | } |
| 296 | 21× | let need = crate::auth::roles::Role::parse(&approval.by).unwrap_or(crate::auth::roles::Role::Maintain); |
| 297 | 21× | let role = crate::auth::roles::role_for(&app.db, Some(who), row).await.map_err(|e| e.to_string())?; |
| 298 | 21× | if role.is_none_or(|r| r < need) { |
| 299 | 5× | return Err(format!("approving {} takes the {} role on {}/{}", job.name, approval.by, row.owner, row.name)); |
| 300 | 16× | } |
| 301 | 16× | if approval.others_only && who.username == run.actor { |
| 302 | 4× | return Err(format!("{} must be approved by someone other than {}, whose push started the run", job.name, run.actor)); |
| 303 | 12× | } |
| 304 | 12× | Ok(approval) |
| 305 | 22× | } |
| 306 | |
|
| 307 | |
|
| 308 | |
|
| 309 | |
|
| 310 | 7× | pub async fn decide( |
| 311 | 7× | app: &Shared, |
| 312 | 7× | row: &db::repos::RepoRow, |
| 313 | 7× | run: &fdb::Run, |
| 314 | 7× | name: &str, |
| 315 | 7× | who: &db::accounts::Account, |
| 316 | 7× | approve: bool, |
| 317 | 7× | via: Option<&str>, |
| 318 | 7× | ) -> Result<String, String> { |
| 319 | 7× | decide_with(app, row, run, name, who, approve, Through { seal: None, via }).await |
| 320 | 7× | } |
| 321 | |
|
| 322 | |
|
| 323 | |
|
| 324 | | #[derive(Clone, Copy, Default)] |
| 325 | | struct Through<'a> { |
| 326 | | seal: Option<&'a str>, |
| 327 | | via: Option<&'a str>, |
| 328 | | } |
| 329 | |
|
| 330 | |
|
| 331 | |
|
| 332 | 8× | async fn decide_with( |
| 333 | 8× | app: &Shared, |
| 334 | 8× | row: &db::repos::RepoRow, |
| 335 | 8× | run: &fdb::Run, |
| 336 | 8× | name: &str, |
| 337 | 8× | who: &db::accounts::Account, |
| 338 | 8× | approve: bool, |
| 339 | 8× | through: Through<'_>, |
| 340 | 8× | ) -> Result<String, String> { |
| 341 | 8× | let Through { seal, via } = through; |
| 342 | 8× | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?; |
| 343 | 16× | let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) }; |
| 344 | 8× | let approval = may_decide(app, row, run, &job, who).await?; |
| 345 | | |
| 346 | | |
| 347 | | |
| 348 | 5× | if let (Some(agent), None) = (via, seal) { |
| 349 | 2× | if !row.agents_decide_gates { |
| 350 | 1× | return Err(format!( |
| 351 | 1× | "{name} is decided by a person here, not through an agent's token ({agent}): decide it yourself on the run page, or a maintainer can let agents decide approval gates in Settings › OxygenFlow" |
| 352 | 1× | )); |
| 353 | 1× | } |
| 354 | 3× | } |
| 355 | 4× | if approve && approval.sealed && seal.is_none() { |
| 356 | 1× | return Err(format!( |
| 357 | 1× | "{name} takes an approval signed with your key: in a checkout of {}/{}, run oxy seal --flow {}/{}/{}/flow/{} --job {name}", |
| 358 | 1× | row.owner, |
| 359 | 1× | row.name, |
| 360 | 1× | app.config.base_url(), |
| 361 | 1× | row.owner, |
| 362 | 1× | row.name, |
| 363 | 1× | run.number |
| 364 | 1× | )); |
| 365 | 3× | } |
| 366 | 3× | let target = format!("{}/{}#{} {name}", row.owner, row.name, run.number); |
| 367 | 3× | let sealed = seal.map(|r| format!(", sealed (approval {})", &r[..12.min(r.len())])).unwrap_or_default(); |
| 368 | | |
| 369 | 3× | let by = match via { |
| 370 | 1× | Some(v) => format!("{} via {v}", who.username), |
| 371 | 2× | None => who.username.clone(), |
| 372 | | }; |
| 373 | 3× | let detail = [seal.unwrap_or(""), &via.map(|v| format!("via {v}")).unwrap_or_default()] |
| 374 | 3× | .iter() |
| 375 | 6× | .filter(|s| !s.is_empty()) |
| 376 | 3× | .cloned() |
| 377 | 3× | .collect::<Vec<_>>() |
| 378 | 3× | .join(", "); |
| 379 | 3× | let said = if approve { |
| 380 | 2× | if !fdb::approve_job(&app.db, job.id, &who.username, seal.unwrap_or(""), via.unwrap_or("")).await.map_err(|e| e.to_string())? { |
| 381 | 0× | return Err(format!("{name} was decided on meanwhile")); |
| 382 | 2× | } |
| 383 | 2× | db::accounts::audit(&app.db, Some(who), "flow.approved", &target, &detail).await.ok(); |
| 384 | 2× | format!("approved by {by}{sealed}") |
| 385 | | } else { |
| 386 | 1× | fdb::note_rejection(&app.db, job.id, &who.username, via.unwrap_or("")).await.map_err(|e| e.to_string())?; |
| 387 | 1× | let why = format!("rejected by {by}{sealed}"); |
| 388 | 1× | let changed = |
| 389 | 1× | fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" }) |
| 390 | 1× | .await |
| 391 | 1× | .map_err(|e| e.to_string())?; |
| 392 | 1× | report_changed(app, run.id, changed).await.ok(); |
| 393 | 1× | db::accounts::audit(&app.db, Some(who), "flow.rejected", &target, &detail).await.ok(); |
| 394 | 1× | why |
| 395 | | }; |
| 396 | 3× | let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said.clone() }; |
| 397 | 3× | fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok(); |
| 398 | 3× | Ok(format!("{name}: {said}")) |
| 399 | 8× | } |
| 400 | |
|
| 401 | |
|
| 402 | |
|
| 403 | |
|
| 404 | 87× | async fn tell_approvers(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) { |
| 405 | | const TOLD: &str = "waiting for approval; the people who may approve were told"; |
| 406 | 87× | let Ok(jobs) = fdb::jobs(&app.db, run.id).await else { return }; |
| 407 | 243× | for job in jobs.iter().filter(|j| j.status == "queued" && j.approval == "waiting") { |
| 408 | 7× | if !job.depends_on().iter().all(|d| jobs.iter().any(|j| &j.name == d && j.status == "passed")) { |
| 409 | 0× | continue; |
| 410 | 5× | } |
| 411 | 5× | let already = fdb::log(&app.db, job.id, 0, 50).await.unwrap_or_default().iter().any(|l| l.text == TOLD); |
| 412 | 5× | if already { |
| 413 | 0× | continue; |
| 414 | 5× | } |
| 415 | 5× | let mut targets = Vec::new(); |
| 416 | 13× | for a in db::accounts::accounts(&app.db).await.unwrap_or_default().iter().filter(|a| !a.blocked()) { |
| 417 | 13× | if may_decide(app, row, run, job, a).await.is_ok() { |
| 418 | 6× | targets.push((a.id, db::notify::Reason::Participating)); |
| 419 | 7× | } |
| 420 | | } |
| 421 | 5× | let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: TOLD.into() }; |
| 422 | 5× | fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok(); |
| 423 | 5× | let title = format!("{}/{} run #{}: {} waits for your approval", row.owner, row.name, run.number, job.name); |
| 424 | 5× | let url = format!("/{}/{}/flow/{}", row.owner, row.name, run.number); |
| 425 | 5× | crate::notify::tell(app, "flow.approval", &title, &url, &run.actor, Some(row.id), targets).await; |
| 426 | | } |
| 427 | 87× | } |
| 428 | |
|
| 429 | |
|
| 430 | |
|
| 431 | |
|
| 432 | 5× | pub async fn approval_statement( |
| 433 | 5× | app: &Shared, |
| 434 | 5× | row: &db::repos::RepoRow, |
| 435 | 5× | run: &fdb::Run, |
| 436 | 5× | name: &str, |
| 437 | 5× | ) -> Result<(oxy_core::approval::Statement, fdb::Job), String> { |
| 438 | 5× | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?; |
| 439 | 10× | let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) }; |
| 440 | 5× | let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?; |
| 441 | 5× | let sandbox: flow_core::Sandbox = serde_json::from_str(&job.sandbox).map_err(|e| format!("stored sandbox: {e}"))?; |
| 442 | 5× | let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
| 443 | 5× | let repo = app.repos.read(&repo_name, |r, _| Ok(r.id()?)).await.map_err(|e| e.to_string())?; |
| 444 | 5× | let state = oxy_core::hash::ObjectRef::from_hex(&run.state).ok_or("the run has no state")?; |
| 445 | 5× | let inputs = artifact_inputs(app, &job, &spec).await?.iter().filter_map(|i| oxy_core::hash::ObjectRef::from_hex(&i.blob)).collect(); |
| 446 | 5× | let statement = oxy_core::approval::Statement { |
| 447 | 5× | repo, |
| 448 | 5× | run: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, run.number), |
| 449 | 5× | job: name.to_string(), |
| 450 | 5× | job_hash: oxy_core::hash::ObjectRef(spec.hash(&sandbox)), |
| 451 | 5× | state, |
| 452 | 5× | inputs, |
| 453 | 5× | environment: spec.environment.clone().unwrap_or_default(), |
| 454 | 5× | approve: true, |
| 455 | 5× | }; |
| 456 | 5× | Ok((statement, job)) |
| 457 | 5× | } |
| 458 | |
|
| 459 | |
|
| 460 | |
|
| 461 | |
|
| 462 | |
|
| 463 | |
|
| 464 | 3× | async fn linked_through_chain( |
| 465 | 3× | app: &Shared, |
| 466 | 3× | repo: &RepoName, |
| 467 | 3× | signer: &oxy_core::identity::Identity, |
| 468 | 3× | links: &[(String, String)], |
| 469 | 3× | ) -> Option<(String, Vec<oxy_core::identity::Identity>)> { |
| 470 | 3× | let mut chain = vec![signer.clone()]; |
| 471 | 3× | let mut cur = signer.clone(); |
| 472 | 3× | for _ in 0..64 { |
| 473 | 3× | let prev = cur.previous?; |
| 474 | 2× | let hex = prev.to_hex(); |
| 475 | 2× | let found = match db::accounts::identity_by_key(&app.db, &hex).await.ok()? { |
| 476 | 2× | Some(l) => oxy_core::identity::Identity::decode(&l.object).ok(), |
| 477 | 0× | None => app.repos.read(repo, move |r, _| Ok(r.store().get_identity(&prev).ok())).await.ok().flatten(), |
| 478 | | }; |
| 479 | 2× | let id = found?; |
| 480 | 2× | if id.hash() != prev || id.ed25519_pk != cur.ed25519_pk || id.mldsa65_pk != cur.mldsa65_pk { |
| 481 | 1× | return None; |
| 482 | 1× | } |
| 483 | 1× | if let Some((_, u)) = links.iter().find(|(k, _)| *k == hex) { |
| 484 | 1× | return Some((u.clone(), chain)); |
| 485 | 0× | } |
| 486 | 0× | chain.push(id.clone()); |
| 487 | 0× | cur = id; |
| 488 | | } |
| 489 | 0× | None |
| 490 | 3× | } |
| 491 | |
|
| 492 | |
|
| 493 | |
|
| 494 | |
|
| 495 | |
|
| 496 | 4× | pub async fn decide_sealed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, name: &str, signed_hex: &str) -> Result<String, String> { |
| 497 | 4× | let bytes = hex::decode(signed_hex.trim()).map_err(|_| "the signed approval is not hex".to_string())?; |
| 498 | 4× | let sa = oxy_core::approval::SignedApproval::decode(&bytes).map_err(|e| format!("not a signed approval: {e}"))?; |
| 499 | 4× | sa.verify().map_err(|e| format!("it does not verify: {e}"))?; |
| 500 | 4× | let (mut expected, _) = approval_statement(app, row, run, name).await?; |
| 501 | 4× | let given = sa.statement().map_err(|e| e.to_string())?; |
| 502 | 4× | expected.approve = given.approve; |
| 503 | 4× | if !sa.covers(&expected) { |
| 504 | 1× | return Err(format!( |
| 505 | 1× | "it approves something else than job {name} of run #{} as it is now (another run, state, plan or environment): sign again", |
| 506 | 1× | run.number |
| 507 | 1× | )); |
| 508 | 3× | } |
| 509 | 3× | let key = sa.signer.hash().to_hex(); |
| 510 | 3× | let links = db::accounts::identity_accounts(&app.db).await.map_err(|e| e.to_string())?; |
| 511 | 3× | let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
| 512 | 3× | let username = match links.iter().find(|(k, _)| *k == key) { |
| 513 | 0× | Some((_, u)) => u.clone(), |
| 514 | | |
| 515 | | |
| 516 | 3× | None => match linked_through_chain(app, &repo_name, &sa.signer, &links).await { |
| 517 | 1× | Some((u, chain)) => { |
| 518 | 1× | if let Ok(Some(acc)) = db::accounts::account_by_name(&app.db, &u).await { |
| 519 | 1× | for id in &chain { |
| 520 | 1× | let k = id.hash().to_hex(); |
| 521 | 1× | if let Ok(None) = db::accounts::identity_by_key(&app.db, &k).await { |
| 522 | 1× | db::accounts::link_identity(&app.db, acc.id, &k, &id.name, &id.email, &id.encode()).await.ok(); |
| 523 | 0× | } |
| 524 | | } |
| 525 | 0× | } |
| 526 | 1× | u |
| 527 | | } |
| 528 | | None => { |
| 529 | 2× | return Err(format!( |
| 530 | 2× | "key {} is not linked to an account here, nor a renewal of a key that is (link it under Settings › Keys; an agent's delegated key cannot approve)", |
| 531 | 2× | &key[..12] |
| 532 | 2× | )) |
| 533 | | } |
| 534 | | }, |
| 535 | | }; |
| 536 | 1× | let who = db::accounts::account_by_name(&app.db, &username).await.map_err(|e| e.to_string())?.ok_or("the key's account is gone")?; |
| 537 | 1× | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?; |
| 538 | 2× | let job = jobs.iter().find(|j| j.name == name).ok_or("no such job")?; |
| 539 | 1× | may_decide(app, row, run, job, &who).await?; |
| 540 | 1× | let (rid, signers) = (sa.attest.repo, sa.sigs.iter().map(|s| s.key).collect::<Vec<_>>()); |
| 541 | 1× | let kept = app.repos.write(&repo_name, move |r, _| Ok(r.add_approval(&sa)?)).await.map_err(|e| e.to_string())?; |
| 542 | 1× | crate::tlog::log(app, vec![crate::tlog::Pending { kind: "approval", repo: rid, repo_row: Some(row.id), object: kept, signers }]).await; |
| 543 | 1× | decide_with(app, row, run, name, &who, given.approve, Through { seal: Some(&kept.to_hex()), via: None }).await |
| 544 | 4× | } |
| 545 | |
|
| 546 | |
|
| 547 | |
|
| 548 | 2× | pub async fn expire_approvals(app: &Shared, now: i64) -> sqlx::Result<usize> { |
| 549 | 2× | let mut ended = 0; |
| 550 | 2× | for job in fdb::waiting_for_approval(&app.db).await? { |
| 551 | 2× | let Ok(spec) = serde_json::from_str::<flow_core::Job>(&job.spec) else { continue }; |
| 552 | 2× | let Some(approval) = spec.approval else { continue }; |
| 553 | 2× | let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? else { continue }; |
| 554 | 2× | let jobs = fdb::jobs(&app.db, run.id).await?; |
| 555 | 2× | let wanted = job.depends_on(); |
| 556 | 4× | let deps: Vec<&fdb::Job> = jobs.iter().filter(|j| wanted.contains(&j.name)).collect(); |
| 557 | 2× | if deps.iter().any(|d| d.status != "passed") { |
| 558 | 0× | continue; |
| 559 | 2× | } |
| 560 | 2× | let since = deps.iter().filter_map(|d| d.finished_ms).max().unwrap_or(run.created_ms); |
| 561 | 2× | if now - since < approval.expires_s as i64 * 1000 { |
| 562 | 1× | continue; |
| 563 | 1× | } |
| 564 | 1× | let hours = approval.expires_s as f64 / 3600.0; |
| 565 | 1× | let why = |
| 566 | 1× | format!("no approval within {}", if hours >= 1.0 { format!("{hours:.0}h") } else { format!("{}m", approval.expires_s / 60) }); |
| 567 | 1× | let changed = |
| 568 | 1× | fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" }) |
| 569 | 1× | .await?; |
| 570 | 1× | report_changed(app, run.id, changed).await?; |
| 571 | 1× | ended += 1; |
| 572 | | } |
| 573 | 2× | Ok(ended) |
| 574 | 2× | } |
| 575 | |
|
| 576 | |
|
| 577 | |
|
| 578 | |
|
| 579 | |
|
| 580 | |
|
| 581 | |
|
| 582 | |
|
| 583 | |
|
| 584 | |
|
| 585 | |
|
| 586 | |
|
| 587 | |
|
| 588 | |
|
| 589 | |
|
| 590 | |
|
| 591 | |
|
| 592 | |
|
| 593 | |
|
| 594 | |
|
| 595 | |
|
| 596 | 136× | fn supersedes(trigger: &str) -> bool { |
| 597 | 136× | matches!(trigger, "push" | "proposal") |
| 598 | 136× | } |
| 599 | |
|
| 600 | |
|
| 601 | |
|
| 602 | |
|
| 603 | |
|
| 604 | |
|
| 605 | 121× | async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, trigger: &str, number: i64) { |
| 606 | 121× | let Ok(older) = fdb::live_runs_before(&app.db, row.id, bookmark, trigger, number, !row.flow_keep_running).await else { return }; |
| 607 | 121× | for run in older { |
| 608 | 11× | if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await { |
| 609 | 0× | tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name); |
| 610 | 11× | } |
| 611 | | } |
| 612 | 121× | } |
| 613 | |
|
| 614 | |
|
| 615 | |
|
| 616 | |
|
| 617 | |
|
| 618 | |
|
| 619 | 20× | pub async fn moot_after_landing(app: &Shared, row: &db::repos::RepoRow, p: &db::proposals::ProposalRow) { |
| 620 | 20× | let now = db::now_ms(); |
| 621 | 20× | let mut moot = Vec::new(); |
| 622 | 20× | let same_repo = p.source_repo_id.is_none_or(|id| id == row.id); |
| 623 | 20× | if p.source_kind == "bookmark" && same_repo && !p.source_bookmark.is_empty() && p.source_bookmark != p.target { |
| 624 | 20× | moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &p.source_bookmark, "push", now).await.unwrap_or_default()); |
| 625 | 0× | } |
| 626 | 20× | moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &format!("proposal/{}", p.number), "proposal", now).await.unwrap_or_default()); |
| 627 | 20× | for run in moot { |
| 628 | 4× | if let Err(e) = cancel_with(app, &run, &format!("moot: proposal #{} landed", p.number)).await { |
| 629 | 0× | tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name); |
| 630 | 4× | } |
| 631 | | } |
| 632 | 20× | } |
| 633 | |
|
| 634 | |
|
| 635 | |
|
| 636 | 137× | fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> { |
| 637 | | use flow_core::pipeline::Event; |
| 638 | 137× | let t = match trigger.strip_prefix("rerun:") { |
| 639 | 3× | Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"), |
| 640 | 134× | None => trigger, |
| 641 | | }; |
| 642 | 137× | if let Some(tag) = t.strip_prefix("tag:") { |
| 643 | 5× | Event::Tag(tag) |
| 644 | 132× | } else if t == "push" { |
| 645 | 114× | Event::Push(bookmark) |
| 646 | 18× | } else if t == "schedule" { |
| 647 | 0× | Event::Schedule |
| 648 | | } else { |
| 649 | 18× | Event::Manual |
| 650 | | } |
| 651 | 137× | } |
| 652 | |
|
| 653 | |
|
| 654 | 2× | fn original_event(run: &fdb::Run) -> String { |
| 655 | 2× | let t = run.trigger.as_str(); |
| 656 | 2× | match t.strip_prefix("rerun:") { |
| 657 | 0× | Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()), |
| 658 | 2× | None => t.to_string(), |
| 659 | | } |
| 660 | 2× | } |
| 661 | |
|
| 662 | |
|
| 663 | |
|
| 664 | |
|
| 665 | |
|
| 666 | 146× | async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> { |
| 667 | 146× | let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??; |
| 668 | 28× | let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect(); |
| 669 | 28× | if old.is_empty() { |
| 670 | 0× | return None; |
| 671 | 28× | } |
| 672 | 28× | let repo = RepoName::new(&row.owner, &row.name)?; |
| 673 | 28× | let new = heads.to_vec(); |
| 674 | 28× | app.repos |
| 675 | 28× | .read(&repo, move |r, cache| { |
| 676 | 28× | let fresh = r.negotiate(&old, &new)?; |
| 677 | 28× | let loaded = cache.load(r, &new)?; |
| 678 | 28× | let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> = |
| 679 | 140× | loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect(); |
| 680 | 28× | let mut paths = std::collections::BTreeSet::new(); |
| 681 | 31× | for h in fresh { |
| 682 | 31× | let (c, _) = r.store().get_change_lazy(&h)?; |
| 683 | 45× | let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node())); |
| 684 | 57× | for n in nodes { |
| 685 | 57× | if let Some(p) = by_node.get(&n) { |
| 686 | 57× | paths.insert((*p).clone()); |
| 687 | 57× | } |
| 688 | | } |
| 689 | | } |
| 690 | 28× | Ok(paths.into_iter().collect::<Vec<String>>()) |
| 691 | 28× | }) |
| 692 | 28× | .await |
| 693 | 28× | .ok() |
| 694 | 146× | } |
| 695 | |
|
| 696 | |
|
| 697 | |
|
| 698 | 366× | fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool { |
| 699 | 366× | let Some(touched) = touched else { return true }; |
| 700 | 71× | if scope.is_empty() { |
| 701 | 65× | return true; |
| 702 | 6× | } |
| 703 | 6× | let mut b = globset::GlobSetBuilder::new(); |
| 704 | 8× | for g in scope { |
| 705 | 8× | match globset::Glob::new(g) { |
| 706 | 8× | Ok(g) => { |
| 707 | 8× | b.add(g); |
| 708 | 8× | } |
| 709 | 0× | Err(_) => return true, |
| 710 | | } |
| 711 | | } |
| 712 | 6× | let Ok(set) = b.build() else { return true }; |
| 713 | 6× | touched.iter().any(|p| set.is_match(p)) |
| 714 | 366× | } |
| 715 | |
|
| 716 | |
|
| 717 | |
|
| 718 | | #[allow(clippy::too_many_arguments)] |
| 719 | 158× | pub async fn start_run_scoped( |
| 720 | 158× | app: &Shared, |
| 721 | 158× | row: &db::repos::RepoRow, |
| 722 | 158× | bookmark: &str, |
| 723 | 158× | state: &str, |
| 724 | 158× | heads: &[ChangeHash], |
| 725 | 158× | actor: &str, |
| 726 | 158× | trigger: &str, |
| 727 | 158× | touched: Option<&[String]>, |
| 728 | 158× | ) -> Result<Option<i64>, String> { |
| 729 | 158× | start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await |
| 730 | 158× | } |
| 731 | |
|
| 732 | |
|
| 733 | |
|
| 734 | |
|
| 735 | |
|
| 736 | |
|
| 737 | |
|
| 738 | 2× | pub async fn start_run( |
| 739 | 2× | app: &Shared, |
| 740 | 2× | row: &db::repos::RepoRow, |
| 741 | 2× | bookmark: &str, |
| 742 | 2× | state: &str, |
| 743 | 2× | heads: &[ChangeHash], |
| 744 | 2× | actor: &str, |
| 745 | 2× | trigger: &str, |
| 746 | 2× | ) -> Result<Option<i64>, String> { |
| 747 | 2× | start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await |
| 748 | 2× | } |
| 749 | |
|
| 750 | | #[allow(clippy::too_many_arguments)] |
| 751 | 166× | async fn start_run_with( |
| 752 | 166× | app: &Shared, |
| 753 | 166× | row: &db::repos::RepoRow, |
| 754 | 166× | bookmark: &str, |
| 755 | 166× | state: &str, |
| 756 | 166× | heads: &[ChangeHash], |
| 757 | 166× | actor: &str, |
| 758 | 166× | trigger: &str, |
| 759 | 166× | touched: Option<&[String]>, |
| 760 | 166× | ) -> Result<Option<i64>, String> { |
| 761 | 166× | start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await |
| 762 | 166× | } |
| 763 | |
|
| 764 | |
|
| 765 | |
|
| 766 | | #[allow(clippy::too_many_arguments)] |
| 767 | 180× | async fn start_run_at( |
| 768 | 180× | app: &Shared, |
| 769 | 180× | row: &db::repos::RepoRow, |
| 770 | 180× | bookmark: &str, |
| 771 | 180× | state: &str, |
| 772 | 180× | heads: &[ChangeHash], |
| 773 | 180× | actor: &str, |
| 774 | 180× | trigger: &str, |
| 775 | 180× | touched: Option<&[String]>, |
| 776 | 180× | at: Option<flow_core::pipeline::When>, |
| 777 | 180× | only: &[String], |
| 778 | 180× | ) -> Result<Option<i64>, String> { |
| 779 | 180× | let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
| 780 | 180× | let h = heads.to_vec(); |
| 781 | 180× | let text = app |
| 782 | 180× | .repos |
| 783 | 180× | .read(&repo, move |r, cache| { |
| 784 | 180× | let loaded = cache.load(r, &h)?; |
| 785 | 180× | Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) { |
| 786 | 144× | Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()), |
| 787 | 36× | _ => None, |
| 788 | | }) |
| 789 | 180× | }) |
| 790 | 180× | .await |
| 791 | 180× | .map_err(|e| format!("reading the pipeline: {e}"))?; |
| 792 | 180× | let Some(text) = text else { return Ok(None) }; |
| 793 | 180× | let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect(); |
| 794 | 144× | hex.sort(); |
| 795 | 144× | let heads_s = hex.join("+"); |
| 796 | 144× | let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None }; |
| 797 | 144× | let id = match Pipeline::parse(&text) { |
| 798 | | |
| 799 | | |
| 800 | | |
| 801 | 5× | Err(_) if at.is_some() => return Ok(None), |
| 802 | 3× | Err(problems) => { |
| 803 | 3× | let msg = problems.to_string(); |
| 804 | 3× | db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())? |
| 805 | | } |
| 806 | 139× | Ok(p) => { |
| 807 | 139× | let depths = p.depths(); |
| 808 | | |
| 809 | | |
| 810 | | |
| 811 | | |
| 812 | 139× | let event = match at { |
| 813 | 2× | Some(w) => flow_core::pipeline::Event::ScheduleAt(w), |
| 814 | 137× | None => event_of(trigger, bookmark), |
| 815 | | }; |
| 816 | 388× | let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched); |
| 817 | 139× | let mut keep: std::collections::HashSet<String> = if only.is_empty() { |
| 818 | 382× | p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect() |
| 819 | | } else { |
| 820 | | |
| 821 | 6× | let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect(); |
| 822 | 2× | for name in only { |
| 823 | 2× | if !startable.contains(&name.as_str()) { |
| 824 | 1× | let what = if p.jobs.contains_key(name) { |
| 825 | 0× | "is not started by hand (no `on { manual }`)" |
| 826 | | } else { |
| 827 | 1× | "is not in the pipeline" |
| 828 | | }; |
| 829 | 1× | return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", "))); |
| 830 | 1× | } |
| 831 | | } |
| 832 | 1× | only.iter().cloned().collect() |
| 833 | | }; |
| 834 | | loop { |
| 835 | 139× | let needed: Vec<String> = p |
| 836 | 139× | .order() |
| 837 | 139× | .into_iter() |
| 838 | 388× | .filter(|j| keep.contains(&j.name)) |
| 839 | 361× | .flat_map(|j| j.depends_on.iter().cloned()) |
| 840 | 139× | .filter(|d| !keep.contains(d)) |
| 841 | 139× | .collect(); |
| 842 | 139× | if needed.is_empty() { |
| 843 | 138× | break; |
| 844 | 1× | } |
| 845 | 1× | keep.extend(needed); |
| 846 | | } |
| 847 | 138× | let mut jobs: Vec<fdb::NewJob> = p |
| 848 | 138× | .order() |
| 849 | 138× | .into_iter() |
| 850 | 385× | .filter(|j| keep.contains(&j.name)) |
| 851 | 360× | .map(|j| { |
| 852 | 360× | let sb = &p.sandboxes[&j.sandbox]; |
| 853 | 360× | fdb::NewJob { |
| 854 | 360× | name: j.name.clone(), |
| 855 | 360× | depth: depths[j.name.as_str()] as i64, |
| 856 | 360× | depends: j.depends_on.clone(), |
| 857 | 360× | spec: serde_json::to_string(j).unwrap_or_default(), |
| 858 | 360× | sandbox: serde_json::to_string(sb).unwrap_or_default(), |
| 859 | 360× | runtime: sb.runtime.as_str().into(), |
| 860 | 360× | need_disk_mib: None, |
| 861 | 360× | reused: None, |
| 862 | 360× | } |
| 863 | 360× | }) |
| 864 | 138× | .collect(); |
| 865 | | |
| 866 | 360× | for j in &mut jobs { |
| 867 | 360× | j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?; |
| 868 | | } |
| 869 | 138× | if jobs.is_empty() { |
| 870 | | |
| 871 | 5× | return Ok(None); |
| 872 | 133× | } |
| 873 | | |
| 874 | | |
| 875 | | |
| 876 | | |
| 877 | | |
| 878 | 133× | if trigger == "push" && row.flow_reuse { |
| 879 | 110× | let consumed: std::collections::HashSet<&str> = p |
| 880 | 110× | .order() |
| 881 | 110× | .into_iter() |
| 882 | 305× | .filter(|j| keep.contains(&j.name)) |
| 883 | 301× | .flat_map(|j| j.inputs.iter().map(|i| i.from.as_str())) |
| 884 | 110× | .collect(); |
| 885 | 301× | for j in &mut jobs { |
| 886 | 301× | let Some(spec) = p.jobs.get(&j.name) else { continue }; |
| 887 | 301× | if !reusable(spec) || consumed.contains(j.name.as_str()) { |
| 888 | 10× | continue; |
| 889 | 291× | } |
| 890 | 291× | j.reused = |
| 891 | 291× | fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?; |
| 892 | | } |
| 893 | 23× | } |
| 894 | 133× | let hash = hex::encode(p.hash()); |
| 895 | 133× | let id = |
| 896 | 133× | db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?; |
| 897 | 133× | if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await { |
| 898 | 360× | for j in jobs.iter().filter(|j| j.reused.is_none()) { |
| 899 | 351× | report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await; |
| 900 | | } |
| 901 | | |
| 902 | | |
| 903 | 353× | if jobs.iter().any(|j| j.reused.is_some()) { |
| 904 | 3× | let reused: Vec<fdb::Job> = |
| 905 | 10× | fdb::jobs(&app.db, run.id).await.unwrap_or_default().into_iter().filter(|j| j.reused_from().is_some()).collect(); |
| 906 | 3× | if let Err(e) = report_changed(app, run.id, reused).await { |
| 907 | 0× | tracing::warn!("flow: reporting reused passes of run #{}: {e}", run.number); |
| 908 | 3× | } |
| 909 | 130× | } |
| 910 | 133× | images_check(app, row, state, run.number, &jobs).await; |
| 911 | 0× | } |
| 912 | 133× | id |
| 913 | | } |
| 914 | | }; |
| 915 | 136× | let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?; |
| 916 | 136× | if supersedes(trigger) { |
| 917 | 121× | supersede(app, row, bookmark, trigger, run.number).await; |
| 918 | 15× | } |
| 919 | 136× | Ok(Some(run.number)) |
| 920 | 180× | } |
| 921 | |
|
| 922 | |
|
| 923 | |
|
| 924 | |
|
| 925 | |
|
| 926 | 133× | async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) { |
| 927 | 133× | let mut pinned: Vec<String> = jobs |
| 928 | 133× | .iter() |
| 929 | 360× | .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok()) |
| 930 | 360× | .filter_map(|v| v["rootfs"].as_str().map(String::from)) |
| 931 | 360× | .filter(|r| !r.is_empty()) |
| 932 | 133× | .collect(); |
| 933 | 133× | pinned.sort(); |
| 934 | 133× | pinned.dedup(); |
| 935 | 133× | if pinned.is_empty() { |
| 936 | 1× | return; |
| 937 | 132× | } |
| 938 | 132× | let mut held = crate::images::held(&app.db).await.unwrap_or_default(); |
| 939 | | |
| 940 | | |
| 941 | 132× | let runners: Vec<db::runners::Runner> = |
| 942 | 132× | db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect(); |
| 943 | 132× | if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) { |
| 944 | 126× | return; |
| 945 | 6× | } |
| 946 | 6× | for r in &runners { |
| 947 | 6× | held.extend(r.image_list().unwrap_or_default()); |
| 948 | 6× | } |
| 949 | 6× | let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect(); |
| 950 | | |
| 951 | | |
| 952 | 6× | let failing_before = db::hooks::checks_for_state(&app.db, row.id, state) |
| 953 | 6× | .await |
| 954 | 6× | .unwrap_or_default() |
| 955 | 6× | .iter() |
| 956 | 13× | .any(|c| c.name == "pipeline/images" && c.status != "passed"); |
| 957 | 6× | if missing.is_empty() && !failing_before { |
| 958 | 3× | return; |
| 959 | 3× | } |
| 960 | 3× | let (status, summary) = if missing.is_empty() { |
| 961 | | ( |
| 962 | | "passed", |
| 963 | 1× | format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }), |
| 964 | | ) |
| 965 | | } else { |
| 966 | 2× | let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect(); |
| 967 | 2× | ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", "))) |
| 968 | | }; |
| 969 | 3× | let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name); |
| 970 | 3× | if let Ok(c) = db::hooks::upsert_check( |
| 971 | 3× | &app.db, |
| 972 | 3× | row.id, |
| 973 | 3× | db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" }, |
| 974 | | ) |
| 975 | 3× | .await |
| 976 | | { |
| 977 | 3× | let api = crate::api::check_of(&c); |
| 978 | 3× | app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
| 979 | 3× | crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await; |
| 980 | 0× | } |
| 981 | 133× | } |
| 982 | |
|
| 983 | |
|
| 984 | 686× | async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) { |
| 985 | 686× | let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name); |
| 986 | 686× | let name = format!("flow/{job}"); |
| 987 | 686× | let c = db::hooks::upsert_check( |
| 988 | 686× | &app.db, |
| 989 | 686× | row.id, |
| 990 | 686× | db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" }, |
| 991 | 686× | ) |
| 992 | 686× | .await; |
| 993 | 686× | if let Ok(c) = c { |
| 994 | 686× | let api = crate::api::check_of(&c); |
| 995 | 686× | app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
| 996 | 686× | crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await; |
| 997 | 0× | } |
| 998 | 686× | } |
| 999 | |
|
| 1000 | |
|
| 1001 | 160× | fn check_status(job_status: &str) -> &'static str { |
| 1002 | 160× | match job_status { |
| 1003 | 160× | "queued" => "queued", |
| 1004 | 160× | "running" => "running", |
| 1005 | 160× | "passed" => "passed", |
| 1006 | 67× | "skipped" => "skipped", |
| 1007 | 59× | "cancelled" => "cancelled", |
| 1008 | 17× | _ => "failed", |
| 1009 | | } |
| 1010 | 160× | } |
| 1011 | |
|
| 1012 | |
|
| 1013 | |
|
| 1014 | |
|
| 1015 | 398× | pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> { |
| 1016 | 398× | let Some(t) = oxy_server::bearer_token(headers) else { |
| 1017 | 4× | return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into())); |
| 1018 | | }; |
| 1019 | 394× | let hash = blake3::hash(t.as_bytes()).to_hex().to_string(); |
| 1020 | 394× | let runner = match db::runners::by_token(&app.db, &hash).await? { |
| 1021 | 329× | Some(r) => r, |
| 1022 | 82× | None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => { |
| 1023 | 61× | db::runners::configured(&app.db, &hash, "runner").await? |
| 1024 | | } |
| 1025 | 4× | None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())), |
| 1026 | | }; |
| 1027 | 390× | if runner.state == "disabled" { |
| 1028 | 1× | return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name))); |
| 1029 | 389× | } |
| 1030 | 389× | Ok(runner) |
| 1031 | 398× | } |
| 1032 | |
|
| 1033 | |
|
| 1034 | |
|
| 1035 | |
|
| 1036 | 15× | pub async fn register( |
| 1037 | 15× | State(app): State<Shared>, |
| 1038 | 15× | Json(req): Json<flow_core::api::Register>, |
| 1039 | 15× | ) -> Result<Json<flow_core::api::Registered>, ApiError> { |
| 1040 | 15× | let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?; |
| 1041 | 15× | let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?; |
| 1042 | 94× | let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect(); |
| 1043 | 15× | if name.is_empty() { |
| 1044 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into())); |
| 1045 | 15× | } |
| 1046 | 15× | let credential = format!("flowrunner_{}", crate::auth::random_hex(32)); |
| 1047 | 15× | let hash = blake3::hash(credential.as_bytes()).to_hex().to_string(); |
| 1048 | 15× | let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string(); |
| 1049 | 14× | let runner = |
| 1050 | 15× | db::runners::register(&app.db, ®, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| { |
| 1051 | 1× | ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into()) |
| 1052 | 1× | })?; |
| 1053 | 14× | db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?; |
| 1054 | 14× | tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]); |
| 1055 | 14× | Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential })) |
| 1056 | 15× | } |
| 1057 | |
|
| 1058 | |
|
| 1059 | 225× | pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> { |
| 1060 | 225× | let me = runner_auth(&app, &headers).await?; |
| 1061 | 1235× | let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect(); |
| 1062 | 221× | db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?; |
| 1063 | 221× | if let Some(holding) = &req.holding { |
| 1064 | 2× | end_jobs_not_held(&app, &me, holding).await?; |
| 1065 | 219× | } |
| 1066 | | |
| 1067 | 221× | if me.state == "draining" { |
| 1068 | 1× | return Ok(StatusCode::NO_CONTENT.into_response()); |
| 1069 | 220× | } |
| 1070 | 220× | let runner = if me.source == "registered" { me.name.clone() } else { claimed }; |
| 1071 | 220× | let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32)); |
| 1072 | | |
| 1073 | | |
| 1074 | 220× | if let Some(archs) = &req.image_archs { |
| 1075 | 5× | fdb::record_image_archs(&app.db, archs).await?; |
| 1076 | 215× | } |
| 1077 | 220× | let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone()); |
| 1078 | | |
| 1079 | | |
| 1080 | 220× | let profile = profile_of(&req, &arch); |
| 1081 | 220× | db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?; |
| 1082 | 220× | if let Some(f) = &req.facts { |
| 1083 | 13× | db::runners::set_facts(&app.db, me.id, f).await?; |
| 1084 | 207× | } |
| 1085 | 220× | app.swarm.claimed(me.id, req.swarm.as_ref()); |
| 1086 | | |
| 1087 | | |
| 1088 | 220× | if let Some(c) = &req.caches { |
| 1089 | 6× | let kept = |slot: &str| req.cache_times.as_ref().and_then(|t| t.get(slot)).map_or(0, |ms| *ms as i64); |
| 1090 | 6× | CACHES |
| 1091 | 6× | .lock() |
| 1092 | 6× | .unwrap_or_else(|p| p.into_inner()) |
| 1093 | 6× | .get_or_insert_with(Default::default) |
| 1094 | 6× | .insert((app.repos.root().display().to_string(), me.id), c.iter().map(|s| (s.clone(), kept(s))).collect()); |
| 1095 | 214× | } |
| 1096 | 220× | let room = fdb::Room { |
| 1097 | 220× | declined: fdb::declined_by(&app.db, me.id, &profile).await?, |
| 1098 | 220× | memory_mib: req.free_memory_mib, |
| 1099 | 220× | cpus: req.free_cpus, |
| 1100 | 220× | images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()), |
| 1101 | 220× | arch, |
| 1102 | 220× | image_archs: fdb::image_archs(&app.db).await?, |
| 1103 | 220× | fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() }, |
| 1104 | 220× | free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib), |
| 1105 | | }; |
| 1106 | | |
| 1107 | | |
| 1108 | 220× | let mut labels: Vec<String> = match &req.info { |
| 1109 | 13× | Some(i) => i.labels.clone(), |
| 1110 | 207× | None => me.reported_label_list().into_iter().map(String::from).collect(), |
| 1111 | | }; |
| 1112 | 220× | for l in me.admin_label_list() { |
| 1113 | 0× | if !labels.iter().any(|x| x == l) { |
| 1114 | 0× | labels.push(l.to_string()); |
| 1115 | 0× | } |
| 1116 | | } |
| 1117 | | |
| 1118 | | |
| 1119 | 220× | let runners = db::runners::list(&app.db).await?; |
| 1120 | 251× | let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone()); |
| 1121 | 220× | let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect(); |
| 1122 | 220× | let queue = fdb::queue(&app.db).await?; |
| 1123 | 452× | let owners: std::collections::HashMap<i64, (String, i64)> = queue.iter().map(|q| (q.job_id, (q.owner.clone(), q.repo_id))).collect(); |
| 1124 | | |
| 1125 | 220× | let slots: std::collections::HashMap<i64, String> = |
| 1126 | 452× | queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect(); |
| 1127 | 220× | let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?); |
| 1128 | 220× | let now = db::now_ms(); |
| 1129 | 220× | let fleet = Fleet { image_archs: &archs, hub_images: &held, now }; |
| 1130 | 220× | let hub_key = app.repos.root().display().to_string(); |
| 1131 | 220× | let better = |j: &fdb::Job| -> bool { |
| 1132 | 159× | let Some((owner, repo_id)) = owners.get(&j.id) else { return false }; |
| 1133 | 158× | let rootfs = rootfs_of(j); |
| 1134 | 158× | let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner()); |
| 1135 | 158× | let holds = holds.get_or_insert_with(Default::default); |
| 1136 | 158× | holds.retain(|_, t| now - *t < 600_000); |
| 1137 | 158× | if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) { |
| 1138 | 0× | return false; |
| 1139 | 158× | } |
| 1140 | 158× | let slot = slots.get(&j.id).map(String::as_str).unwrap_or(""); |
| 1141 | 158× | let mine = fit(&hub_key, &me_now, &rootfs, slot); |
| 1142 | 184× | let other = runners.iter().any(|r| { |
| 1143 | 184× | r.id != me.id |
| 1144 | 29× | && r.state == "active" |
| 1145 | 29× | && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS) |
| 1146 | 28× | && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1) |
| 1147 | 17× | && fit(&hub_key, r, &rootfs, slot).beats(&mine) |
| 1148 | 3× | && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none() |
| 1149 | 184× | }); |
| 1150 | 158× | if other { |
| 1151 | 3× | holds.entry((hub_key.clone(), j.id)).or_insert(now); |
| 1152 | 155× | } |
| 1153 | 158× | other |
| 1154 | 159× | }; |
| 1155 | 156× | let Some(job) = |
| 1156 | 220× | fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await? |
| 1157 | | else { |
| 1158 | 64× | return Ok(StatusCode::NO_CONTENT.into_response()); |
| 1159 | | }; |
| 1160 | | |
| 1161 | 156× | let waited = |
| 1162 | 156× | HELD.lock().unwrap_or_else(|p| p.into_inner()).as_mut().and_then(|h| h.remove(&(hub_key.clone(), job.id))).map(|t| now - t); |
| 1163 | 156× | let rootfs = rootfs_of(&job); |
| 1164 | | |
| 1165 | 156× | let mut why = Vec::new(); |
| 1166 | 156× | if !rootfs.is_empty() { |
| 1167 | 155× | why.push(if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) { |
| 1168 | 5× | "holds the image".to_string() |
| 1169 | | } else { |
| 1170 | 150× | "fetches the image from the hub".to_string() |
| 1171 | | }); |
| 1172 | 1× | } |
| 1173 | 156× | if let Some(kept) = slots.get(&job.id).and_then(|slot| cache_kept(&hub_key, me.id, slot)) { |
| 1174 | 2× | why.insert( |
| 1175 | | 0, |
| 1176 | 2× | if kept > 0 { format!("holds its build cache (kept {} ago)", ago(now - kept)) } else { "holds its build cache".into() }, |
| 1177 | | ); |
| 1178 | 154× | } |
| 1179 | 156× | match running.get(&me.id).copied().unwrap_or(0) { |
| 1180 | 134× | 0 => why.push("idle".into()), |
| 1181 | 22× | n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })), |
| 1182 | | } |
| 1183 | 156× | if let Some(sp) = me_now.speed { |
| 1184 | 3× | why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0))); |
| 1185 | 153× | } |
| 1186 | 156× | if let Some(w) = waited { |
| 1187 | 3× | why.push(format!("after {} s held for a better runner", w / 1000)); |
| 1188 | 153× | } |
| 1189 | 156× | fdb::set_placed(&app.db, job.id, &why.join(", ")).await?; |
| 1190 | 156× | db::runners::set_job_runner(&app.db, job.id, me.id).await?; |
| 1191 | 156× | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 1192 | 156× | let row = |
| 1193 | 156× | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
| 1194 | 156× | report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await; |
| 1195 | 156× | let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")); |
| 1196 | | |
| 1197 | | |
| 1198 | 156× | let limits = limits(&app).await; |
| 1199 | 156× | let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?; |
| 1200 | 156× | let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?; |
| 1201 | 156× | let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60); |
| 1202 | | |
| 1203 | 156× | if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) { |
| 1204 | 0× | timeout_s = timeout_s.min(m as u64 * 60).max(60); |
| 1205 | 156× | } |
| 1206 | 156× | fdb::set_job_timeout(&app.db, job.id, timeout_s).await?; |
| 1207 | 156× | let claim = Claim { |
| 1208 | 156× | job_id: job.id, |
| 1209 | 156× | run_id: run.id, |
| 1210 | 156× | repo: format!("{}/{}", row.owner, row.name), |
| 1211 | 156× | clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name), |
| 1212 | 156× | token: secret, |
| 1213 | 156× | heads: run.head_list().into_iter().map(String::from).collect(), |
| 1214 | 156× | pipeline_hash: run.pipeline_hash.clone(), |
| 1215 | 156× | job: spec, |
| 1216 | 156× | sandbox, |
| 1217 | 156× | timeout_s, |
| 1218 | 156× | subrepos: vec![], |
| 1219 | 156× | state: run.state.clone(), |
| 1220 | 156× | bookmark: run.bookmark.clone(), |
| 1221 | 156× | run_number: run.number, |
| 1222 | | |
| 1223 | 156× | env: crate::variables::env_for_repo(&app.db, &row).await?, |
| 1224 | 156× | secrets: vec![], |
| 1225 | 156× | inputs: vec![], |
| 1226 | | |
| 1227 | | |
| 1228 | 156× | expected_ms: fdb::expected_ms(&app.db, row.id, &job.name).await?.map(|ms| ms as u64), |
| 1229 | | |
| 1230 | 156× | checkpoint: if job.handbacks > 0 { fdb::checkpoint(&app.db, job.id).await?.map(|a| a.blob) } else { None }, |
| 1231 | | }; |
| 1232 | 156× | let mut claim = claim; |
| 1233 | 156× | let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await; |
| 1234 | | |
| 1235 | | |
| 1236 | 156× | let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect(); |
| 1237 | 156× | let found = crate::secrets::for_job(&app, &row, &names).await?; |
| 1238 | | |
| 1239 | | |
| 1240 | | |
| 1241 | 156× | let environment = claim.job.environment.clone().filter(|_| job.approval == "approved"); |
| 1242 | 156× | if let (Some(e), None, false) = (&claim.job.environment, &environment, claim.job.identities.is_empty()) { |
| 1243 | 0× | left_out.push(format!( |
| 1244 | 0× | "environment {e}: not in the job's identity tokens, because the job was not approved (give it an approval block)" |
| 1245 | 0× | )); |
| 1246 | 156× | } |
| 1247 | 156× | if !claim.job.identities.is_empty() { |
| 1248 | 3× | let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?; |
| 1249 | 3× | for i in &claim.job.identities { |
| 1250 | 3× | let token = issuer.mint(&crate::oidc::JobClaims { |
| 1251 | 3× | issuer: app.config.base_url(), |
| 1252 | 3× | audience: &i.audience, |
| 1253 | 3× | owner: &row.owner, |
| 1254 | 3× | name: &row.name, |
| 1255 | 3× | visibility: &row.visibility, |
| 1256 | 3× | bookmark: &run.bookmark, |
| 1257 | 3× | run_number: run.number, |
| 1258 | 3× | job: &job.name, |
| 1259 | 3× | state: &run.state, |
| 1260 | 3× | runner: &runner, |
| 1261 | 3× | actor: &run.actor, |
| 1262 | 3× | environment: environment.as_deref(), |
| 1263 | 3× | approved_by: (job.approval == "approved").then_some(job.approved_by.as_str()), |
| 1264 | 3× | approval: (!job.approval_seal.is_empty()).then_some(job.approval_seal.as_str()), |
| 1265 | 3× | now_ms: db::now_ms(), |
| 1266 | 3× | ttl_s: timeout_s, |
| 1267 | 3× | }); |
| 1268 | 3× | claim.secrets.push(flow_core::api::SecretInput { |
| 1269 | 3× | name: format!("identity:{}", i.audience), |
| 1270 | 3× | path: i.mount_path.clone(), |
| 1271 | 3× | value: token, |
| 1272 | 3× | }); |
| 1273 | 3× | } |
| 1274 | 153× | } |
| 1275 | 156× | for s in &claim.job.secrets { |
| 1276 | 2× | match found.iter().find(|(n, _)| *n == s.name) { |
| 1277 | 1× | Some((name, value)) => { |
| 1278 | 1× | claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() }) |
| 1279 | | } |
| 1280 | 1× | None => left_out.push(format!( |
| 1281 | | "secret {:?} is not set for this repository, its owner or the site; {} will be missing", |
| 1282 | | s.name, s.mount_path |
| 1283 | | )), |
| 1284 | | } |
| 1285 | | } |
| 1286 | 156× | if !ids.is_empty() { |
| 1287 | 28× | fdb::set_also_read(&app.db, job.id, &ids).await?; |
| 1288 | 128× | } |
| 1289 | | |
| 1290 | | |
| 1291 | 156× | if !left_out.is_empty() { |
| 1292 | 2× | let records: Vec<flow_core::api::Record> = |
| 1293 | 2× | left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect(); |
| 1294 | 2× | fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?; |
| 1295 | 154× | } |
| 1296 | 156× | claim.subrepos = inputs; |
| 1297 | | |
| 1298 | | |
| 1299 | 156× | match artifact_inputs(&app, &job, &claim.job).await { |
| 1300 | 156× | Ok(found) => claim.inputs = found, |
| 1301 | 0× | Err(why) => { |
| 1302 | 0× | let error = format!("inputs: {why}"); |
| 1303 | 0× | fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
| 1304 | 0× | .await?; |
| 1305 | 0× | return Err(ApiError(StatusCode::CONFLICT, error)); |
| 1306 | | } |
| 1307 | | } |
| 1308 | 156× | Ok(Json(claim).into_response()) |
| 1309 | 225× | } |
| 1310 | |
|
| 1311 | |
|
| 1312 | |
|
| 1313 | |
|
| 1314 | 163× | async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> { |
| 1315 | 163× | if spec.inputs.is_empty() { |
| 1316 | 154× | return Ok(vec![]); |
| 1317 | 9× | } |
| 1318 | 9× | let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?; |
| 1319 | 9× | let mut out = Vec::new(); |
| 1320 | 9× | for i in &spec.inputs { |
| 1321 | 9× | let Some(producer) = jobs.iter().find(|j| j.name == i.from) else { |
| 1322 | 0× | return Err(format!("{:?} is not a job of this run", i.from)); |
| 1323 | | }; |
| 1324 | 9× | if producer.status != "passed" || producer.receipt_hash.is_empty() { |
| 1325 | 0× | return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact)); |
| 1326 | 9× | } |
| 1327 | 9× | let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?; |
| 1328 | 9× | let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else { |
| 1329 | 0× | return Err(format!("{} kept no artifact {}", i.from, i.artifact)); |
| 1330 | | }; |
| 1331 | 9× | out.push(flow_core::api::ArtifactInput { |
| 1332 | 9× | artifact: i.artifact.clone(), |
| 1333 | 9× | from: i.from.clone(), |
| 1334 | 9× | path: i.path.clone(), |
| 1335 | 9× | blob: a.blob.clone(), |
| 1336 | 9× | }); |
| 1337 | | } |
| 1338 | 9× | Ok(out) |
| 1339 | 163× | } |
| 1340 | |
|
| 1341 | |
|
| 1342 | |
|
| 1343 | 4× | pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> { |
| 1344 | 4× | let runner = runner_auth(&app, &headers).await?; |
| 1345 | 3× | let job = running_job(&app, id).await?; |
| 1346 | | |
| 1347 | | |
| 1348 | 2× | if runner.source == "registered" && job.runner != runner.name { |
| 1349 | 0× | return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into())); |
| 1350 | 2× | } |
| 1351 | 2× | let spec: flow_core::Job = |
| 1352 | 2× | serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?; |
| 1353 | 2× | let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?; |
| 1354 | 2× | let Some(i) = found.get(index) else { |
| 1355 | 1× | return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name))); |
| 1356 | | }; |
| 1357 | 1× | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 1358 | 1× | let row = |
| 1359 | 1× | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
| 1360 | 1× | let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?; |
| 1361 | 1× | let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob) |
| 1362 | 1× | .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?; |
| 1363 | 1× | let bytes = app |
| 1364 | 1× | .repos |
| 1365 | 1× | .read(&repo, move |r, _| Ok(r.read_blob(&blob)?)) |
| 1366 | 1× | .await |
| 1367 | 1× | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?; |
| 1368 | 1× | Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()) |
| 1369 | 4× | } |
| 1370 | |
|
| 1371 | |
|
| 1372 | |
|
| 1373 | 220× | fn profile_of(req: &ClaimRequest, arch: &str) -> String { |
| 1374 | 259× | let sorted = |v: &[String]| { |
| 1375 | 259× | let mut v = v.to_vec(); |
| 1376 | 259× | v.sort(); |
| 1377 | 259× | v |
| 1378 | 259× | }; |
| 1379 | 220× | let info = req.info.as_ref(); |
| 1380 | 220× | let v = serde_json::json!({ |
| 1381 | 220× | "arch": arch, |
| 1382 | 220× | "tiers": sorted(&req.runtimes), |
| 1383 | 220× | "images": req.images.as_deref().map(sorted), |
| 1384 | 220× | "labels": info.map(|i| sorted(&i.labels)), |
| 1385 | 220× | "memory_mib": info.and_then(|i| i.memory_mib), |
| 1386 | 220× | "cpus": info.and_then(|i| i.cpus), |
| 1387 | | }); |
| 1388 | 220× | blake3::hash(v.to_string().as_bytes()).to_hex().to_string() |
| 1389 | 220× | } |
| 1390 | |
|
| 1391 | |
|
| 1392 | | pub const UNPLACEABLE_AFTER: usize = 3; |
| 1393 | |
|
| 1394 | |
|
| 1395 | |
|
| 1396 | |
|
| 1397 | | pub const LEASE_MS: i64 = 3 * 60_000; |
| 1398 | |
|
| 1399 | |
|
| 1400 | |
|
| 1401 | | pub const MAX_HANDBACKS: i64 = 5; |
| 1402 | |
|
| 1403 | |
|
| 1404 | |
|
| 1405 | |
|
| 1406 | |
|
| 1407 | 3× | pub async fn hand_back_job(app: &Shared, id: i64, runner: Option<i64>, why: &str) -> sqlx::Result<bool> { |
| 1408 | 3× | let Some(before) = fdb::job(&app.db, id).await? else { return Ok(false) }; |
| 1409 | 3× | let Some(n) = fdb::hand_back(&app.db, id, runner).await? else { return Ok(false) }; |
| 1410 | 2× | let t = before.started_ms.map(|s| (db::now_ms() - s).max(0) as u64).unwrap_or(0); |
| 1411 | 2× | let record = flow_core::api::Record { t, stream: "flow".into(), text: format!("── {why}; queued again ahead of new work") }; |
| 1412 | 2× | fdb::append_log(&app.db, id, &[record], MAX_RECORDS as i64).await.ok(); |
| 1413 | 2× | tracing::info!("flow: job {id} handed back ({n}): {why}"); |
| 1414 | 2× | if n > MAX_HANDBACKS { |
| 1415 | 0× | let error = format!("handed back {n} times; the last: {why}"); |
| 1416 | 0× | let changed = |
| 1417 | 0× | fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
| 1418 | 0× | .await?; |
| 1419 | 0× | report_changed(app, before.run_id, changed).await?; |
| 1420 | 2× | } else if let Some(run) = fdb::run_by_id(&app.db, before.run_id).await? { |
| 1421 | 2× | if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? { |
| 1422 | 2× | let summary = format!("{why}; queued again ahead of new work"); |
| 1423 | 2× | report_check(app, &row, &run.state, run.number, &before.name, "queued", &summary).await; |
| 1424 | 0× | } |
| 1425 | 0× | } |
| 1426 | 2× | Ok(true) |
| 1427 | 3× | } |
| 1428 | |
|
| 1429 | |
|
| 1430 | |
|
| 1431 | 1× | pub async fn requeue_lapsed(app: &Shared) -> sqlx::Result<usize> { |
| 1432 | 1× | let lapsed = fdb::running_on_silent_runners(&app.db, db::now_ms() - LEASE_MS).await?; |
| 1433 | 1× | let mut n = 0; |
| 1434 | 1× | for (job, runner) in lapsed { |
| 1435 | 1× | let why = format!("the runner {runner} has not been heard from for {} minutes", LEASE_MS / 60_000); |
| 1436 | 1× | if hand_back_job(app, job.id, None, &why).await? { |
| 1437 | 1× | n += 1; |
| 1438 | 1× | } |
| 1439 | | } |
| 1440 | 1× | Ok(n) |
| 1441 | 1× | } |
| 1442 | |
|
| 1443 | |
|
| 1444 | |
|
| 1445 | |
|
| 1446 | 2× | pub async fn save_checkpoint( |
| 1447 | 2× | State(app): State<Shared>, |
| 1448 | 2× | headers: HeaderMap, |
| 1449 | 2× | Path(id): Path<i64>, |
| 1450 | 2× | body: axum::body::Bytes, |
| 1451 | 2× | ) -> Result<Response, ApiError> { |
| 1452 | 2× | let runner = runner_auth(&app, &headers).await?; |
| 1453 | 2× | let job = running_job(&app, id).await?; |
| 1454 | 2× | if runner.source == "registered" && job.runner != runner.name { |
| 1455 | 0× | return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into())); |
| 1456 | 2× | } |
| 1457 | 2× | let spec: flow_core::Job = |
| 1458 | 2× | serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?; |
| 1459 | 2× | if spec.checkpoints.is_empty() { |
| 1460 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no checkpoint", job.name))); |
| 1461 | 1× | } |
| 1462 | 1× | let repo = job_repo(&app, &job).await?; |
| 1463 | 1× | let content = blake3::hash(&body).to_hex().to_string(); |
| 1464 | 1× | let size = body.len() as i64; |
| 1465 | 1× | let blob = app |
| 1466 | 1× | .repos |
| 1467 | 1× | .write(&repo, move |r, _| Ok(r.store_blob(&body)?)) |
| 1468 | 1× | .await |
| 1469 | 1× | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the checkpoint: {e}")))?; |
| 1470 | 1× | let a = fdb::Artifact { |
| 1471 | 1× | job_id: job.id, |
| 1472 | 1× | name: fdb::CHECKPOINT.into(), |
| 1473 | 1× | path: spec.checkpoints.join(" "), |
| 1474 | 1× | blob: blob.to_hex(), |
| 1475 | 1× | size, |
| 1476 | 1× | content, |
| 1477 | 1× | created_ms: db::now_ms(), |
| 1478 | 1× | }; |
| 1479 | 1× | fdb::add_artifact(&app.db, &a).await?; |
| 1480 | 1× | Ok(Json(serde_json::json!({ "blob": a.blob })).into_response()) |
| 1481 | 2× | } |
| 1482 | |
|
| 1483 | |
|
| 1484 | |
|
| 1485 | 1× | pub async fn checkpoint(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<Response, ApiError> { |
| 1486 | 1× | let runner = runner_auth(&app, &headers).await?; |
| 1487 | 1× | let job = running_job(&app, id).await?; |
| 1488 | 1× | if runner.source == "registered" && job.runner != runner.name { |
| 1489 | 0× | return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into())); |
| 1490 | 1× | } |
| 1491 | 1× | let a = fdb::checkpoint(&app.db, job.id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "this job has no checkpoint".into()))?; |
| 1492 | 1× | let repo = job_repo(&app, &job).await?; |
| 1493 | 1× | let blob = oxy_core::hash::ObjectRef::from_hex(&a.blob) |
| 1494 | 1× | .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept checkpoint has a bad blob".into()))?; |
| 1495 | 1× | let bytes = app |
| 1496 | 1× | .repos |
| 1497 | 1× | .read(&repo, move |r, _| Ok(r.read_blob(&blob)?)) |
| 1498 | 1× | .await |
| 1499 | 1× | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the checkpoint: {e}")))?; |
| 1500 | 1× | Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()) |
| 1501 | 1× | } |
| 1502 | |
|
| 1503 | |
|
| 1504 | 2× | async fn job_repo(app: &Shared, job: &fdb::Job) -> Result<RepoName, ApiError> { |
| 1505 | 2× | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 1506 | 2× | let row = |
| 1507 | 2× | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
| 1508 | 2× | RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into())) |
| 1509 | 2× | } |
| 1510 | |
|
| 1511 | |
|
| 1512 | |
|
| 1513 | |
|
| 1514 | |
|
| 1515 | 2× | pub async fn release( |
| 1516 | 2× | State(app): State<Shared>, |
| 1517 | 2× | headers: HeaderMap, |
| 1518 | 2× | Path(id): Path<i64>, |
| 1519 | 2× | Json(d): Json<flow_core::api::Decline>, |
| 1520 | 2× | ) -> Result<StatusCode, ApiError> { |
| 1521 | 2× | let me = runner_auth(&app, &headers).await?; |
| 1522 | 2× | db::runners::seen(&app.db, me.id, None, None).await?; |
| 1523 | 18× | let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect(); |
| 1524 | 2× | let why = match reason.trim() { |
| 1525 | 2× | "" => format!("handed back by {}", me.name), |
| 1526 | 2× | r => format!("handed back by {}: {r}", me.name), |
| 1527 | | }; |
| 1528 | 2× | if !hand_back_job(&app, id, Some(me.id), &why).await? { |
| 1529 | 1× | return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into())); |
| 1530 | 1× | } |
| 1531 | 1× | Ok(StatusCode::NO_CONTENT) |
| 1532 | 2× | } |
| 1533 | |
|
| 1534 | |
|
| 1535 | |
|
| 1536 | |
|
| 1537 | |
|
| 1538 | |
|
| 1539 | |
|
| 1540 | |
|
| 1541 | |
|
| 1542 | 2× | pub async fn heartbeat(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> { |
| 1543 | 2× | let me = runner_auth(&app, &headers).await?; |
| 1544 | 1× | db::runners::seen(&app.db, me.id, None, None).await?; |
| 1545 | 1× | Ok(StatusCode::NO_CONTENT) |
| 1546 | 2× | } |
| 1547 | |
|
| 1548 | |
|
| 1549 | |
|
| 1550 | 13× | pub async fn runner_update(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<flow_core::api::UpdateAnswer>, ApiError> { |
| 1551 | 13× | let me = runner_auth(&app, &headers).await?; |
| 1552 | 12× | db::runners::seen(&app.db, me.id, None, None).await?; |
| 1553 | 12× | let policy = crate::runner_updates::Levels::load(&app.db).await?.resolve(&me); |
| 1554 | 12× | let hour = time::OffsetDateTime::now_utc().hour(); |
| 1555 | 12× | let (offer, note) = crate::runner_updates::offer(&app.db, &me, &policy, hour).await?; |
| 1556 | 12× | Ok(Json(flow_core::api::UpdateAnswer { policy, offer, note })) |
| 1557 | 13× | } |
| 1558 | |
|
| 1559 | |
|
| 1560 | |
|
| 1561 | |
|
| 1562 | 2× | pub async fn report_update( |
| 1563 | 2× | State(app): State<Shared>, |
| 1564 | 2× | headers: HeaderMap, |
| 1565 | 2× | Json(r): Json<flow_core::api::UpdateReport>, |
| 1566 | 2× | ) -> Result<StatusCode, ApiError> { |
| 1567 | 2× | let me = runner_auth(&app, &headers).await?; |
| 1568 | 101× | let note: String = r.note.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect(); |
| 1569 | 2× | let note = note.trim(); |
| 1570 | 2× | if note.is_empty() { |
| 1571 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "say what happened (note)".into())); |
| 1572 | 2× | } |
| 1573 | 2× | db::runner_updates::report(&app.db, me.id, note).await?; |
| 1574 | | |
| 1575 | 2× | if let Some(v) = crate::runner_updates::rolled_back_version(note) { |
| 1576 | 1× | let why = note.split_once(": ").map(|(_, w)| w).unwrap_or("rolled back"); |
| 1577 | 1× | db::runner_updates::hold(&app.db, &v, &me.name, why).await?; |
| 1578 | 1× | db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("rolled back on {}: {why}", me.name)).await.ok(); |
| 1579 | 1× | } |
| 1580 | 2× | db::accounts::audit(&app.db, None, "runner.update", &me.name, note).await.ok(); |
| 1581 | 2× | Ok(StatusCode::NO_CONTENT) |
| 1582 | 2× | } |
| 1583 | |
|
| 1584 | 4× | pub async fn decline( |
| 1585 | 4× | State(app): State<Shared>, |
| 1586 | 4× | headers: HeaderMap, |
| 1587 | 4× | Path(id): Path<i64>, |
| 1588 | 4× | Json(d): Json<flow_core::api::Decline>, |
| 1589 | 4× | ) -> Result<StatusCode, ApiError> { |
| 1590 | 4× | let me = runner_auth(&app, &headers).await?; |
| 1591 | 4× | db::runners::seen(&app.db, me.id, None, None).await?; |
| 1592 | 137× | let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect(); |
| 1593 | 4× | let reason = match reason.trim() { |
| 1594 | 4× | "" => "no reason given".to_string(), |
| 1595 | 4× | r => r.to_string(), |
| 1596 | | }; |
| 1597 | 4× | let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else { |
| 1598 | 1× | return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into())); |
| 1599 | | }; |
| 1600 | 3× | tracing::info!("flow: runner {} declined job {id}: {reason}", me.name); |
| 1601 | 3× | let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?; |
| 1602 | 3× | let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect(); |
| 1603 | 3× | if runners.len() >= UNPLACEABLE_AFTER { |
| 1604 | 1× | let error = format!( |
| 1605 | | "unplaceable: declined by {} runners: {}", |
| 1606 | 1× | runners.len(), |
| 1607 | 3× | all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ") |
| 1608 | | ); |
| 1609 | 1× | let changed = |
| 1610 | 1× | fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
| 1611 | 1× | .await?; |
| 1612 | 1× | report_changed(&app, job.run_id, changed).await?; |
| 1613 | 2× | } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? { |
| 1614 | 2× | if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? { |
| 1615 | 2× | let summary = format!("declined by {}: {reason}; waiting for another runner", me.name); |
| 1616 | 2× | report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await; |
| 1617 | 2× | fdb::set_waiting(&app.db, id, &summary).await?; |
| 1618 | 0× | } |
| 1619 | 0× | } |
| 1620 | 3× | Ok(StatusCode::NO_CONTENT) |
| 1621 | 4× | } |
| 1622 | |
|
| 1623 | |
|
| 1624 | |
|
| 1625 | |
|
| 1626 | |
|
| 1627 | |
|
| 1628 | |
|
| 1629 | | pub struct Fleet<'a> { |
| 1630 | | pub image_archs: &'a std::collections::HashMap<String, String>, |
| 1631 | | pub hub_images: &'a std::collections::HashSet<String>, |
| 1632 | | pub now: i64, |
| 1633 | | } |
| 1634 | |
|
| 1635 | |
|
| 1636 | |
|
| 1637 | |
|
| 1638 | 32× | pub fn runner_lacks( |
| 1639 | 32× | job: &fdb::Job, |
| 1640 | 32× | owner: &str, |
| 1641 | 32× | repo_id: i64, |
| 1642 | 32× | r: &db::runners::Runner, |
| 1643 | 32× | fleet: &Fleet, |
| 1644 | 32× | declines: &[fdb::DeclineRow], |
| 1645 | 32× | ) -> Option<String> { |
| 1646 | 32× | let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default(); |
| 1647 | 32× | let rootfs = sandbox["rootfs"].as_str().unwrap_or(""); |
| 1648 | 32× | let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned()); |
| 1649 | 32× | let need = |k: &str| sandbox[k].as_u64().unwrap_or(0); |
| 1650 | 32× | if r.state == "draining" { |
| 1651 | 0× | return Some("is draining".into()); |
| 1652 | 32× | } |
| 1653 | 32× | let scope = r.scope(); |
| 1654 | 32× | if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) { |
| 1655 | 1× | return Some("serves other repositories".into()); |
| 1656 | 31× | } |
| 1657 | 31× | let tiers = r.tier_list(); |
| 1658 | 31× | if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) { |
| 1659 | 1× | return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", "))); |
| 1660 | 30× | } |
| 1661 | 30× | if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) { |
| 1662 | 1× | return Some(format!("is {}; the job needs {w}", r.arch)); |
| 1663 | 29× | } |
| 1664 | | |
| 1665 | 29× | let fetches = r.fetches_images && fleet.hub_images.contains(rootfs); |
| 1666 | 29× | if !rootfs.is_empty() && !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) { |
| 1667 | 4× | let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" }; |
| 1668 | 4× | return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())])); |
| 1669 | 25× | } |
| 1670 | 25× | let labels = r.label_list(); |
| 1671 | 25× | if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) { |
| 1672 | 0× | return Some(format!("lacks label {l}")); |
| 1673 | 25× | } |
| 1674 | 25× | if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) { |
| 1675 | 1× | return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib"))); |
| 1676 | 24× | } |
| 1677 | 24× | if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) { |
| 1678 | 0× | return Some(format!("has {c} CPUs; the job needs {}", need("cpus"))); |
| 1679 | 24× | } |
| 1680 | | |
| 1681 | 24× | if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) { |
| 1682 | 1× | if free < n { |
| 1683 | 1× | return Some(format!( |
| 1684 | 1× | "has {} free; the job needs ~{} (its recent runs took up to {})", |
| 1685 | 1× | mib(free), |
| 1686 | 1× | mib(n), |
| 1687 | 1× | mib((n - 64) * 4 / 5) |
| 1688 | 1× | )); |
| 1689 | 0× | } |
| 1690 | 23× | } |
| 1691 | 23× | if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) { |
| 1692 | 1× | return Some(format!("declined it: {}", d.reason)); |
| 1693 | 22× | } |
| 1694 | 22× | None |
| 1695 | 32× | } |
| 1696 | |
|
| 1697 | |
|
| 1698 | |
|
| 1699 | |
|
| 1700 | |
|
| 1701 | | #[allow(clippy::too_many_arguments)] |
| 1702 | 20× | pub fn why_waiting( |
| 1703 | 20× | job: &fdb::Job, |
| 1704 | 20× | owner: &str, |
| 1705 | 20× | repo_id: i64, |
| 1706 | 20× | runners: &[db::runners::Runner], |
| 1707 | 20× | image_archs: &std::collections::HashMap<String, String>, |
| 1708 | 20× | declines: &[fdb::DeclineRow], |
| 1709 | 20× | now: i64, |
| 1710 | 20× | hub_images: &std::collections::HashSet<String>, |
| 1711 | 20× | ) -> String { |
| 1712 | 20× | let fleet = Fleet { image_archs, hub_images, now }; |
| 1713 | 22× | let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect(); |
| 1714 | 20× | if online.is_empty() { |
| 1715 | 1× | return "no runner is online".into(); |
| 1716 | 19× | } |
| 1717 | 19× | let mut why = Vec::new(); |
| 1718 | 21× | for r in &online { |
| 1719 | 21× | match runner_lacks(job, owner, repo_id, r, &fleet, declines) { |
| 1720 | 11× | None => return String::new(), |
| 1721 | 10× | Some(w) => why.push(format!("{} {w}", r.name)), |
| 1722 | | } |
| 1723 | | } |
| 1724 | 8× | let more = why.len().saturating_sub(4); |
| 1725 | 8× | why.truncate(4); |
| 1726 | 8× | let mut text = format!("no runner can take it: {}", why.join("; ")); |
| 1727 | 8× | if more > 0 { |
| 1728 | 0× | text.push_str(&format!("; and {more} more")); |
| 1729 | 8× | } |
| 1730 | 8× | text |
| 1731 | 20× | } |
| 1732 | |
|
| 1733 | |
|
| 1734 | |
|
| 1735 | |
|
| 1736 | |
|
| 1737 | | #[allow(clippy::too_many_arguments)] |
| 1738 | 8× | pub fn why_still_waiting( |
| 1739 | 8× | job: &fdb::Job, |
| 1740 | 8× | owner: &str, |
| 1741 | 8× | repo_id: i64, |
| 1742 | 8× | runners: &[db::runners::Runner], |
| 1743 | 8× | fleet: (&std::collections::HashMap<String, String>, &std::collections::HashSet<String>, i64), |
| 1744 | 8× | declines: &[fdb::DeclineRow], |
| 1745 | 8× | running: &std::collections::HashMap<i64, i64>, |
| 1746 | 8× | ahead: usize, |
| 1747 | 8× | behind_urgent: &[String], |
| 1748 | 8× | ) -> String { |
| 1749 | 8× | let fleet = Fleet { image_archs: fleet.0, hub_images: fleet.1, now: fleet.2 }; |
| 1750 | 8× | let mut busy = Vec::new(); |
| 1751 | 8× | for r in runners.iter().filter(|r| r.state != "disabled" && r.online()) { |
| 1752 | 8× | if runner_lacks(job, owner, repo_id, r, &fleet, declines).is_some() { |
| 1753 | 0× | continue; |
| 1754 | 8× | } |
| 1755 | 8× | let n = running.get(&r.id).copied().unwrap_or(0); |
| 1756 | 8× | let slots = r.max_jobs.max(1); |
| 1757 | 8× | if n >= slots { |
| 1758 | 7× | busy.push(format!("{} busy ({n} of {slots})", r.name)); |
| 1759 | 7× | } else { |
| 1760 | 1× | busy.push(format!("{} has a free slot ({n} of {slots}) but not the memory or disk for it now", r.name)); |
| 1761 | 1× | } |
| 1762 | | } |
| 1763 | 8× | let mut parts = Vec::new(); |
| 1764 | 8× | if !busy.is_empty() { |
| 1765 | 8× | parts.push(format!("the runners that can take it are busy: {}", busy.join("; "))); |
| 1766 | 8× | } |
| 1767 | 8× | if ahead > 0 { |
| 1768 | 4× | let after = match behind_urgent { |
| 1769 | 4× | [] => String::new(), |
| 1770 | 1× | [one] => format!(" (after expedited run {one})"), |
| 1771 | 0× | many => format!(" (after expedited runs {})", many.join(", ")), |
| 1772 | | }; |
| 1773 | 4× | parts.push(format!("{ahead} ready {} first{after}", if ahead == 1 { "job comes" } else { "jobs come" })); |
| 1774 | 4× | } |
| 1775 | 8× | parts.join("; ") |
| 1776 | 8× | } |
| 1777 | |
|
| 1778 | |
|
| 1779 | |
|
| 1780 | | const BETTER_HOLD_MS: i64 = 15_000; |
| 1781 | |
|
| 1782 | | const POLLING_MS: i64 = 10_000; |
| 1783 | |
|
| 1784 | |
|
| 1785 | |
|
| 1786 | | static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None); |
| 1787 | |
|
| 1788 | |
|
| 1789 | |
|
| 1790 | |
|
| 1791 | |
|
| 1792 | | type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashMap<String, i64>>; |
| 1793 | | static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None); |
| 1794 | |
|
| 1795 | |
|
| 1796 | |
|
| 1797 | 330× | fn cache_kept(hub: &str, runner: i64, slot: &str) -> Option<i64> { |
| 1798 | 330× | CACHES |
| 1799 | 330× | .lock() |
| 1800 | 330× | .unwrap_or_else(|p| p.into_inner()) |
| 1801 | 330× | .as_ref() |
| 1802 | 330× | .and_then(|c| c.get(&(hub.to_string(), runner))) |
| 1803 | 330× | .and_then(|held| held.get(slot).copied()) |
| 1804 | 330× | } |
| 1805 | |
|
| 1806 | |
|
| 1807 | |
|
| 1808 | | const FRESHER_MS: i64 = 60_000; |
| 1809 | |
|
| 1810 | |
|
| 1811 | | struct Fit { |
| 1812 | | |
| 1813 | | cache: Option<i64>, |
| 1814 | | image: bool, |
| 1815 | | speed: i64, |
| 1816 | | } |
| 1817 | |
|
| 1818 | | impl Fit { |
| 1819 | | |
| 1820 | | |
| 1821 | | |
| 1822 | 17× | fn beats(&self, other: &Fit) -> bool { |
| 1823 | 17× | match (self.cache, other.cache) { |
| 1824 | 1× | (Some(_), None) => return true, |
| 1825 | 1× | (None, Some(_)) => return false, |
| 1826 | 2× | (Some(a), Some(b)) if a > 0 && b > 0 && (a - b).abs() >= FRESHER_MS => return a > b, |
| 1827 | 13× | _ => {} |
| 1828 | | } |
| 1829 | 13× | (self.image, self.speed) > (other.image, other.speed) |
| 1830 | 17× | } |
| 1831 | | } |
| 1832 | |
|
| 1833 | |
|
| 1834 | |
|
| 1835 | 175× | fn fit(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> Fit { |
| 1836 | | Fit { |
| 1837 | 175× | cache: cache_kept(hub, r.id, slot), |
| 1838 | 175× | image: r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)), |
| 1839 | 175× | speed: r.speed_class.unwrap_or(0), |
| 1840 | | } |
| 1841 | 175× | } |
| 1842 | |
|
| 1843 | |
|
| 1844 | 1× | fn ago(ms: i64) -> String { |
| 1845 | 1× | let s = ms.max(0) / 1000; |
| 1846 | 1× | match s { |
| 1847 | 1× | 0..=59 => format!("{s} s"), |
| 1848 | 1× | 60..=3599 => format!("{} min", s / 60), |
| 1849 | 0× | 3600..=172_799 => format!("{} h", s / 3600), |
| 1850 | 0× | _ => format!("{} days", s / 86_400), |
| 1851 | | } |
| 1852 | 1× | } |
| 1853 | |
|
| 1854 | 314× | fn rootfs_of(job: &fdb::Job) -> String { |
| 1855 | 314× | serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default() |
| 1856 | 314× | } |
| 1857 | |
|
| 1858 | |
|
| 1859 | 3× | fn mib(m: i64) -> String { |
| 1860 | 3× | if m >= 1024 { |
| 1861 | 3× | format!("{:.1} GiB", m as f64 / 1024.0) |
| 1862 | | } else { |
| 1863 | 0× | format!("{m} MiB") |
| 1864 | | } |
| 1865 | 3× | } |
| 1866 | |
|
| 1867 | |
|
| 1868 | 10× | pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> { |
| 1869 | 10× | let ready = fdb::ready_queued(&app.db).await?; |
| 1870 | 10× | if ready.is_empty() { |
| 1871 | 3× | return Ok(()); |
| 1872 | 7× | } |
| 1873 | 7× | let runners = db::runners::list(&app.db).await?; |
| 1874 | 7× | let archs = fdb::image_archs(&app.db).await?; |
| 1875 | 7× | let held = crate::images::held(&app.db).await?; |
| 1876 | 7× | let now = db::now_ms(); |
| 1877 | 7× | let ranks = fdb::queued_ranks(&app.db).await?; |
| 1878 | 7× | let running = fdb::running_by_runner(&app.db).await?; |
| 1879 | | |
| 1880 | 12× | let order: Vec<(i64, i64)> = ready.iter().map(|(j, _, _)| (ranks.get(&j.id).copied().unwrap_or(2), j.id)).collect(); |
| 1881 | 12× | let run_of: std::collections::HashMap<i64, i64> = ready.iter().map(|(j, _, _)| (j.id, j.run_id)).collect(); |
| 1882 | | |
| 1883 | 7× | let mut urgent: std::collections::BTreeMap<i64, String> = std::collections::BTreeMap::new(); |
| 1884 | 12× | for (j, _, _) in ready.iter().filter(|(j, _, _)| ranks.get(&j.id) == Some(&0)) { |
| 1885 | 2× | if urgent.contains_key(&j.run_id) { |
| 1886 | 1× | continue; |
| 1887 | 1× | } |
| 1888 | 1× | if let Some(run) = fdb::run_by_id(&app.db, j.run_id).await? { |
| 1889 | 1× | if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? { |
| 1890 | 1× | urgent.insert(j.run_id, format!("{}/{} #{}", row.owner, row.name, run.number)); |
| 1891 | 1× | } |
| 1892 | 0× | } |
| 1893 | | } |
| 1894 | | |
| 1895 | 7× | let mut changed = std::collections::BTreeSet::new(); |
| 1896 | 12× | for (job, owner, repo_id) in ready { |
| 1897 | 12× | let declines = fdb::declines_of_job(&app.db, job.id).await?; |
| 1898 | 12× | let mut why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held); |
| 1899 | 12× | if why.is_empty() { |
| 1900 | 8× | let mine = (ranks.get(&job.id).copied().unwrap_or(2), job.id); |
| 1901 | 20× | let ahead = order.iter().filter(|o| **o < mine).count(); |
| 1902 | 8× | let mut behind: Vec<String> = Vec::new(); |
| 1903 | 8× | if mine.0 > 0 { |
| 1904 | 14× | for o in order.iter().filter(|o| o.0 == 0 && **o < mine) { |
| 1905 | 2× | let Some(r) = run_of.get(&o.1).filter(|r| **r != job.run_id) else { continue }; |
| 1906 | 2× | if let Some(label) = urgent.get(r).filter(|l| !behind.contains(l)) { |
| 1907 | 1× | behind.push(label.clone()); |
| 1908 | 1× | } |
| 1909 | | } |
| 1910 | 2× | } |
| 1911 | 8× | why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead, &behind); |
| 1912 | 4× | } |
| 1913 | 12× | if why == job.waiting { |
| 1914 | 0× | continue; |
| 1915 | 12× | } |
| 1916 | 12× | fdb::set_waiting(&app.db, job.id, &why).await?; |
| 1917 | 12× | changed.insert(job.run_id); |
| 1918 | | |
| 1919 | | |
| 1920 | | |
| 1921 | | |
| 1922 | 1251× | let kind = |s: &str| s.chars().filter(|c| !c.is_ascii_digit()).collect::<String>(); |
| 1923 | 12× | if kind(&why) == kind(&job.waiting) { |
| 1924 | 0× | continue; |
| 1925 | 12× | } |
| 1926 | 12× | if let (Some(run), Some(row)) = (fdb::run_by_id(&app.db, job.run_id).await?, db::repos::get_by_id(&app.db, repo_id).await?) { |
| 1927 | 12× | let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() }; |
| 1928 | 12× | report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await; |
| 1929 | 0× | } |
| 1930 | | } |
| 1931 | 9× | for run_id in changed { |
| 1932 | 9× | app.bus.live(&crate::events::topic::run(run_id), "jobs"); |
| 1933 | 9× | } |
| 1934 | 7× | Ok(()) |
| 1935 | 10× | } |
| 1936 | |
|
| 1937 | |
|
| 1938 | 191× | pub async fn placement(app: Shared) { |
| 1939 | | loop { |
| 1940 | 194× | tokio::time::sleep(std::time::Duration::from_secs(15)).await; |
| 1941 | 3× | if let Err(e) = explain_waits(&app).await { |
| 1942 | 0× | tracing::warn!("flow: placement pass: {e}"); |
| 1943 | 3× | } |
| 1944 | | } |
| 1945 | | } |
| 1946 | |
|
| 1947 | |
|
| 1948 | 401× | pub(crate) async fn pins_in( |
| 1949 | 401× | app: &Shared, |
| 1950 | 401× | name: crate::repos::RepoName, |
| 1951 | 401× | heads: Vec<oxy_core::hash::ChangeHash>, |
| 1952 | 401× | ) -> Result<Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)>, String> { |
| 1953 | 401× | app.repos |
| 1954 | 401× | .read(&name, move |repo, cache| { |
| 1955 | 399× | let loaded = cache.load(repo, &heads)?; |
| 1956 | 399× | let mut out = Vec::new(); |
| 1957 | 1317× | for (path, f) in &loaded.snapshot.files { |
| 1958 | 1317× | if f.kind != oxy_core::objects::NodeKind::Subrepo { |
| 1959 | 1274× | continue; |
| 1960 | 43× | } |
| 1961 | 43× | if let oxy_core::state::FileContent::Opaque { blobs } = &f.content { |
| 1962 | 43× | if let Some(b) = blobs.first() { |
| 1963 | 43× | if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo |
| 1964 | 43× | .read_blob(b) |
| 1965 | 43× | .map_err(crate::repos::RepoError::Oxy) |
| 1966 | 43× | .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into()))) |
| 1967 | 43× | { |
| 1968 | 43× | out.push((path.clone(), id, heads)); |
| 1969 | 43× | } |
| 1970 | 0× | } |
| 1971 | 0× | } |
| 1972 | | } |
| 1973 | 399× | Ok(out) |
| 1974 | 399× | }) |
| 1975 | 401× | .await |
| 1976 | 401× | .map_err(|e| e.to_string()) |
| 1977 | 401× | } |
| 1978 | |
|
| 1979 | |
|
| 1980 | | const MAX_SUBREPO_DEPTH: usize = 3; |
| 1981 | |
|
| 1982 | |
|
| 1983 | |
|
| 1984 | |
|
| 1985 | |
|
| 1986 | |
|
| 1987 | 156× | async fn subrepo_inputs( |
| 1988 | 156× | app: &Shared, |
| 1989 | 156× | row: &db::repos::RepoRow, |
| 1990 | 156× | run: &fdb::Run, |
| 1991 | 156× | ) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) { |
| 1992 | 156× | let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) }; |
| 1993 | 156× | let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect(); |
| 1994 | 156× | let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten(); |
| 1995 | 156× | let rows = db::repos::list(&app.db).await.unwrap_or_default(); |
| 1996 | 156× | let mut inputs = Vec::new(); |
| 1997 | 156× | let mut ids = Vec::new(); |
| 1998 | 156× | let mut left_out = Vec::new(); |
| 1999 | 156× | let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() }; |
| 2000 | | |
| 2001 | 156× | let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]); |
| 2002 | 341× | while let Some((prefix, name, heads, depth)) = queue.pop_front() { |
| 2003 | 185× | if depth == MAX_SUBREPO_DEPTH { |
| 2004 | 0× | continue; |
| 2005 | 185× | } |
| 2006 | 185× | for (path, id, pinned) in pins_in(app, name, heads).await.unwrap_or_default() { |
| 2007 | 30× | let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") }; |
| 2008 | 30× | let mut hosted = false; |
| 2009 | 45× | for other in &rows { |
| 2010 | 45× | let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue }; |
| 2011 | 45× | let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false); |
| 2012 | 45× | if !matches { |
| 2013 | 15× | continue; |
| 2014 | 30× | } |
| 2015 | 30× | hosted = true; |
| 2016 | 30× | let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten(); |
| 2017 | | |
| 2018 | | |
| 2019 | | |
| 2020 | 30× | let diffused = run.actor == crate::diffusion::ACCOUNT |
| 2021 | 18× | && run.bookmark.starts_with("diffusion/") |
| 2022 | 18× | && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1 AND upstream_repo_id = $2") |
| 2023 | 18× | .bind(run.repo_id) |
| 2024 | 18× | .bind(other.id) |
| 2025 | 18× | .fetch_one(&app.db) |
| 2026 | 18× | .await |
| 2027 | 18× | .unwrap_or(0) |
| 2028 | | > 0; |
| 2029 | 30× | if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) { |
| 2030 | 29× | inputs.push(flow_core::api::SubrepoInput { |
| 2031 | 29× | path: path.clone(), |
| 2032 | 29× | repo: format!("{}/{}", other.owner, other.name), |
| 2033 | 29× | clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name), |
| 2034 | 65× | heads: pinned.iter().map(|h| h.to_hex()).collect(), |
| 2035 | | }); |
| 2036 | 29× | if !ids.contains(&other.id) { |
| 2037 | 29× | ids.push(other.id); |
| 2038 | 29× | } |
| 2039 | 29× | queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1)); |
| 2040 | 1× | } else { |
| 2041 | 1× | left_out.push(format!( |
| 2042 | 1× | "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there", |
| 2043 | 1× | other.owner, other.name |
| 2044 | 1× | )); |
| 2045 | 1× | } |
| 2046 | 30× | break; |
| 2047 | | } |
| 2048 | 30× | if !hosted { |
| 2049 | 0× | left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub")); |
| 2050 | 30× | } |
| 2051 | | } |
| 2052 | | } |
| 2053 | 156× | (inputs, ids, left_out) |
| 2054 | 156× | } |
| 2055 | |
|
| 2056 | 134× | async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> { |
| 2057 | 134× | match fdb::job(&app.db, id).await? { |
| 2058 | 134× | Some(j) if j.status == "running" => Ok(j), |
| 2059 | 3× | Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())), |
| 2060 | 0× | None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())), |
| 2061 | | } |
| 2062 | 134× | } |
| 2063 | |
|
| 2064 | |
|
| 2065 | 9× | pub async fn log( |
| 2066 | 9× | State(app): State<Shared>, |
| 2067 | 9× | headers: HeaderMap, |
| 2068 | 9× | Path(id): Path<i64>, |
| 2069 | 9× | Json(mut batch): Json<LogBatch>, |
| 2070 | 9× | ) -> Result<StatusCode, ApiError> { |
| 2071 | 9× | let me = runner_auth(&app, &headers).await?; |
| 2072 | 9× | let job = running_job(&app, id).await?; |
| 2073 | | |
| 2074 | | |
| 2075 | 8× | db::runners::seen(&app.db, me.id, None, None).await?; |
| 2076 | | |
| 2077 | | |
| 2078 | 8× | if batch.records.is_empty() { |
| 2079 | 0× | return Ok(StatusCode::NO_CONTENT); |
| 2080 | 8× | } |
| 2081 | | |
| 2082 | 8× | let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) { |
| 2083 | 8× | Ok(spec) if !spec.secrets.is_empty() => { |
| 2084 | 1× | let run = fdb::run_by_id(&app.db, job.run_id).await?; |
| 2085 | 1× | let row = match run { |
| 2086 | 1× | Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?, |
| 2087 | 0× | None => None, |
| 2088 | | }; |
| 2089 | 1× | match row { |
| 2090 | 1× | Some(row) => { |
| 2091 | 2× | let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect(); |
| 2092 | 1× | crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect() |
| 2093 | | } |
| 2094 | 0× | None => vec![], |
| 2095 | | } |
| 2096 | | } |
| 2097 | 7× | _ => vec![], |
| 2098 | | }; |
| 2099 | 8× | let kid = app.oidc.get().map(|i| i.kid.clone()); |
| 2100 | 21× | for r in &mut batch.records { |
| 2101 | 21× | if !values.is_empty() { |
| 2102 | 2× | r.text = crate::secrets::mask(&r.text, &values); |
| 2103 | 19× | } |
| 2104 | 21× | if let Some(kid) = &kid { |
| 2105 | 1× | r.text = crate::oidc::mask(&r.text, kid); |
| 2106 | 20× | } |
| 2107 | 21× | if r.text.len() > MAX_RECORD_TEXT { |
| 2108 | 1× | let mut cut = MAX_RECORD_TEXT; |
| 2109 | 1× | while !r.text.is_char_boundary(cut) { |
| 2110 | 0× | cut -= 1; |
| 2111 | 0× | } |
| 2112 | 1× | r.text.truncate(cut); |
| 2113 | 20× | } |
| 2114 | 21× | if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") { |
| 2115 | 4× | r.stream = "console".into(); |
| 2116 | 17× | } |
| 2117 | | } |
| 2118 | 8× | let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?; |
| 2119 | 8× | if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 { |
| 2120 | 0× | tracing::info!("flow: job {} reached the log limit", job.id); |
| 2121 | 8× | } |
| 2122 | 8× | Ok(StatusCode::NO_CONTENT) |
| 2123 | 9× | } |
| 2124 | |
|
| 2125 | |
|
| 2126 | | pub const MAX_ARTIFACT_BYTES: usize = 512 << 20; |
| 2127 | |
|
| 2128 | |
|
| 2129 | |
|
| 2130 | |
|
| 2131 | 15× | pub async fn artifact( |
| 2132 | 15× | State(app): State<Shared>, |
| 2133 | 15× | headers: HeaderMap, |
| 2134 | 15× | Path((id, name)): Path<(i64, String)>, |
| 2135 | 15× | body: axum::body::Bytes, |
| 2136 | 15× | ) -> Result<Response, ApiError> { |
| 2137 | 15× | runner_auth(&app, &headers).await?; |
| 2138 | 15× | let job = running_job(&app, id).await?; |
| 2139 | 15× | let spec: flow_core::Job = |
| 2140 | 15× | serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?; |
| 2141 | 15× | let Some(out) = spec.outputs.iter().find(|o| o.name == name) else { |
| 2142 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name))); |
| 2143 | | }; |
| 2144 | 14× | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 2145 | 14× | let row = |
| 2146 | 14× | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
| 2147 | 14× | let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?; |
| 2148 | | |
| 2149 | | |
| 2150 | 14× | let report = match out.kind { |
| 2151 | 2× | flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?), |
| 2152 | 12× | flow_core::OutputKind::Artifact => None, |
| 2153 | | }; |
| 2154 | 13× | let content = blake3::hash(&body).to_hex().to_string(); |
| 2155 | 13× | let size = body.len() as i64; |
| 2156 | 13× | let artifact = report.is_none(); |
| 2157 | 13× | let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone()); |
| 2158 | 13× | let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone()); |
| 2159 | 13× | let blob = app |
| 2160 | 13× | .repos |
| 2161 | 13× | .write(&repo, move |r, _| Ok(r.store_blob(&body)?)) |
| 2162 | 13× | .await |
| 2163 | 13× | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?; |
| 2164 | 13× | let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() }; |
| 2165 | 13× | fdb::add_artifact(&app.db, &a).await?; |
| 2166 | 13× | if let Some((manifest, declaration)) = report { |
| 2167 | 1× | let origin = crate::reports::Origin { |
| 2168 | 1× | repo_id: row.id, |
| 2169 | 1× | run_id: run.id, |
| 2170 | 1× | run_number: run.number, |
| 2171 | 1× | job_id: job.id, |
| 2172 | 1× | job: &job.name, |
| 2173 | 1× | bookmark: &run.bookmark, |
| 2174 | 1× | state: &run.state, |
| 2175 | 1× | }; |
| 2176 | 1× | let said = match declaration { |
| 2177 | 1× | Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await { |
| 2178 | 1× | Ok(id) => { |
| 2179 | 1× | report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await; |
| 2180 | 1× | let s = if d.metrics.len() == 1 { "" } else { "s" }; |
| 2181 | 1× | format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len()) |
| 2182 | | } |
| 2183 | 0× | Err(e) => format!("report {}: not indexed: {e}", a.name), |
| 2184 | | }, |
| 2185 | 0× | Err(why) => { |
| 2186 | 0× | let d = crate::reports::Declaration::unreadable(&a.name, &why); |
| 2187 | 0× | crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok(); |
| 2188 | 0× | format!("report {}: report.toml: {why}", a.name) |
| 2189 | | } |
| 2190 | | }; |
| 2191 | 1× | let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said }; |
| 2192 | 1× | fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok(); |
| 2193 | 12× | } |
| 2194 | | |
| 2195 | | |
| 2196 | 13× | if let Some(bytes) = report_bytes { |
| 2197 | 3× | let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await { |
| 2198 | 3× | Ok(text) | Err(text) => text, |
| 2199 | | }; |
| 2200 | 3× | let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said }; |
| 2201 | 3× | fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok(); |
| 2202 | 10× | } |
| 2203 | | |
| 2204 | 13× | if let Some(bytes) = junit_bytes { |
| 2205 | 4× | let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await { |
| 2206 | 4× | Ok(text) | Err(text) => text, |
| 2207 | | }; |
| 2208 | 4× | let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said }; |
| 2209 | 4× | fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok(); |
| 2210 | 9× | } |
| 2211 | 13× | Ok(Json(serde_json::json!({ "blob": a.blob })).into_response()) |
| 2212 | 15× | } |
| 2213 | |
|
| 2214 | |
|
| 2215 | |
|
| 2216 | |
|
| 2217 | 1× | async fn report_threshold_checks( |
| 2218 | 1× | app: &Shared, |
| 2219 | 1× | row: &db::repos::RepoRow, |
| 2220 | 1× | run: &fdb::Run, |
| 2221 | 1× | name: &str, |
| 2222 | 1× | id: i64, |
| 2223 | 1× | metrics: &[crate::reports::Metric], |
| 2224 | 1× | ) { |
| 2225 | 1× | let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name); |
| 2226 | 1× | for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) { |
| 2227 | 1× | let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) }; |
| 2228 | 1× | let above = m.fail_above.filter(|a| m.value > *a); |
| 2229 | 1× | let below = m.fail_below.filter(|b| m.value < *b); |
| 2230 | 1× | let (status, summary) = match (above, below) { |
| 2231 | 0× | (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)), |
| 2232 | 0× | (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)), |
| 2233 | | _ => { |
| 2234 | 1× | let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))] |
| 2235 | 1× | .into_iter() |
| 2236 | 1× | .flatten() |
| 2237 | 1× | .collect(); |
| 2238 | 1× | ("passed", format!("{}{unit} ({})", m.value, limits.join(", "))) |
| 2239 | | } |
| 2240 | | }; |
| 2241 | 1× | let check = format!("report/{name}/{}", m.key); |
| 2242 | 1× | if let Ok(c) = db::hooks::upsert_check( |
| 2243 | 1× | &app.db, |
| 2244 | 1× | row.id, |
| 2245 | 1× | db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" }, |
| 2246 | | ) |
| 2247 | 1× | .await |
| 2248 | | { |
| 2249 | 1× | let api = crate::api::check_of(&c); |
| 2250 | 1× | app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
| 2251 | 1× | crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await; |
| 2252 | 0× | } |
| 2253 | | } |
| 2254 | 1× | } |
| 2255 | |
|
| 2256 | |
|
| 2257 | |
|
| 2258 | |
|
| 2259 | 2× | async fn report_manifest( |
| 2260 | 2× | app: &Shared, |
| 2261 | 2× | repo: &RepoName, |
| 2262 | 2× | name: &str, |
| 2263 | 2× | body: &[u8], |
| 2264 | 2× | ) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> { |
| 2265 | 2× | let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?; |
| 2266 | 2× | let refs: Vec<(String, oxy_core::hash::ObjectRef)> = |
| 2267 | 7× | manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect(); |
| 2268 | 2× | let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex); |
| 2269 | 2× | let found = app |
| 2270 | 2× | .repos |
| 2271 | 2× | .read(repo, move |r, _| { |
| 2272 | 7× | if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) { |
| 2273 | 1× | return Ok(Err(path.clone())); |
| 2274 | 1× | } |
| 2275 | 1× | Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?)) |
| 2276 | 2× | }) |
| 2277 | 2× | .await |
| 2278 | 2× | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?; |
| 2279 | 2× | let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?; |
| 2280 | 1× | let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default()); |
| 2281 | 1× | Ok((manifest, declaration)) |
| 2282 | 2× | } |
| 2283 | |
|
| 2284 | |
|
| 2285 | |
|
| 2286 | |
|
| 2287 | |
|
| 2288 | 5× | pub async fn report_file( |
| 2289 | 5× | State(app): State<Shared>, |
| 2290 | 5× | headers: HeaderMap, |
| 2291 | 5× | Path((id, name)): Path<(i64, String)>, |
| 2292 | 5× | body: axum::body::Bytes, |
| 2293 | 5× | ) -> Result<Response, ApiError> { |
| 2294 | 5× | runner_auth(&app, &headers).await?; |
| 2295 | 5× | let job = running_job(&app, id).await?; |
| 2296 | 5× | let spec: flow_core::Job = |
| 2297 | 5× | serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?; |
| 2298 | 6× | if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) { |
| 2299 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name))); |
| 2300 | 4× | } |
| 2301 | 4× | let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default(); |
| 2302 | 4× | if !crate::reports::path_ok(path) { |
| 2303 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path"))); |
| 2304 | 3× | } |
| 2305 | 3× | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 2306 | 3× | let row = |
| 2307 | 3× | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
| 2308 | 3× | let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?; |
| 2309 | 3× | let blob = app |
| 2310 | 3× | .repos |
| 2311 | 3× | .write(&repo, move |r, _| Ok(r.store_blob(&body)?)) |
| 2312 | 3× | .await |
| 2313 | 3× | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?; |
| 2314 | 3× | Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response()) |
| 2315 | 5× | } |
| 2316 | |
|
| 2317 | |
|
| 2318 | 4× | async fn read_junit( |
| 2319 | 4× | app: &Shared, |
| 2320 | 4× | row: &db::repos::RepoRow, |
| 2321 | 4× | run: &fdb::Run, |
| 2322 | 4× | job: &fdb::Job, |
| 2323 | 4× | blob: &str, |
| 2324 | 4× | bytes: axum::body::Bytes, |
| 2325 | 4× | ) -> Result<String, String> { |
| 2326 | 4× | if bytes.len() > crate::junit::MAX_REPORT { |
| 2327 | 0× | return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20)); |
| 2328 | 4× | } |
| 2329 | 4× | let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?; |
| 2330 | 4× | let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text)) |
| 2331 | 4× | .await |
| 2332 | 4× | .map_err(|e| format!("tests: {e}"))? |
| 2333 | 4× | .map_err(|e| format!("tests: {e}"))?; |
| 2334 | 4× | let origin = crate::junit::Origin { |
| 2335 | 4× | repo_id: row.id, |
| 2336 | 4× | run_id: run.id, |
| 2337 | 4× | run_number: run.number, |
| 2338 | 4× | job_id: job.id, |
| 2339 | 4× | job: &job.name, |
| 2340 | 4× | bookmark: &run.bookmark, |
| 2341 | 4× | state: &run.state, |
| 2342 | 4× | blob, |
| 2343 | 4× | }; |
| 2344 | 4× | crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?; |
| 2345 | 4× | let r = crate::junit::for_run(&app.db, run.id) |
| 2346 | 4× | .await |
| 2347 | 4× | .map_err(|e| format!("tests: {e}"))? |
| 2348 | 4× | .into_iter() |
| 2349 | 4× | .find(|r| r.job_id == job.id) |
| 2350 | 4× | .ok_or_else(|| "tests: the report was not kept".to_string())?; |
| 2351 | | |
| 2352 | | |
| 2353 | | |
| 2354 | 4× | let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?; |
| 2355 | 4× | let newly = match &base { |
| 2356 | 2× | Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?, |
| 2357 | 3× | _ => vec![], |
| 2358 | | }; |
| 2359 | 4× | let (status, summary) = if r.broken() == 0 { |
| 2360 | 2× | ("passed", format!("{} passed", r.passed)) |
| 2361 | 2× | } else if let Some(b) = &base { |
| 2362 | 1× | let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect(); |
| 2363 | 1× | let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() }; |
| 2364 | 1× | let new = if newly.is_empty() { |
| 2365 | 0× | format!("none new against {} run #{}", row.default_bookmark, b.run_number) |
| 2366 | | } else { |
| 2367 | 1× | format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", ")) |
| 2368 | | }; |
| 2369 | 1× | ("failed", format!("{} failing; {new}", r.broken())) |
| 2370 | | } else { |
| 2371 | 1× | ("failed", format!("{} failing", r.broken())) |
| 2372 | | }; |
| 2373 | 4× | let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id); |
| 2374 | 4× | if let Ok(c) = db::hooks::upsert_check( |
| 2375 | 4× | &app.db, |
| 2376 | 4× | row.id, |
| 2377 | 4× | db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" }, |
| 2378 | | ) |
| 2379 | 4× | .await |
| 2380 | | { |
| 2381 | 4× | let api = crate::api::check_of(&c); |
| 2382 | 4× | app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
| 2383 | 4× | crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await; |
| 2384 | 0× | } |
| 2385 | 4× | Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms))) |
| 2386 | 4× | } |
| 2387 | |
|
| 2388 | |
|
| 2389 | 3× | async fn read_coverage( |
| 2390 | 3× | app: &Shared, |
| 2391 | 3× | repo: &RepoName, |
| 2392 | 3× | row: &db::repos::RepoRow, |
| 2393 | 3× | run: &fdb::Run, |
| 2394 | 3× | job: &fdb::Job, |
| 2395 | 3× | blob: &str, |
| 2396 | 3× | bytes: axum::body::Bytes, |
| 2397 | 3× | ) -> Result<String, String> { |
| 2398 | 3× | if bytes.len() > crate::coverage::MAX_REPORT { |
| 2399 | 0× | return Err(format!( |
| 2400 | 0× | "coverage: the report is {} MiB; at most {} MiB is read", |
| 2401 | 0× | bytes.len() >> 20, |
| 2402 | 0× | crate::coverage::MAX_REPORT >> 20 |
| 2403 | 0× | )); |
| 2404 | 3× | } |
| 2405 | 3× | let text = String::from_utf8(bytes.to_vec()) |
| 2406 | 3× | .map_err(|_| "coverage: the report is not text (LCOV, Cobertura XML or JaCoCo XML is expected)".to_string())?; |
| 2407 | 3× | let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text)) |
| 2408 | 3× | .await |
| 2409 | 3× | .map_err(|e| format!("coverage: {e}"))? |
| 2410 | 3× | .map_err(|e| format!("coverage: {e}"))?; |
| 2411 | 2× | let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect(); |
| 2412 | 2× | let in_state: std::collections::HashSet<String> = app |
| 2413 | 2× | .repos |
| 2414 | 2× | .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect())) |
| 2415 | 2× | .await |
| 2416 | 2× | .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?; |
| 2417 | 2× | let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect(); |
| 2418 | 2× | let origin = crate::coverage::Origin { |
| 2419 | 2× | repo_id: row.id, |
| 2420 | 2× | run_id: Some(run.id), |
| 2421 | 2× | run_number: run.number, |
| 2422 | 2× | job_id: Some(job.id), |
| 2423 | 2× | uploader: "", |
| 2424 | 2× | job: &job.name, |
| 2425 | 2× | bookmark: &run.bookmark, |
| 2426 | 2× | state: &run.state, |
| 2427 | 2× | heads: &heads, |
| 2428 | 2× | blob, |
| 2429 | 2× | format, |
| 2430 | 2× | }; |
| 2431 | 2× | let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?; |
| 2432 | 2× | let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else { |
| 2433 | 0× | return Err("coverage: the report was not kept".into()); |
| 2434 | | }; |
| 2435 | | |
| 2436 | | |
| 2437 | 2× | let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?; |
| 2438 | 2× | let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?; |
| 2439 | 2× | let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules); |
| 2440 | 2× | let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id); |
| 2441 | 2× | if let Ok(c) = db::hooks::upsert_check( |
| 2442 | 2× | &app.db, |
| 2443 | 2× | row.id, |
| 2444 | 2× | db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" }, |
| 2445 | | ) |
| 2446 | 2× | .await |
| 2447 | | { |
| 2448 | 2× | let api = crate::api::check_of(&c); |
| 2449 | 2× | app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
| 2450 | 2× | crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await; |
| 2451 | 0× | } |
| 2452 | 2× | let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into()); |
| 2453 | 2× | let dropped = |
| 2454 | 2× | if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() }; |
| 2455 | 2× | Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files)) |
| 2456 | 3× | } |
| 2457 | |
|
| 2458 | |
|
| 2459 | 99× | pub async fn finish( |
| 2460 | 99× | State(app): State<Shared>, |
| 2461 | 99× | headers: HeaderMap, |
| 2462 | 99× | Path(id): Path<i64>, |
| 2463 | 99× | Json(f): Json<Finish>, |
| 2464 | 99× | ) -> Result<StatusCode, ApiError> { |
| 2465 | 99× | let me = runner_auth(&app, &headers).await?; |
| 2466 | 99× | let job = running_job(&app, id).await?; |
| 2467 | 98× | db::runners::seen(&app.db, me.id, None, None).await?; |
| 2468 | 98× | let status = match (f.exit_code, &f.error) { |
| 2469 | 86× | (Some(0), None) => "passed", |
| 2470 | 6× | (Some(_), _) => "failed", |
| 2471 | 6× | (None, _) => "error", |
| 2472 | | }; |
| 2473 | 95× | let (receipt, receipt_hash) = match &f.receipt { |
| 2474 | 87× | Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => { |
| 2475 | | |
| 2476 | | |
| 2477 | | |
| 2478 | 86× | let kept: Vec<String> = |
| 2479 | 86× | if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] }; |
| 2480 | 86× | if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept { |
| 2481 | 2× | return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into())); |
| 2482 | 84× | } |
| 2483 | 84× | (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash())) |
| 2484 | | } |
| 2485 | 1× | Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())), |
| 2486 | 11× | None => (String::new(), String::new()), |
| 2487 | | }; |
| 2488 | 95× | let error = f.error.clone().unwrap_or_default(); |
| 2489 | 95× | fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?; |
| 2490 | 95× | let changed = fdb::finish( |
| 2491 | 95× | &app.db, |
| 2492 | 95× | job.id, |
| 2493 | 95× | fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash }, |
| 2494 | 95× | ) |
| 2495 | 95× | .await?; |
| 2496 | 95× | if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? { |
| 2497 | 95× | if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? { |
| 2498 | 95× | if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) { |
| 2499 | 8× | let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await { |
| 2500 | 4× | Ok((attest, signer)) => (attest, signer, String::new()), |
| 2501 | 4× | Err(e) => { |
| 2502 | 4× | tracing::warn!("flow: job {} receipt not kept: {e}", job.id); |
| 2503 | 4× | (String::new(), String::new(), e) |
| 2504 | | } |
| 2505 | | }; |
| 2506 | 8× | fdb::set_receipt(&app.db, job.id, &attest, &signer, ¬e).await?; |
| 2507 | 87× | } |
| 2508 | 0× | } |
| 2509 | 0× | } |
| 2510 | 95× | report_changed(&app, job.run_id, changed).await?; |
| 2511 | 95× | Ok(StatusCode::NO_CONTENT) |
| 2512 | 99× | } |
| 2513 | |
|
| 2514 | |
|
| 2515 | 122× | async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> { |
| 2516 | 122× | let Some(run) = fdb::run_by_id(&app.db, run_id).await? else { |
| 2517 | 0× | return Ok(()); |
| 2518 | | }; |
| 2519 | 122× | let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else { |
| 2520 | 0× | return Ok(()); |
| 2521 | | }; |
| 2522 | 122× | let mut errored_on: Vec<i64> = Vec::new(); |
| 2523 | 154× | let some_passed = changed.iter().any(|j| j.status == "passed"); |
| 2524 | 122× | if !changed.is_empty() { |
| 2525 | 122× | app.bus.live(&crate::events::topic::run(run_id), "jobs"); |
| 2526 | 122× | } |
| 2527 | 160× | for j in changed { |
| 2528 | 160× | let summary = match j.status.as_str() { |
| 2529 | 160× | "passed" => match j.reused_from() { |
| 2530 | 9× | Some(n) => format!("passed in run #{n} on this state, reused"), |
| 2531 | 84× | None => format!("passed in {}", duration(&j)), |
| 2532 | | }, |
| 2533 | 67× | "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)), |
| 2534 | 62× | "skipped" => "skipped: a dependency did not pass".into(), |
| 2535 | 54× | "cancelled" => j.error.clone(), |
| 2536 | 12× | _ => format!("could not run: {}", j.error), |
| 2537 | | }; |
| 2538 | 160× | report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await; |
| 2539 | 160× | if j.status == "error" { |
| 2540 | 12× | if let Some(r) = runner_of(app, j.id).await { |
| 2541 | 11× | errored_on.push(r); |
| 2542 | 11× | } |
| 2543 | 148× | } |
| 2544 | | } |
| 2545 | 122× | errored_on.dedup(); |
| 2546 | 122× | for r in errored_on { |
| 2547 | 11× | drain_if_erroring(app, r).await; |
| 2548 | | } |
| 2549 | 122× | if some_passed { |
| 2550 | 87× | tell_approvers(app, &row, &run).await; |
| 2551 | 35× | } |
| 2552 | 122× | if let Some(done) = fdb::run_by_id(&app.db, run_id).await? { |
| 2553 | 122× | if done.status == "failed" && !done.live() { |
| 2554 | 11× | tell_failed(app, &row, &done).await; |
| 2555 | 111× | } |
| 2556 | | |
| 2557 | 122× | if done.status == "passed" { |
| 2558 | 31× | let before: Option<String> = sqlx::query_scalar( |
| 2559 | 31× | "SELECT status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND id < $3 AND status IN ('passed', 'failed') |
| 2560 | 31× | ORDER BY id DESC LIMIT 1", |
| 2561 | 31× | ) |
| 2562 | 31× | .bind(done.repo_id) |
| 2563 | 31× | .bind(&done.bookmark) |
| 2564 | 31× | .bind(done.id) |
| 2565 | 31× | .fetch_optional(&app.db) |
| 2566 | 31× | .await?; |
| 2567 | | |
| 2568 | 31× | let url = format!("/{}/{}/flow/{}", row.owner, row.name, done.number); |
| 2569 | 31× | let failed_here: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE kind = 'flow.failed' AND url = $1") |
| 2570 | 31× | .bind(&url) |
| 2571 | 31× | .fetch_one(&app.db) |
| 2572 | 31× | .await?; |
| 2573 | 31× | if before.as_deref() == Some("failed") || failed_here > 0 { |
| 2574 | 2× | tell_fixed(app, &row, &done).await; |
| 2575 | 29× | } |
| 2576 | 91× | } |
| 2577 | 0× | } |
| 2578 | | |
| 2579 | | |
| 2580 | 122× | if let Some(done) = fdb::run_by_id(&app.db, run_id).await? { |
| 2581 | 122× | if done.status == "passed" && done.bookmark == row.default_bookmark { |
| 2582 | 41× | let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect(); |
| 2583 | 25× | let (app, row) = (app.clone(), row.clone()); |
| 2584 | 25× | tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await }); |
| 2585 | 97× | } |
| 2586 | | |
| 2587 | | |
| 2588 | 122× | if let Some(path) = done.bookmark.strip_prefix("diffusion/").filter(|_| done.status == "passed") { |
| 2589 | 6× | tokio::spawn(crate::diffusion::try_land(app.clone(), row.clone(), path.to_string())); |
| 2590 | 116× | } |
| 2591 | 0× | } |
| 2592 | 122× | Ok(()) |
| 2593 | 122× | } |
| 2594 | |
|
| 2595 | |
|
| 2596 | |
|
| 2597 | | pub const ACCOUNT: &str = "oxygenflow"; |
| 2598 | |
|
| 2599 | 14× | async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> { |
| 2600 | 14× | if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await { |
| 2601 | 7× | return Some(a); |
| 2602 | 7× | } |
| 2603 | 7× | db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok() |
| 2604 | 14× | } |
| 2605 | |
|
| 2606 | 12× | async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> { |
| 2607 | 12× | sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = $1").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten() |
| 2608 | 12× | } |
| 2609 | |
|
| 2610 | |
|
| 2611 | |
|
| 2612 | |
|
| 2613 | 11× | async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) { |
| 2614 | 11× | let Some(me) = flow_account(&app.db).await else { return }; |
| 2615 | 11× | let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default(); |
| 2616 | 27× | let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect(); |
| 2617 | 11× | let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten(); |
| 2618 | 11× | let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) }; |
| 2619 | 11× | crate::notify::deliver( |
| 2620 | 11× | app, |
| 2621 | | crate::notify::Event { |
| 2622 | 11× | kind: "flow.failed", |
| 2623 | 11× | title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")), |
| 2624 | 11× | url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number), |
| 2625 | 11× | actor: &me, |
| 2626 | 11× | repo_id: row.id, |
| 2627 | 11× | thread: None, |
| 2628 | 11× | mention_text: "", |
| 2629 | 11× | direct: pusher.into_iter().map(|a| a.id).collect(), |
| 2630 | | }, |
| 2631 | | ) |
| 2632 | 11× | .await; |
| 2633 | 11× | } |
| 2634 | |
|
| 2635 | |
|
| 2636 | |
|
| 2637 | 2× | async fn tell_fixed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) { |
| 2638 | 2× | let Some(me) = flow_account(&app.db).await else { return }; |
| 2639 | 2× | let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten(); |
| 2640 | 2× | let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) }; |
| 2641 | 2× | crate::notify::deliver( |
| 2642 | 2× | app, |
| 2643 | | crate::notify::Event { |
| 2644 | 2× | kind: "flow.fixed", |
| 2645 | 2× | title: format!("{}/{}: run #{}{on} passed: fixed", row.owner, row.name, run.number), |
| 2646 | 2× | url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number), |
| 2647 | 2× | actor: &me, |
| 2648 | 2× | repo_id: row.id, |
| 2649 | 2× | thread: None, |
| 2650 | 2× | mention_text: "", |
| 2651 | 2× | direct: pusher.into_iter().map(|a| a.id).collect(), |
| 2652 | | }, |
| 2653 | | ) |
| 2654 | 2× | .await; |
| 2655 | 2× | } |
| 2656 | |
|
| 2657 | |
|
| 2658 | |
|
| 2659 | |
|
| 2660 | 11× | async fn drain_if_erroring(app: &Shared, runner_id: i64) { |
| 2661 | 11× | let n = app.config.flow.drain_after_errors; |
| 2662 | 11× | let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return }; |
| 2663 | 1× | let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return }; |
| 2664 | 1× | if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() { |
| 2665 | 0× | return; |
| 2666 | 1× | } |
| 2667 | 1× | tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name); |
| 2668 | 1× | let Some(me) = flow_account(&app.db).await else { return }; |
| 2669 | 1× | let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = TRUE").fetch_all(&app.db).await.unwrap_or_default(); |
| 2670 | 1× | crate::notify::deliver( |
| 2671 | 1× | app, |
| 2672 | 1× | crate::notify::Event { |
| 2673 | 1× | kind: "flow.runner_drained", |
| 2674 | 1× | title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name), |
| 2675 | 1× | url: format!("/admin/runners/{runner_id}"), |
| 2676 | 1× | actor: &me, |
| 2677 | 1× | repo_id: 0, |
| 2678 | 1× | thread: None, |
| 2679 | 1× | mention_text: "", |
| 2680 | 1× | direct: admins, |
| 2681 | 1× | }, |
| 2682 | 1× | ) |
| 2683 | 1× | .await; |
| 2684 | 11× | } |
| 2685 | |
|
| 2686 | |
|
| 2687 | |
|
| 2688 | | pub struct Limits { |
| 2689 | | |
| 2690 | | pub default_s: u64, |
| 2691 | | |
| 2692 | | pub max_s: u64, |
| 2693 | | |
| 2694 | | |
| 2695 | | |
| 2696 | | |
| 2697 | | pub grace_ms: i64, |
| 2698 | | } |
| 2699 | |
|
| 2700 | | pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes"; |
| 2701 | | pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes"; |
| 2702 | | pub const SETTING_GRACE: &str = "flow.lost_grace_minutes"; |
| 2703 | |
|
| 2704 | 164× | pub async fn limits(app: &Shared) -> Limits { |
| 2705 | 492× | let minutes = |key: &'static str| async move { |
| 2706 | 492× | db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0) |
| 2707 | 984× | }; |
| 2708 | 164× | let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1)); |
| 2709 | 164× | let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m); |
| 2710 | 164× | let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5); |
| 2711 | 164× | Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 } |
| 2712 | 164× | } |
| 2713 | |
|
| 2714 | |
|
| 2715 | |
|
| 2716 | |
|
| 2717 | 3× | pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> { |
| 2718 | 3× | let limits = limits(app).await; |
| 2719 | 3× | let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?; |
| 2720 | 3× | let n = lost.len(); |
| 2721 | 3× | for job in lost { |
| 2722 | 3× | let error = format!( |
| 2723 | | "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it", |
| 2724 | 3× | (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000, |
| 2725 | 3× | if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) }, |
| 2726 | | ); |
| 2727 | 3× | let changed = |
| 2728 | 3× | fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
| 2729 | 3× | .await?; |
| 2730 | 3× | tracing::warn!("flow: job {} {error}", job.id); |
| 2731 | 3× | report_changed(app, job.run_id, changed).await?; |
| 2732 | | } |
| 2733 | 3× | Ok(n) |
| 2734 | 3× | } |
| 2735 | |
|
| 2736 | |
|
| 2737 | |
|
| 2738 | |
|
| 2739 | 2× | async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> { |
| 2740 | 3× | for job in fdb::running_on(&app.db, runner.id).await? { |
| 2741 | 3× | if holding.contains(&job.id) { |
| 2742 | 1× | continue; |
| 2743 | 2× | } |
| 2744 | 2× | let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name); |
| 2745 | 2× | let changed = |
| 2746 | 2× | fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
| 2747 | 2× | .await?; |
| 2748 | 2× | tracing::warn!("flow: job {} {error}", job.id); |
| 2749 | 2× | report_changed(app, job.run_id, changed).await?; |
| 2750 | | } |
| 2751 | 2× | Ok(()) |
| 2752 | 2× | } |
| 2753 | |
|
| 2754 | |
|
| 2755 | |
|
| 2756 | |
|
| 2757 | 6× | pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize { |
| 2758 | 6× | let Ok(rows) = db::repos::list(&app.db).await else { return 0 }; |
| 2759 | 6× | let mut started = 0; |
| 2760 | 6× | for row in rows.into_iter().filter(|r| enabled(app, r)) { |
| 2761 | 4× | let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue }; |
| 2762 | 4× | let b = row.default_bookmark.clone(); |
| 2763 | 4× | let found = app |
| 2764 | 4× | .repos |
| 2765 | 4× | .read(&repo, move |r, _| { |
| 2766 | 4× | let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) }; |
| 2767 | 4× | Ok(Some((st, r.store().get_state(&st)?.heads))) |
| 2768 | 4× | }) |
| 2769 | 4× | .await; |
| 2770 | 4× | let Ok(Some((state, heads))) = found else { continue }; |
| 2771 | 4× | if heads.is_empty() { |
| 2772 | 0× | continue; |
| 2773 | 4× | } |
| 2774 | 4× | match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await { |
| 2775 | 1× | Ok(Some(_)) => started += 1, |
| 2776 | 3× | Ok(None) => {} |
| 2777 | 0× | Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name), |
| 2778 | | } |
| 2779 | | } |
| 2780 | 6× | started |
| 2781 | 6× | } |
| 2782 | |
|
| 2783 | |
|
| 2784 | 2× | pub fn when_now() -> flow_core::pipeline::When { |
| 2785 | 2× | let now = time::OffsetDateTime::now_utc(); |
| 2786 | 2× | flow_core::pipeline::When { |
| 2787 | 2× | minute: now.minute() as u32, |
| 2788 | 2× | hour: now.hour() as u32, |
| 2789 | 2× | day: now.day() as u32, |
| 2790 | 2× | month: u8::from(now.month()) as u32, |
| 2791 | 2× | weekday: now.weekday().number_days_from_sunday() as u32, |
| 2792 | 2× | } |
| 2793 | 2× | } |
| 2794 | |
|
| 2795 | |
|
| 2796 | 191× | pub async fn scheduler(app: Shared) { |
| 2797 | 191× | let mut last = None; |
| 2798 | | loop { |
| 2799 | 194× | tokio::time::sleep(std::time::Duration::from_secs(15)).await; |
| 2800 | 3× | let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60; |
| 2801 | 3× | if last == Some(minute) { |
| 2802 | 1× | continue; |
| 2803 | 2× | } |
| 2804 | 2× | last = Some(minute); |
| 2805 | 2× | let n = run_due(&app, when_now()).await; |
| 2806 | 2× | if n > 0 { |
| 2807 | 0× | tracing::info!("flow: {n} scheduled run(s) started"); |
| 2808 | 2× | } |
| 2809 | | } |
| 2810 | | } |
| 2811 | |
|
| 2812 | |
|
| 2813 | 191× | pub async fn reaper(app: Shared) { |
| 2814 | | loop { |
| 2815 | 191× | tokio::time::sleep(std::time::Duration::from_secs(60)).await; |
| 2816 | 0× | if let Err(e) = requeue_lapsed(&app).await { |
| 2817 | 0× | tracing::warn!("flow: looking for lapsed leases: {e}"); |
| 2818 | 0× | } |
| 2819 | 0× | if let Err(e) = reap_lost_jobs(&app).await { |
| 2820 | 0× | tracing::warn!("flow: looking for lost jobs: {e}"); |
| 2821 | 0× | } |
| 2822 | 0× | match expire_approvals(&app, db::now_ms()).await { |
| 2823 | 0× | Ok(n) if n > 0 => tracing::info!("flow: {n} approvals expired"), |
| 2824 | 0× | Ok(_) => {} |
| 2825 | 0× | Err(e) => tracing::warn!("flow: expiring approvals: {e}"), |
| 2826 | | } |
| 2827 | | } |
| 2828 | | } |
| 2829 | |
|
| 2830 | |
|
| 2831 | |
|
| 2832 | |
|
| 2833 | |
|
| 2834 | |
|
| 2835 | 8× | pub async fn keep_receipt( |
| 2836 | 8× | app: &Shared, |
| 2837 | 8× | runner: &db::runners::Runner, |
| 2838 | 8× | row: &db::repos::RepoRow, |
| 2839 | 8× | run: &fdb::Run, |
| 2840 | 8× | receipt: &flow_core::Receipt, |
| 2841 | 8× | signed_hex: &str, |
| 2842 | 8× | ) -> Result<(String, String), String> { |
| 2843 | | use oxy_core::receipt::SignedReceipt; |
| 2844 | 8× | let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?; |
| 2845 | 8× | let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?; |
| 2846 | 8× | sr.verify().map_err(|e| format!("it does not verify: {e}"))?; |
| 2847 | 8× | if sr.receipt.hash().0 != receipt.hash() { |
| 2848 | 0× | return Err("it signs another receipt than the job's".into()); |
| 2849 | 8× | } |
| 2850 | 8× | let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect(); |
| 2851 | 8× | let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode()); |
| 2852 | 8× | if sr.state() != state { |
| 2853 | 1× | return Err("it is for another state than the run's".into()); |
| 2854 | 7× | } |
| 2855 | 7× | let signer = match &sr.certificate { |
| 2856 | | |
| 2857 | | |
| 2858 | 4× | Some(c) => { |
| 2859 | 4× | let cert = &c.cert.cert; |
| 2860 | 4× | if cert.issuer != app.hub_key.key || c.issuer.hash() != app.hub_key.key { |
| 2861 | 1× | return Err("its certificate was not issued by this hub".into()); |
| 2862 | 3× | } |
| 2863 | 3× | let subject = format!("repo:{}/{}:bookmark:{}", row.owner, row.name, run.bookmark); |
| 2864 | 3× | if cert.subject != subject { |
| 2865 | 0× | return Err(format!("its certificate is for {}, not {subject}", cert.subject)); |
| 2866 | 3× | } |
| 2867 | 3× | if cert.claim("run") != Some(run.number.to_string().as_str()) || cert.claim("job") != Some(sr.receipt.job_name.as_str()) { |
| 2868 | 1× | return Err("its certificate is for another run or job".into()); |
| 2869 | 2× | } |
| 2870 | | |
| 2871 | 2× | if !cert.covers(oxy_core::identity::Scope::SEAL, db::now_ms() as u64) { |
| 2872 | 0× | return Err("its certificate does not cover now: certify the key just before signing the receipt".into()); |
| 2873 | 2× | } |
| 2874 | 2× | format!("{} (short-lived key)", runner.name) |
| 2875 | | } |
| 2876 | | None => { |
| 2877 | 3× | let key = sr.runner.hash().to_hex(); |
| 2878 | 3× | let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?; |
| 2879 | 3× | if bound != key { |
| 2880 | 1× | return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())])); |
| 2881 | 2× | } |
| 2882 | 2× | sr.runner.name.clone() |
| 2883 | | } |
| 2884 | | }; |
| 2885 | 4× | let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
| 2886 | 4× | let (rid, signers) = (sr.attest.repo, sr.sigs.iter().map(|s| s.key).collect::<Vec<_>>()); |
| 2887 | 4× | let cert = sr.certificate.as_ref().map(|c| (c.cert.cert.hash(), c.cert.cert.issuer)); |
| 2888 | 4× | let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?; |
| 2889 | | |
| 2890 | | |
| 2891 | 4× | let mut entries = vec![crate::tlog::Pending { kind: "receipt", repo: rid, repo_row: Some(row.id), object: attest, signers }]; |
| 2892 | 4× | if let Some((c, issuer)) = cert { |
| 2893 | 2× | entries.push(crate::tlog::Pending { kind: "certificate", repo: rid, repo_row: Some(row.id), object: c, signers: vec![issuer] }); |
| 2894 | 2× | } |
| 2895 | 4× | crate::tlog::log(app, entries).await; |
| 2896 | 4× | Ok((attest.to_hex(), signer)) |
| 2897 | 8× | } |
| 2898 | |
|
| 2899 | |
|
| 2900 | 194× | pub fn duration(j: &fdb::Job) -> String { |
| 2901 | 194× | match (j.started_ms, j.finished_ms) { |
| 2902 | 130× | (Some(a), Some(b)) => human_ms(b - a), |
| 2903 | 6× | (Some(a), None) => human_ms(db::now_ms() - a), |
| 2904 | 58× | _ => "–".into(), |
| 2905 | | } |
| 2906 | 194× | } |
| 2907 | |
|
| 2908 | 224× | pub fn human_ms(ms: i64) -> String { |
| 2909 | 224× | let ms = ms.max(0); |
| 2910 | 224× | if ms < 1000 { |
| 2911 | 178× | format!("{ms} ms") |
| 2912 | 46× | } else if ms < 60_000 { |
| 2913 | 46× | format!("{:.1} s", ms as f64 / 1000.0) |
| 2914 | | } else { |
| 2915 | 0× | format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000) |
| 2916 | | } |
| 2917 | 224× | } |
| 2918 | |
|
| 2919 | |
|
| 2920 | |
|
| 2921 | |
|
| 2922 | | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 2923 | | pub struct QueueInfo { |
| 2924 | | |
| 2925 | | pub position: Option<usize>, |
| 2926 | | |
| 2927 | | pub running: Vec<String>, |
| 2928 | | pub ahead: Vec<String>, |
| 2929 | | |
| 2930 | | pub hidden: usize, |
| 2931 | | } |
| 2932 | |
|
| 2933 | | impl QueueInfo { |
| 2934 | | |
| 2935 | 21× | pub fn place(&self) -> Option<String> { |
| 2936 | 21× | self.position.map(|n| { |
| 2937 | 21× | let suffix = match (n % 10, n % 100) { |
| 2938 | 2× | (1, 11) | (2, 12) | (3, 13) => "th", |
| 2939 | 9× | (1, _) => "st", |
| 2940 | 2× | (2, _) => "nd", |
| 2941 | 1× | (3, _) => "rd", |
| 2942 | 7× | _ => "th", |
| 2943 | | }; |
| 2944 | 21× | format!("{n}{suffix} in line") |
| 2945 | 21× | }) |
| 2946 | 21× | } |
| 2947 | | } |
| 2948 | |
|
| 2949 | |
|
| 2950 | 33× | pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo { |
| 2951 | 33× | let mut info = QueueInfo::default(); |
| 2952 | 87× | let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id); |
| 2953 | 33× | info.position = first.map(|i| i + 1); |
| 2954 | 44× | let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job); |
| 2955 | 33× | let mut queued_seen = 0; |
| 2956 | 156× | for e in queue { |
| 2957 | 156× | if e.run_id == run_id { |
| 2958 | 68× | continue; |
| 2959 | 88× | } |
| 2960 | 88× | let ahead = match e.status.as_str() { |
| 2961 | 88× | "running" => true, |
| 2962 | | _ => { |
| 2963 | 75× | queued_seen += 1; |
| 2964 | 75× | first.is_some_and(|f| queued_seen <= f) |
| 2965 | | } |
| 2966 | | }; |
| 2967 | 88× | if !ahead { |
| 2968 | 39× | continue; |
| 2969 | 49× | } |
| 2970 | 49× | if e.repo_id != repo_id && e.visibility != "public" { |
| 2971 | 5× | info.hidden += 1; |
| 2972 | 44× | } else if e.status == "running" { |
| 2973 | 9× | info.running.push(label(e)); |
| 2974 | 35× | } else { |
| 2975 | 35× | info.ahead.push(label(e)); |
| 2976 | 35× | } |
| 2977 | | } |
| 2978 | 33× | info |
| 2979 | 33× | } |
| 2980 | |
|
| 2981 | | #[cfg(test)] |
| 2982 | | mod queue_tests { |
| 2983 | | use super::*; |
| 2984 | |
|
| 2985 | 7× | fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry { |
| 2986 | 7× | fdb::QueueEntry { |
| 2987 | 7× | job_id, |
| 2988 | 7× | run_id, |
| 2989 | 7× | repo_id, |
| 2990 | 7× | owner: "o".into(), |
| 2991 | 7× | name: format!("r{repo_id}"), |
| 2992 | 7× | visibility: vis.into(), |
| 2993 | 7× | run_number: run_id, |
| 2994 | 7× | job: job.into(), |
| 2995 | 7× | status: status.into(), |
| 2996 | 7× | runner: String::new(), |
| 2997 | 7× | spec: String::new(), |
| 2998 | 7× | sandbox: String::new(), |
| 2999 | 7× | started_ms: None, |
| 3000 | 7× | priority_rank: 2, |
| 3001 | 7× | priority_note: String::new(), |
| 3002 | 7× | } |
| 3003 | 7× | } |
| 3004 | |
|
| 3005 | | #[test] |
| 3006 | 1× | fn a_run_learns_its_place_and_what_is_ahead_of_it() { |
| 3007 | | |
| 3008 | 1× | let q = vec![ |
| 3009 | 1× | e(1, 10, 1, "public", "build", "running"), |
| 3010 | 1× | e(2, 11, 2, "private", "test", "running"), |
| 3011 | 1× | e(3, 10, 1, "public", "test", "queued"), |
| 3012 | 1× | e(4, 12, 3, "private", "lint", "queued"), |
| 3013 | 1× | e(5, 13, 4, "public", "build", "queued"), |
| 3014 | 1× | e(6, 13, 4, "public", "test", "queued"), |
| 3015 | 1× | e(7, 14, 5, "public", "late", "queued"), |
| 3016 | | ]; |
| 3017 | 1× | let info = queue_info(&q, 4, 13); |
| 3018 | 1× | assert_eq!(info.position, Some(3)); |
| 3019 | 1× | assert_eq!(info.place().as_deref(), Some("3rd in line")); |
| 3020 | 1× | assert_eq!(info.running, ["o/r1 #10 build"]); |
| 3021 | 1× | assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind"); |
| 3022 | 1× | assert_eq!(info.hidden, 2, "private repositories are counted, not named"); |
| 3023 | | |
| 3024 | 1× | assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]); |
| 3025 | 1× | assert_eq!(queue_info(&q, 3, 12).position, Some(2)); |
| 3026 | | |
| 3027 | 1× | assert_eq!(queue_info(&q, 1, 99).position, None); |
| 3028 | 5× | let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap(); |
| 3029 | 1× | assert_eq!( |
| 3030 | 1× | [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)], |
| 3031 | | ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"] |
| 3032 | | ); |
| 3033 | 1× | } |
| 3034 | | } |
| 3035 | |
|
| 3036 | | #[cfg(test)] |
| 3037 | | mod placement_tests { |
| 3038 | | use super::*; |
| 3039 | |
|
| 3040 | | #[test] |
| 3041 | 1× | fn a_waiting_job_says_what_each_runner_lacks() { |
| 3042 | | use crate::db::runners::Runner; |
| 3043 | 1× | let rootfs = "ab".repeat(32); |
| 3044 | 1× | let job = fdb::Job { |
| 3045 | 1× | id: 7, |
| 3046 | 1× | runtime: "syscall".into(), |
| 3047 | 1× | sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(), |
| 3048 | 1× | spec: serde_json::json!({ "name": "build" }).to_string(), |
| 3049 | 1× | ..Default::default() |
| 3050 | 1× | }; |
| 3051 | 1× | let now = db::now_ms(); |
| 3052 | 1× | let runner = |id: i64, name: &str| Runner { |
| 3053 | 9× | id, |
| 3054 | 9× | name: name.into(), |
| 3055 | 9× | state: "active".into(), |
| 3056 | 9× | last_seen_ms: Some(now), |
| 3057 | 9× | arch: "x86_64".into(), |
| 3058 | 9× | tiers: "microvm,syscall".into(), |
| 3059 | 9× | images: serde_json::to_string(&[&rootfs]).unwrap(), |
| 3060 | 9× | memory_mib: Some(16384), |
| 3061 | 9× | cpus: Some(8), |
| 3062 | 9× | ..Default::default() |
| 3063 | 9× | }; |
| 3064 | 1× | let archs = std::collections::HashMap::new(); |
| 3065 | 6× | let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| { |
| 3066 | 6× | why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default()) |
| 3067 | 6× | }; |
| 3068 | 1× | assert_eq!(why(&[], &[]), "no runner is online"); |
| 3069 | 1× | assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn"); |
| 3070 | 1× | let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") }; |
| 3071 | 1× | let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") }; |
| 3072 | 1× | let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") }; |
| 3073 | 1× | let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") }; |
| 3074 | 1× | let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") }; |
| 3075 | 1× | assert_eq!( |
| 3076 | 1× | why(&[microvm_only, small, other, offline], &[]), |
| 3077 | | "no runner can take it: earth-1 has no syscall tier (it offers microvm); pi has 2048 MiB; the job needs 4096 MiB; bobs serves other repositories" |
| 3078 | | ); |
| 3079 | 1× | let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into(); |
| 3080 | 1× | assert_eq!( |
| 3081 | 1× | why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()), |
| 3082 | | "no runner can take it: narvi is riscv64; the job needs x86_64" |
| 3083 | | ); |
| 3084 | 1× | let without = Runner { images: "[]".into(), ..runner(6, "new") }; |
| 3085 | 1× | assert_eq!(why(std::slice::from_ref(&without), &[]), "no runner can take it: new does not hold image abababababab"); |
| 3086 | | |
| 3087 | 1× | let wasm = fdb::Job { runtime: "wasm".into(), sandbox: r#"{"memory_mib":256}"#.into(), ..job.clone() }; |
| 3088 | 1× | let wasm_runner = Runner { tiers: "microvm,wasm".into(), ..without }; |
| 3089 | 1× | assert_eq!(why_waiting(&wasm, "andy", 1, &[wasm_runner], &archs, &[], now, &Default::default()), "", "it only waits its turn"); |
| 3090 | 1× | let d = fdb::DeclineRow { |
| 3091 | 1× | job_id: 7, |
| 3092 | 1× | runner_id: 1, |
| 3093 | 1× | runner: "earth-1".into(), |
| 3094 | 1× | reason: "only 512 MiB of memory is free here".into(), |
| 3095 | 1× | ms: now, |
| 3096 | 1× | }; |
| 3097 | 1× | assert_eq!( |
| 3098 | 1× | why(&[runner(1, "earth-1")], std::slice::from_ref(&d)), |
| 3099 | | "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here" |
| 3100 | | ); |
| 3101 | 1× | let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d }; |
| 3102 | 1× | assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds"); |
| 3103 | 1× | } |
| 3104 | | } |