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 }