worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // The run record and where it lives on disk. |
| 2 | // |
| 3 | // One JSON file per run plus one log file, under |
| 4 | // `$DATA_DIR/runs/{account}/{repo}/`. The same store backs the queue: a run written |
| 5 | // as `Queued` is the queue entry, so a push that lands while the server is down is |
| 6 | // still picked up when it comes back, with no separate durable channel to keep |
| 7 | // consistent. |
| 8 | |
| 9 | use crate::error::{Error, Result}; |
| 10 | use crate::git::validate::Name; |
| 11 | use anyhow::Context; |
| 12 | use serde::{Deserialize, Serialize}; |
| 13 | use std::path::{Path, PathBuf}; |
| 14 | |
| 15 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 16 | #[serde(rename_all = "snake_case")] |
| 17 | pub enum Status { |
| 18 | Queued, |
| 19 | Running, |
| 20 | Succeeded, |
| 21 | Failed, |
| 22 | Cancelled, |
| 23 | TimedOut, |
| 24 | } |
| 25 | |
| 26 | impl Status { |
| 27 | /// Whether the run is finished and will not change again. |
| 28 | pub fn terminal(self) -> bool { |
| 29 | !matches!(self, Status::Queued | Status::Running) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 34 | pub struct Run { |
| 35 | pub id: String, |
| 36 | pub account: String, |
| 37 | pub repo: String, |
| 38 | /// The ref that triggered this run. |
| 39 | pub git_ref: String, |
| 40 | pub sha: String, |
| 41 | pub status: Status, |
| 42 | pub created_at: u64, |
| 43 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 44 | pub started_at: Option<u64>, |
| 45 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 46 | pub finished_at: Option<u64>, |
| 47 | /// Exit code of the step that ended the run. |
| 48 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 49 | pub exit_code: Option<i32>, |
| 50 | /// Why the run ended, when that is not obvious from the status. |
| 51 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 52 | pub detail: Option<String>, |
| 53 | } |
| 54 | |
| 55 | impl Run { |
| 56 | pub fn queued(account: &str, repo: &str, git_ref: &str, sha: &str) -> Self { |
| 57 | Run { |
| 58 | id: uuid::Uuid::new_v4().simple().to_string(), |
| 59 | account: account.to_string(), |
| 60 | repo: repo.to_string(), |
| 61 | git_ref: git_ref.to_string(), |
| 62 | sha: sha.to_string(), |
| 63 | status: Status::Queued, |
| 64 | created_at: crate::account::token::now_secs(), |
| 65 | started_at: None, |
| 66 | finished_at: None, |
| 67 | exit_code: None, |
| 68 | detail: None, |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Run records and their logs. |
| 74 | #[derive(Debug, Clone)] |
| 75 | pub struct RunStore { |
| 76 | root: PathBuf, |
| 77 | } |
| 78 | |
| 79 | impl RunStore { |
| 80 | pub fn open(root: impl Into<PathBuf>) -> Result<Self> { |
| 81 | let root = root.into(); |
| 82 | std::fs::create_dir_all(&root) |
| 83 | .with_context(|| format!("create runs dir {}", root.display()))?; |
| 84 | Ok(RunStore { root }) |
| 85 | } |
| 86 | |
| 87 | fn dir(&self, account: &Name, repo: &Name) -> PathBuf { |
| 88 | self.root.join(account.as_str()).join(repo.as_str()) |
| 89 | } |
| 90 | |
| 91 | /// Run ids are generated, never supplied, but this is the boundary where a |
| 92 | /// client-supplied one would arrive — so it is checked before it becomes a path. |
| 93 | fn record_path(&self, account: &Name, repo: &Name, id: &str) -> Result<PathBuf> { |
| 94 | if id.is_empty() || id.len() > 64 || !id.bytes().all(|b| b.is_ascii_alphanumeric()) { |
| 95 | return Err(Error::invalid("invalid-run", "run id must be alphanumeric")); |
| 96 | } |
| 97 | Ok(self.dir(account, repo).join(format!("{id}.json"))) |
| 98 | } |
| 99 | |
| 100 | pub fn log_path(&self, account: &Name, repo: &Name, id: &str) -> Result<PathBuf> { |
| 101 | Ok(self.record_path(account, repo, id)?.with_extension("log")) |
| 102 | } |
| 103 | |
| 104 | pub fn put(&self, account: &Name, repo: &Name, run: &Run) -> Result<()> { |
| 105 | let path = self.record_path(account, repo, &run.id)?; |
| 106 | let dir = path.parent().expect("record path has a parent"); |
| 107 | std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; |
| 108 | |
| 109 | let body = serde_json::to_vec_pretty(run).context("serialize run")?; |
| 110 | let temp = path.with_extension("json.tmp"); |
| 111 | std::fs::write(&temp, &body).with_context(|| format!("write {}", temp.display()))?; |
| 112 | std::fs::rename(&temp, &path).with_context(|| format!("rename into {}", path.display()))?; |
| 113 | Ok(()) |
| 114 | } |
| 115 | |
| 116 | pub fn get(&self, account: &Name, repo: &Name, id: &str) -> Result<Option<Run>> { |
| 117 | let path = self.record_path(account, repo, id)?; |
| 118 | match std::fs::read(&path) { |
| 119 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
| 120 | Err(e) => Err(Error::Internal( |
| 121 | anyhow::Error::from(e).context(format!("read {}", path.display())), |
| 122 | )), |
| 123 | Ok(bytes) => Ok(Some( |
| 124 | serde_json::from_slice(&bytes) |
| 125 | .with_context(|| format!("parse {}", path.display()))?, |
| 126 | )), |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// Runs for one repository, newest first. |
| 131 | pub fn list(&self, account: &Name, repo: &Name) -> Result<Vec<Run>> { |
| 132 | let mut runs = read_dir_runs(&self.dir(account, repo)); |
| 133 | runs.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id))); |
| 134 | Ok(runs) |
| 135 | } |
| 136 | |
| 137 | /// Every queued run across every repository, oldest first. |
| 138 | /// |
| 139 | /// This is the queue. It is derived from the same records the API serves, so |
| 140 | /// there is no second source of truth to drift. |
| 141 | pub fn queued(&self) -> Vec<Run> { |
| 142 | let mut queued = Vec::new(); |
| 143 | let Ok(accounts) = std::fs::read_dir(&self.root) else { |
| 144 | return queued; |
| 145 | }; |
| 146 | |
| 147 | for account in accounts.flatten() { |
| 148 | let Ok(repos) = std::fs::read_dir(account.path()) else { |
| 149 | continue; |
| 150 | }; |
| 151 | for repo in repos.flatten() { |
| 152 | queued.extend( |
| 153 | read_dir_runs(&repo.path()) |
| 154 | .into_iter() |
| 155 | .filter(|r| r.status == Status::Queued), |
| 156 | ); |
| 157 | } |
| 158 | } |
| 159 | queued.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id))); |
| 160 | queued |
| 161 | } |
| 162 | |
| 163 | /// Append to a run's log, bounded. |
| 164 | /// |
| 165 | /// Returns how many bytes were actually written, so the caller can notice the |
| 166 | /// cap was reached and stop producing output. |
| 167 | pub fn append_log( |
| 168 | &self, |
| 169 | account: &Name, |
| 170 | repo: &Name, |
| 171 | id: &str, |
| 172 | chunk: &[u8], |
| 173 | cap: u64, |
| 174 | ) -> Result<usize> { |
| 175 | use std::io::Write; |
| 176 | |
| 177 | let path = self.log_path(account, repo, id)?; |
| 178 | let dir = path.parent().expect("log path has a parent"); |
| 179 | std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; |
| 180 | |
| 181 | let existing = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); |
| 182 | if existing >= cap { |
| 183 | return Ok(0); |
| 184 | } |
| 185 | let room = (cap - existing) as usize; |
| 186 | let slice = &chunk[..chunk.len().min(room)]; |
| 187 | |
| 188 | let mut file = std::fs::OpenOptions::new() |
| 189 | .create(true) |
| 190 | .append(true) |
| 191 | .open(&path) |
| 192 | .with_context(|| format!("open {}", path.display()))?; |
| 193 | file.write_all(slice) |
| 194 | .with_context(|| format!("write {}", path.display()))?; |
| 195 | Ok(slice.len()) |
| 196 | } |
| 197 | |
| 198 | pub fn read_log(&self, account: &Name, repo: &Name, id: &str) -> Result<Vec<u8>> { |
| 199 | let path = self.log_path(account, repo, id)?; |
| 200 | match std::fs::read(&path) { |
| 201 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), |
| 202 | Err(e) => Err(Error::Internal( |
| 203 | anyhow::Error::from(e).context(format!("read {}", path.display())), |
| 204 | )), |
| 205 | Ok(bytes) => Ok(bytes), |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | /// Remove every run for a repository. Called when the repository is deleted. |
| 210 | pub fn remove_repo(&self, account: &Name, repo: &Name) { |
| 211 | let _ = std::fs::remove_dir_all(self.dir(account, repo)); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | fn read_dir_runs(dir: &Path) -> Vec<Run> { |
| 216 | let Ok(entries) = std::fs::read_dir(dir) else { |
| 217 | return Vec::new(); |
| 218 | }; |
| 219 | entries |
| 220 | .flatten() |
| 221 | .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")) |
| 222 | .filter_map(|e| std::fs::read(e.path()).ok()) |
| 223 | .filter_map(|bytes| serde_json::from_slice::<Run>(&bytes).ok()) |
| 224 | .collect() |
| 225 | } |
| 226 | |
| 227 | #[cfg(test)] |
| 228 | mod tests { |
| 229 | use super::*; |
| 230 | use crate::git::validate::name; |
| 231 | |
| 232 | fn store() -> (tempfile::TempDir, RunStore, Name, Name) { |
| 233 | let dir = tempfile::tempdir().unwrap(); |
| 234 | let store = RunStore::open(dir.path()).unwrap(); |
| 235 | (dir, store, name("alice").unwrap(), name("site").unwrap()) |
| 236 | } |
| 237 | |
| 238 | #[test] |
| 239 | fn a_run_round_trips() { |
| 240 | let (_dir, store, a, r) = store(); |
| 241 | let run = Run::queued("alice", "site", "refs/heads/main", "abc"); |
| 242 | store.put(&a, &r, &run).unwrap(); |
| 243 | |
| 244 | let found = store.get(&a, &r, &run.id).unwrap().unwrap(); |
| 245 | assert_eq!(found.status, Status::Queued); |
| 246 | assert_eq!(found.git_ref, "refs/heads/main"); |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn terminal_states_are_the_ones_that_stop_changing() { |
| 251 | assert!(!Status::Queued.terminal()); |
| 252 | assert!(!Status::Running.terminal()); |
| 253 | for status in [ |
| 254 | Status::Succeeded, |
| 255 | Status::Failed, |
| 256 | Status::Cancelled, |
| 257 | Status::TimedOut, |
| 258 | ] { |
| 259 | assert!(status.terminal(), "{status:?} must be terminal"); |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | #[test] |
| 264 | fn the_queue_is_derived_from_the_records_the_api_serves() { |
| 265 | let (_dir, store, a, r) = store(); |
| 266 | |
| 267 | let mut first = Run::queued("alice", "site", "refs/heads/main", "a"); |
| 268 | first.created_at = 100; |
| 269 | let mut second = Run::queued("alice", "site", "refs/heads/main", "b"); |
| 270 | second.created_at = 200; |
| 271 | let mut done = Run::queued("alice", "site", "refs/heads/main", "c"); |
| 272 | done.created_at = 50; |
| 273 | done.status = Status::Succeeded; |
| 274 | |
| 275 | for run in [&first, &second, &done] { |
| 276 | store.put(&a, &r, run).unwrap(); |
| 277 | } |
| 278 | |
| 279 | let queued = store.queued(); |
| 280 | assert_eq!(queued.len(), 2, "a finished run must not be in the queue"); |
| 281 | assert_eq!(queued[0].id, first.id, "the queue is oldest first"); |
| 282 | assert_eq!(queued[1].id, second.id); |
| 283 | } |
| 284 | |
| 285 | #[test] |
| 286 | fn listing_is_newest_first() { |
| 287 | let (_dir, store, a, r) = store(); |
| 288 | let mut old = Run::queued("alice", "site", "refs/heads/main", "a"); |
| 289 | old.created_at = 100; |
| 290 | let mut new = Run::queued("alice", "site", "refs/heads/main", "b"); |
| 291 | new.created_at = 200; |
| 292 | store.put(&a, &r, &old).unwrap(); |
| 293 | store.put(&a, &r, &new).unwrap(); |
| 294 | |
| 295 | let listed = store.list(&a, &r).unwrap(); |
| 296 | assert_eq!(listed[0].id, new.id); |
| 297 | } |
| 298 | |
| 299 | #[test] |
| 300 | fn a_log_is_capped_and_reports_what_it_wrote() { |
| 301 | let (_dir, store, a, r) = store(); |
| 302 | let run = Run::queued("alice", "site", "refs/heads/main", "a"); |
| 303 | |
| 304 | assert_eq!(store.append_log(&a, &r, &run.id, b"12345", 8).unwrap(), 5); |
| 305 | // Only three bytes of room are left. |
| 306 | assert_eq!(store.append_log(&a, &r, &run.id, b"67890", 8).unwrap(), 3); |
| 307 | // And then none. |
| 308 | assert_eq!(store.append_log(&a, &r, &run.id, b"more", 8).unwrap(), 0); |
| 309 | |
| 310 | assert_eq!(store.read_log(&a, &r, &run.id).unwrap(), b"12345678"); |
| 311 | } |
| 312 | |
| 313 | #[test] |
| 314 | fn an_absent_log_reads_as_empty_rather_than_failing() { |
| 315 | let (_dir, store, a, r) = store(); |
| 316 | assert!(store.read_log(&a, &r, "deadbeef").unwrap().is_empty()); |
| 317 | } |
| 318 | |
| 319 | #[test] |
| 320 | fn a_run_id_cannot_become_a_path() { |
| 321 | let (_dir, store, a, r) = store(); |
| 322 | for id in ["../../etc/passwd", "a/b", "..", "", "a.json"] { |
| 323 | assert!(store.get(&a, &r, id).is_err(), "{id:?} must be refused"); |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | #[test] |
| 328 | fn removing_a_repository_removes_its_runs() { |
| 329 | let (_dir, store, a, r) = store(); |
| 330 | let run = Run::queued("alice", "site", "refs/heads/main", "a"); |
| 331 | store.put(&a, &r, &run).unwrap(); |
| 332 | |
| 333 | store.remove_repo(&a, &r); |
| 334 | assert!(store.get(&a, &r, &run.id).unwrap().is_none()); |
| 335 | assert!(store.queued().is_empty()); |
| 336 | } |
| 337 | } |