crates/cli/src/cmd/ci.rs 19.3 KiB 578 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge ci: script runs (PLAN M4). Steps are plain scripts; verdict = exit code.3 use crate::{args, output, CliError};4 use f0rge_core::{CiRun, CiRunStep, Forge, Pr};5 use serde::Serialize;6 use std::path::{Path, PathBuf};7 use std::process::Command;8 use std::time::Instant;9 10 pub fn help() -> &'static str {11     "f0rge ci: script runs\nusage: f0rge ci <verb> [--json] [flags]\nverbs: run list view log rerun cancel\nrun: [--docker] [pr]; log: <run> [step]\ndocs: spec/schema.md §Ci\ncontract: f0rge --skill\n"12 }13 14 // Per-verb helps next to parser arms (#36). Each <=250B/8ln.15 const RUN_H: &str = "f0rge ci run: scripts\nusage: f0rge ci run [--docker] [--continue-on-error] [pr]\ncontract: f0rge --skill\n";16 const LIST_H: &str = "f0rge ci list: runs\nusage: f0rge ci list\ncontract: f0rge --skill\n";17 const VIEW_H: &str = "f0rge ci view: summary\nusage: f0rge ci view <run>\ncontract: f0rge --skill\n";18 const LOG_H: &str = "f0rge ci log: output\nusage: f0rge ci log <run> [step]\ncontract: f0rge --skill\n";19 const RERUN_H: &str = "f0rge ci rerun: retry\nusage: f0rge ci rerun [run]\ncontract: f0rge --skill\n";20 const CANCEL_H: &str = "f0rge ci cancel: stop\nusage: f0rge ci cancel\ncontract: f0rge --skill\n";21 22 /// Verb-depth help (#36).23 pub fn verb_help(verb: &str) -> Option<&'static str> {24     Some(match verb {25         "run" => RUN_H,26         "list" => LIST_H,27         "view" => VIEW_H,28         "log" => LOG_H,29         "rerun" => RERUN_H,30         "cancel" => CANCEL_H,31         _ => return None,32     })33 }34 35 fn with_try(e: CliError, verb: &str) -> CliError {36     match e {37         CliError::Usage(m) if !m.contains("(try:") => {38             CliError::Usage(format!("{m} (try: f0rge ci {verb} --help)"))39         }40         other => other,41     }42 }43 44 const VERBS: &[&str] = &["run", "list", "view", "log", "rerun", "cancel"];45 46 fn spec() -> Vec<args::FlagSpec> {47     args::spec_from_lists(&["jq"], &["json", "docker", "continue-on-error"])48 }49 50 fn usage(m: &str) -> CliError {51     CliError::Usage(m.to_string())52 }53 54 fn io_err() -> CliError {55     CliError::Io("ci: io error".to_string())56 }57 58 fn clean(s: &str) -> String {59     s.chars().filter(|c| *c != '\n' && *c != '\r').take(80).collect::<String>().trim().to_string()60 }61 62 /// Reject path escape attempts in run/step ids (SEC-02).63 fn safe_name(raw: &str) -> Result<String, CliError> {64     let s = clean(raw);65     if s.is_empty()66         || s.contains('/')67         || s.contains('\\')68         || s.contains("..")69         || s.contains('\0')70         || s.starts_with('-')71     {72         return Err(usage("bad id"));73     }74     Ok(s)75 }76 77 fn plain(s: &str) -> bool {78     !s.is_empty()79         && s.bytes().all(|b| {80             b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/'81         })82 }83 84 fn esc(s: &str) -> String {85     if plain(s) {86         return s.to_string();87     }88     let mut o = String::with_capacity(s.len() + 2);89     o.push('"');90     for c in s.chars() {91         match c {92             '"' => o.push_str("\\\""),93             '\\' => o.push_str("\\\\"),94             '\n' => o.push_str("\\n"),95             '\r' => o.push_str("\\r"),96             '\t' => o.push_str("\\t"),97             _ => o.push(c),98         }99     }100     o.push('"');101     o102 }103 104 #[derive(Debug, Clone, Serialize)]105 struct RunJson {106     run: String,107     verdict: String,108     created: String,109 }110 111 fn repo_of(fg: &Forge) -> PathBuf {112     fg.root().parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."))113 }114 115 fn load_env(fg: &Forge) -> Result<f0rge_core::CiEnv, CliError> {116     match fg.read_capped(&fg.ci_env()) {117         Err(f0rge_core::ForgeError::NotFound { .. }) => Ok(f0rge_core::CiEnv {118             runner: "local".to_string(),119             knobs: Default::default(),120         }),121         Err(e) => Err(CliError::from(e)),122         Ok(b) => {123             let t =124                 String::from_utf8(b).map_err(|_| CliError::Runtime("ci.env: not utf-8".to_string()))?;125             f0rge_core::parse_ci_env(&t, "ci.env").map_err(CliError::from)126         }127     }128 }129 130 /// Step scripts in filename order; skips symlinks, dotfiles, dirs.131 fn step_list(fg: &Forge) -> Result<Vec<(String, PathBuf)>, CliError> {132     let dir = fg.root().join("ci").join("steps");133     let rd = std::fs::read_dir(&dir).map_err(|_| CliError::Runtime("ci steps: not found".to_string()))?;134     let mut out = Vec::new();135     for e in rd.flatten() {136         let ft = match e.file_type() {137             Ok(t) => t,138             Err(_) => continue,139         };140         if ft.is_symlink() || !ft.is_file() {141             continue;142         }143         let name = e.file_name().to_string_lossy().into_owned();144         if name.is_empty() || name.starts_with('.') {145             continue;146         }147         out.push((name, e.path()));148     }149     out.sort_by(|a, b| a.0.cmp(&b.0));150     Ok(out)151 }152 153 fn render_summary(run: &str, verdict: &str, created: &str, steps: &[CiRunStep], body: &str) -> String {154     let mut s = format!("---\nrun: {run}\nverdict: {verdict}\ncreated: {created}\n");155     if steps.is_empty() {156         s.push_str("steps: []\n");157     } else {158         s.push_str("steps:\n");159         for st in steps {160             s.push_str(&format!(161                 "  - name: {}\n    exit: {}\n    duration_ms: {}\n",162                 esc(&st.name),163                 st.exit,164                 st.duration_ms165             ));166         }167     }168     s.push_str("---\n");169     s.push_str(body);170     s171 }172 173 /// Atomic write without the 256 KiB entity cap (logs are not entities).174 fn atomic(path: &Path, bytes: &[u8]) -> Result<(), CliError> {175     if let Some(p) = path.parent() {176         std::fs::create_dir_all(p).map_err(|_| io_err())?;177     }178     let tmp = path.with_extension("tmp");179     std::fs::write(&tmp, bytes).map_err(|_| io_err())?;180     if let Ok(f) = std::fs::File::open(&tmp) {181         let _ = f.sync_all();182     }183     std::fs::rename(&tmp, path).map_err(|_| io_err())?;184     Ok(())185 }186 187 fn write_summary(188     fg: &Forge,189     ts: &str,190     verdict: &str,191     created: &str,192     steps: &[CiRunStep],193     body: &str,194 ) -> Result<(), CliError> {195     let doc = render_summary(ts, verdict, created, steps, body);196     atomic(&fg.root().join("ci").join("runs").join(ts).join("summary.md"), doc.as_bytes())197 }198 199 fn set_pr_ci(fg: &Forge, uuid: &str, verdict: &str) -> Result<(), CliError> {200     let rel = fg.pr_file(uuid);201     let p: f0rge_core::Parsed<Pr> = fg.load_entity(&rel).map_err(|e| match e {202         f0rge_core::ForgeError::NotFound { .. } => {203             CliError::Runtime(format!("pr {uuid}: not found"))204         }205         _ => CliError::from(e),206     })?;207     output::warn_schema(&p.schema_note);208     let mut pr = p.value;209     pr.ci = Some(verdict.to_string());210     pr.updated = Some(f0rge_core::now_rfc3339());211     fg.save_entity(&rel, &pr, &p.body).map_err(CliError::from)?;212     Ok(())213 }214 215 struct Cfg {216     cont: bool,217     image: Option<String>,218     knobs: Vec<(String, String)>,219     repo: PathBuf,220 }221 222 fn exec_step(full: &Path, name: &str, cfg: &Cfg) -> (i32, u64, Vec<u8>) {223     let t0 = Instant::now();224     let out = if let Some(img) = &cfg.image {225         // argv array only; posture explicit (SEC-04, SEC-07).226         let mut c = Command::new("docker");227         c.arg("run").arg("--rm").arg("-v").arg(format!("{}:/work", cfg.repo.display()));228         c.arg("-w").arg("/work");229         for (k, v) in &cfg.knobs {230             c.arg("-e").arg(format!("{k}={v}"));231         }232         c.arg(img).arg(format!("/work/.forge/ci/steps/{name}"));233         c.output()234     } else {235         let mut c = Command::new(full);236         c.envs(cfg.knobs.clone());237         c.current_dir(&cfg.repo);238         c.output()239     };240     let ms = t0.elapsed().as_millis().min(u64::MAX as u128) as u64;241     match out {242         Ok(o) => {243             let code = o.status.code().unwrap_or(128);244             let mut log = o.stdout;245             log.extend_from_slice(&o.stderr);246             (code, ms, log)247         }248         Err(_) => (126, ms, b"exec failed\n".to_vec()),249     }250 }251 252 fn do_run(fs: &args::FlagSet, fg: &Forge, pr: Option<String>) -> Result<(), CliError> {253     let env = load_env(fg)?;254     let docker = fs.is("docker") || env.runner == "docker";255     let image = if docker {256         match env.knobs.get("image").filter(|s| !s.is_empty()) {257             Some(i) => Some(i.clone()),258             None => return Err(CliError::Runtime("ci: docker image not set in ci.env".to_string())),259         }260     } else {261         None262     };263     let list = step_list(fg)?;264     // Validate linked pr before any side effect: no stray run dir,265     // no dangling .current, no phantom cancel on bad id.266     if let Some(p) = &pr {267         if !fg.root().join(fg.pr_file(p)).is_file() {268             return Err(CliError::Runtime(format!("pr {p}: not found")));269         }270     }271     let base = f0rge_core::now_rfc3339().replace(':', "-");272     let mut ts = base.clone();273     let mut n = 0u32;274     while fg.root().join("ci").join("runs").join(&ts).exists() {275         n += 1;276         ts = format!("{base}-{n}");277     }278     let rdir = fg.root().join("ci").join("runs").join(&ts);279     std::fs::create_dir_all(rdir.join("steps")).map_err(|_| io_err())?;280     let created = f0rge_core::now_rfc3339();281     let cur = fg.root().join("ci").join("runs").join(".current");282     let tmp = cur.with_extension("tmp"); // best-effort pointer: tmp+rename, never partial283     let _ = std::fs::write(&tmp, format!("{ts} {}", std::process::id()));284     let _ = std::fs::rename(&tmp, &cur);285     if let Some(p) = &pr {286         set_pr_ci(fg, p, "running")?;287     }288     write_summary(fg, &ts, "running", &created, &[], "running.\n")?;289     let knobs: Vec<(String, String)> = env.knobs.into_iter().collect();290     let cfg = Cfg { cont: fs.is("continue-on-error"), image, knobs, repo: repo_of(fg) };291     let mut done: Vec<CiRunStep> = Vec::new();292     let mut failed: Option<String> = None;293     for (name, full) in &list {294         let (code, ms, log) = exec_step(full, name, &cfg);295         atomic(&rdir.join("steps").join(format!("{name}.log")), &log)?;296         done.push(CiRunStep { name: name.clone(), exit: code, duration_ms: ms });297         write_summary(fg, &ts, "running", &created, &done, &format!("running: {name} {code}.\n"))?;298         if code != 0 && failed.is_none() {299             failed = Some(name.clone());300         }301         if code != 0 && !cfg.cont {302             break;303         }304     }305     let verdict = if failed.is_none() { "pass" } else { "fail" };306     let body = match &failed {307         None => "pass.\n".to_string(),308         Some(x) => format!("failed at {x}.\n"),309     };310     write_summary(fg, &ts, verdict, &created, &done, &body)?;311     let _ = std::fs::remove_file(&cur);312     if let Some(p) = &pr {313         set_pr_ci(fg, p, verdict)?;314     }315     if fs.is("json") {316         output::emit_json(317             &RunJson { run: ts.clone(), verdict: verdict.to_string(), created },318             fs.get("jq"),319         );320     } else {321         output::out(&format!("{ts} {verdict}\n"));322     }323     if verdict == "fail" {324         return Err(CliError::Runtime(format!("ci run {ts}: fail")));325     }326     Ok(())327 }328 329 fn all_runs(fg: &Forge) -> Result<Vec<(String, CiRun)>, CliError> {330     let dir = fg.root().join("ci").join("runs");331     let rd = match std::fs::read_dir(&dir) {332         Ok(r) => r,333         Err(_) => return Ok(Vec::new()),334     };335     let mut out = Vec::new();336     for e in rd.flatten() {337         let ft = match e.file_type() {338             Ok(t) => t,339             Err(_) => continue,340         };341         if ft.is_symlink() || !ft.is_dir() {342             continue;343         }344         let name = e.file_name().to_string_lossy().into_owned();345         if name.starts_with('.') {346             continue;347         }348         let rel = Path::new("ci").join("runs").join(&name).join("summary.md");349         match fg.load_entity::<CiRun>(&rel) {350             Ok(p) => {351                 output::warn_schema(&p.schema_note);352                 out.push((name, p.value))353             }354             Err(_) => continue,355         }356     }357     out.sort_by(|a, b| b.0.cmp(&a.0));358     Ok(out)359 }360 361 fn list_runs(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {362     if !fs.positional.is_empty() {363         return Err(usage("f0rge ci list"));364     }365     let runs = all_runs(fg)?;366     if fs.is("json") {367         if runs.is_empty() {368             output::emit_empty_array(fs.get("jq"));369             return Ok(());370         }371         for (_, r) in &runs {372             let j = RunJson { run: r.run.clone(), verdict: r.verdict.clone(), created: r.created.clone() };373             output::emit_str(&output::json_line(&j), fs.get("jq"));374         }375         return Ok(());376     }377     if runs.is_empty() {378         return Ok(());379     }380     let grid: Vec<Vec<String>> =381         runs.iter().map(|(_, r)| vec![r.run.clone(), r.verdict.clone()]).collect();382     output::out(&format!("{}\n", output::align(&grid)));383     Ok(())384 }385 386 fn view(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {387     let id = safe_name(&fs.positional[0])?;388     let rel = Path::new("ci").join("runs").join(&id).join("summary.md");389     let bytes = fg.read_capped(&rel).map_err(|e| match e {390         f0rge_core::ForgeError::NotFound { .. } => CliError::Runtime(format!("ci {id}: not found")),391         _ => CliError::from(e),392     })?;393     let text =394         String::from_utf8(bytes).map_err(|_| CliError::Runtime(format!("ci {id}: not utf-8")))?;395     if text.ends_with('\n') {396         output::out(&text);397     } else {398         output::out(&format!("{text}\n"));399     }400     Ok(())401 }402 403 fn log(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {404     let id = safe_name(&fs.positional[0])?;405     let rdir = fg.root().join("ci").join("runs").join(&id);406     if !rdir.join("summary.md").is_file() {407         return Err(CliError::Runtime(format!("ci {id}: not found")));408     }409     if fs.positional.len() == 2 {410         let step = safe_name(&fs.positional[1])?;411         let rel = Path::new("ci").join("runs").join(&id).join("steps").join(format!("{step}.log"));412         let bytes = fg.read_capped(&rel).map_err(|e| match e {413             f0rge_core::ForgeError::NotFound { .. } => {414                 CliError::Runtime(format!("ci {id} {step}: not found"))415             }416             _ => CliError::from(e),417         })?;418         let text = String::from_utf8_lossy(&bytes);419         if text.ends_with('\n') {420             output::out(&text);421         } else {422             output::out(&format!("{text}\n"));423         }424         return Ok(());425     }426     let sdir = rdir.join("steps");427     let rd = match std::fs::read_dir(&sdir) {428         Ok(r) => r,429         Err(_) => return Ok(()),430     };431     let mut names: Vec<String> = Vec::new();432     for e in rd.flatten() {433         let ft = match e.file_type() {434             Ok(t) => t,435             Err(_) => continue,436         };437         if ft.is_symlink() || !ft.is_file() {438             continue;439         }440         names.push(e.file_name().to_string_lossy().into_owned());441     }442     names.sort();443     for name in &names {444         output::out(&format!("== {name} ==\n"));445         let bytes = std::fs::read(sdir.join(name)).map_err(|_| io_err())?;446         let text = String::from_utf8_lossy(&bytes);447         if text.ends_with('\n') {448             output::out(&text);449         } else {450             output::out(&format!("{text}\n"));451         }452     }453     Ok(())454 }455 456 fn rerun(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {457     if let Some(raw) = fs.positional.first() {458         let id = safe_name(raw)?;459         if !fg.root().join("ci").join("runs").join(&id).join("summary.md").is_file() {460             return Err(CliError::Runtime(format!("ci {id}: not found")));461         }462     }463     do_run(fs, fg, None)464 }465 466 /// Our pid still alive under this name? Non-linux: assume yes.467 fn is_forge(pid: u32) -> bool {468     match std::fs::read(format!("/proc/{pid}/cmdline")) {469         Ok(b) => b.windows(5).any(|w| w == b"f0rge"),470         Err(_) => true,471     }472 }473 474 fn cancel(fg: &Forge) -> Result<(), CliError> {475     let cur = fg.root().join("ci").join("runs").join(".current");476     let text = std::fs::read_to_string(&cur).map_err(|_| CliError::Runtime("ci: no running run".to_string()))?;477     let mut it = text.split_whitespace();478     let ts = it.next().ok_or_else(|| CliError::Runtime("ci: no running run".to_string()))?;479     let ts = safe_name(ts)?;480     let pid: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);481     if pid > 0 && is_forge(pid) {482         let _ = Command::new("kill").arg(pid.to_string()).output();483     }484     let rel = Path::new("ci").join("runs").join(&ts).join("summary.md");485     if let Ok(p) = fg.load_entity::<CiRun>(&rel) {486         output::warn_schema(&p.schema_note);487         if p.value.verdict == "running" {488             write_summary(fg, &ts, "cancelled", &p.value.created, &p.value.steps, "cancelled.\n")?;489         }490     }491     let _ = std::fs::remove_file(&cur);492     output::out(&format!("{ts} cancelled\n"));493     Ok(())494 }495 496 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {497     if args_in.is_empty() {498         return Err(usage("f0rge ci <run|list|view|log|rerun|cancel> (try: f0rge ci -h)"));499     }500     let verb = clean(&args_in[0]);501     if verb.is_empty() || verb.starts_with('-') || !VERBS.contains(&verb.as_str()) {502         let shown = if verb.is_empty() { "<none>".to_string() } else { verb };503         return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge ci -h)")));504     }505     let fs = args::parse_flags(&args_in[1..], &spec()).map_err(|e| with_try(e, &verb))?;506     if fs.is("help") {507         if let Some(h) = verb_help(&verb) {508             output::out(h);509             return Ok(());510         }511     }512     let r: Result<(), CliError> = match verb.as_str() {513         "run" => {514             if fs.positional.len() > 1 {515                 return Err(with_try(usage("f0rge ci run [--docker] [pr]"), &verb));516             }517             let pr = fs.positional.first().map(|s| clean(s)).filter(|s| !s.is_empty());518             if let Some(p) = &pr {519                 if !f0rge_core::is_uuid4_lower(p) {520                     return Err(with_try(usage("f0rge ci run [pr]"), &verb));521                 }522             }523             do_run(&fs, fg, pr)524         }525         "list" => list_runs(&fs, fg),526         "view" => {527             if fs.positional.len() != 1 {528                 return Err(with_try(usage("f0rge ci view <run>"), &verb));529             }530             view(&fs, fg)531         }532         "log" => {533             if fs.positional.is_empty() || fs.positional.len() > 2 {534                 return Err(with_try(usage("f0rge ci log <run> [step]"), &verb));535             }536             log(&fs, fg)537         }538         "rerun" => {539             if fs.positional.len() > 1 {540                 return Err(with_try(usage("f0rge ci rerun [run]"), &verb));541             }542             rerun(&fs, fg)543         }544         "cancel" => {545             if !fs.positional.is_empty() {546                 return Err(with_try(usage("f0rge ci cancel"), &verb));547             }548             cancel(fg)549         }550         _ => Err(usage("f0rge ci <run|list|view|log|rerun|cancel> (try: f0rge ci -h)")),551     };552     r.map_err(|e| with_try(e, &verb))553 }554 555 #[cfg(test)]556 mod verb_help_tests {557     use super::*;558     fn budget(s: &str) {559         assert!(s.lines().count() <= 8, "lines:\n{s}");560         assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());561     }562     #[test]563     fn per_verb_budgets_and_needles() {564         for (v, needle) in [565             ("run", "--docker"),566             ("list", "ci list"),567             ("view", "<run>"),568             ("log", "[step]"),569             ("rerun", "[run]"),570             ("cancel", "ci cancel"),571         ] {572             let h = verb_help(v).expect(v);573             budget(h);574             assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");575         }576         assert!(verb_help("frobnicate").is_none());577     }578 }