//! Command line for the proof of concept. No crates, so the options are //! parsed by hand; every flag takes the next argument. use std::path::PathBuf; use std::time::Duration; /// What the binary was asked to do. pub enum Command { Run(Options), Help, } /// One proof-of-concept run. #[derive(Clone, Debug)] pub struct Options { pub kubeconfig: Option, pub context: Option, pub namespace: String, pub kubectl: PathBuf, pub name: String, pub runner: String, pub image: String, pub workdir: PathBuf, pub outputs: Option, pub steps_dir: Option, pub steps_file: Option, pub script: Option, pub step_timeout: Duration, pub timeout: Duration, pub cpu_request: Option, pub memory_request: Option, pub keep: bool, pub dry_run: bool, pub log: Option, pub result: Option, } impl Default for Options { fn default() -> Self { Self { kubeconfig: None, context: None, namespace: "default".to_string(), kubectl: PathBuf::from("kubectl"), name: String::new(), runner: "did:example:runner".to_string(), image: String::new(), workdir: PathBuf::from("."), outputs: None, steps_dir: None, steps_file: None, script: None, step_timeout: Duration::from_secs(600), timeout: Duration::from_secs(3600), cpu_request: None, memory_request: None, keep: false, dry_run: false, log: None, result: None, } } } /// The log path, defaulting to `.log`. pub fn log_path(options: &Options) -> PathBuf { options .log .clone() .unwrap_or_else(|| PathBuf::from(format!("{}.log", options.name))) } /// Parses the arguments after the program name. pub fn parse(args: &[String]) -> Result { let Some(first) = args.first() else { return Ok(Command::Help); }; match first.as_str() { "run" => parse_run(&args[1..]), "help" | "--help" | "-h" => Ok(Command::Help), other => Err(format!("unknown command {other}")), } } fn parse_run(args: &[String]) -> Result { let mut options = Options::default(); let mut i = 0; while i < args.len() { let flag = args[i].as_str(); i += 1; if matches!(flag, "--help" | "-h") { return Ok(Command::Help); } let mut value = || -> Result { let value = args .get(i) .ok_or_else(|| format!("{flag} needs a value"))?; i += 1; Ok(value.clone()) }; match flag { "--kubeconfig" => options.kubeconfig = Some(PathBuf::from(value()?)), "--context" => options.context = Some(value()?), "--namespace" => options.namespace = value()?, "--kubectl" => options.kubectl = PathBuf::from(value()?), "--name" => options.name = value()?, "--runner" => options.runner = value()?, "--image" => options.image = value()?, "--workdir" => options.workdir = PathBuf::from(value()?), "--outputs" => options.outputs = Some(PathBuf::from(value()?)), "--steps-dir" => options.steps_dir = Some(PathBuf::from(value()?)), "--steps-file" => options.steps_file = Some(PathBuf::from(value()?)), "--script" => options.script = Some(value()?), "--step-timeout" => options.step_timeout = seconds(&value()?, flag)?, "--timeout" => options.timeout = seconds(&value()?, flag)?, "--cpu-request" => options.cpu_request = Some(value()?), "--memory-request" => options.memory_request = Some(value()?), "--log" => options.log = Some(PathBuf::from(value()?)), "--result" => options.result = Some(PathBuf::from(value()?)), "--keep" => options.keep = true, "--dry-run" => options.dry_run = true, other => return Err(format!("unknown option {other}")), } } if options.name.trim().is_empty() { return Err("--name is required".to_string()); } if options.image.trim().is_empty() { return Err("--image is required".to_string()); } Ok(Command::Run(options)) } fn seconds(value: &str, flag: &str) -> Result { let secs: u64 = value .parse() .map_err(|_| format!("{flag} needs whole seconds, got {value}"))?; if secs == 0 { return Err(format!("{flag} must be at least 1 second")); } Ok(Duration::from_secs(secs)) } /// The usage text. pub fn print_usage() { println!( "knotless-runner-k8s run --name NAME --image IMAGE [options]\n\ \n\ A proof-of-concept Kubernetes Job executor for knotless-run.\n\ \n\ Required:\n\ \x20 --name NAME Job and run name\n\ \x20 --image IMAGE container image for the run\n\ \n\ Steps (one of):\n\ \x20 --steps-file FILE lines of namescript\n\ \x20 --script SCRIPT one step named run\n\ \n\ Cluster:\n\ \x20 --kubeconfig PATH kubeconfig to use\n\ \x20 --context NAME kubeconfig context\n\ \x20 --namespace NAME namespace (default: default)\n\ \x20 --kubectl PATH kubectl client (default: kubectl)\n\ \n\ Run:\n\ \x20 --runner DID runner DID for labels (default: did:example:runner)\n\ \x20 --workdir DIR checkout to stream in (default: .)\n\ \x20 --steps-dir DIR shared steps to stream to /steps\n\ \x20 --outputs DIR directory to stream /outputs back into\n\ \x20 --step-timeout SECS per-step timeout (default: 600)\n\ \x20 --timeout SECS whole-run timeout (default: 3600)\n\ \x20 --cpu-request CPU pod CPU request\n\ \x20 --memory-request MEM pod memory request\n\ \x20 --keep keep the Job after the run\n\ \x20 --dry-run print the manifest and commands, run nothing\n\ \x20 --log FILE run log (default: .log)\n\ \x20 --result FILE result JSON (default: stdout)" ); } #[cfg(test)] mod tests { use super::*; fn args(values: &[&str]) -> Vec { values.iter().map(|v| v.to_string()).collect() } #[test] fn parses_run() { let Command::Run(options) = parse(&args(&[ "run", "--name", "ci-1", "--image", "rust:1.98", "--script", "cargo test", "--step-timeout", "30", ])) .unwrap() else { panic!("not a run"); }; assert_eq!(options.name, "ci-1"); assert_eq!(options.image, "rust:1.98"); assert_eq!(options.script.as_deref(), Some("cargo test")); assert_eq!(options.step_timeout, Duration::from_secs(30)); assert_eq!(options.namespace, "default"); } #[test] fn requires_name_and_image() { assert!(parse(&args(&["run", "--image", "x"])).is_err()); assert!(parse(&args(&["run", "--name", "x"])).is_err()); assert!(parse(&args(&["run", "--name", "x", "--image", "i", "--timeout", "0"])).is_err()); } #[test] fn help() { assert!(matches!(parse(&args(&[])).unwrap(), Command::Help)); assert!(matches!(parse(&args(&["--help"])).unwrap(), Command::Help)); assert!(matches!( parse(&args(&["run", "--help"])).unwrap(), Command::Help )); } }