~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless-runner-k8s

0
0
229 lines · 7.8 KB Raw History
//! 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<PathBuf>,
    pub context: Option<String>,
    pub namespace: String,
    pub kubectl: PathBuf,
    pub name: String,
    pub runner: String,
    pub image: String,
    pub workdir: PathBuf,
    pub outputs: Option<PathBuf>,
    pub steps_dir: Option<PathBuf>,
    pub steps_file: Option<PathBuf>,
    pub script: Option<String>,
    pub step_timeout: Duration,
    pub timeout: Duration,
    pub cpu_request: Option<String>,
    pub memory_request: Option<String>,
    pub keep: bool,
    pub dry_run: bool,
    pub log: Option<PathBuf>,
    pub result: Option<PathBuf>,
}

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 `<name>.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<Command, String> {
    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<Command, String> {
    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<String, String> {
            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<Duration, String> {
    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 name<TAB>script\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: <name>.log)\n\
         \x20 --result FILE          result JSON (default: stdout)"
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    fn args(values: &[&str]) -> Vec<String> {
        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
        ));
    }
}