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

johnandersen777.bsky.social/knotless-runner-k8s

0
0
185 lines · 6.0 KB Raw History
//! The Job one workflow run becomes.
//!
//! One Job, one pod, one container that stays alive until the run ends.
//! Steps are `kubectl exec` calls into it, so tools installed by one step
//! are there for the next, as under knotless's docker executor.

use crate::cli::Options;
use crate::json::quote;

/// The Job name, as a DNS label: lowercase alphanumerics and `-`, at most
/// 63 characters, starting and ending alphanumeric.
pub fn job_name(name: &str) -> Result<String, String> {
    let mut out = String::new();
    for ch in name.chars() {
        let ch = ch.to_ascii_lowercase();
        if ch.is_ascii_alphanumeric() {
            out.push(ch);
        } else {
            out.push('-');
        }
    }
    let out = out.trim_matches('-').to_string();
    if out.is_empty() {
        return Err(format!("{name:?} has no letters or digits for a Job name"));
    }
    if out.len() <= 63 {
        return Ok(out);
    }
    // Keep it deterministic and inside the label limit the Job's pod label
    // also needs.
    let hash = fnv1a(out.as_bytes());
    let keep = 63 - 9;
    let mut cut = out[..keep].trim_end_matches('-').to_string();
    cut.push('-');
    cut.push_str(&format!("{:08x}", hash as u32));
    Ok(cut)
}

/// A Kubernetes label value for `value`: lowercase alphanumerics, `-`, `_`
/// and `.`, at most 63, starting and ending alphanumeric.
pub fn label_value(value: &str) -> String {
    let mut out = String::new();
    for ch in value.chars() {
        let ch = ch.to_ascii_lowercase();
        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
            out.push(ch);
        } else {
            out.push('-');
        }
    }
    let out = out.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_string();
    if out.is_empty() {
        return "unknown".to_string();
    }
    if out.len() <= 63 {
        out
    } else {
        let hash = fnv1a(out.as_bytes());
        let keep = 63 - 9;
        let mut cut = out[..keep].trim_end_matches(|c: char| !c.is_ascii_alphanumeric()).to_string();
        cut.push('-');
        cut.push_str(&format!("{:08x}", hash as u32));
        cut
    }
}

fn fnv1a(bytes: &[u8]) -> u64 {
    let mut hash: u64 = 0xcbf29ce484222325;
    for byte in bytes {
        hash ^= *byte as u64;
        hash = hash.wrapping_mul(0x100000001b3);
    }
    hash
}

/// The Job manifest as JSON, which `kubectl apply -f -` accepts.
pub fn job_json(options: &Options) -> Result<String, String> {
    let name = job_name(&options.name)?;
    let runner_label = label_value(&options.runner);
    let run_label = label_value(&options.name);
    let runner = quote(&options.runner);
    let run = quote(&options.name);
    let image = quote(&options.image);
    let namespace = quote(&options.namespace);
    let mut resources = String::new();
    if options.cpu_request.is_some() || options.memory_request.is_some() {
        let mut requests = Vec::new();
        if let Some(cpu) = &options.cpu_request {
            requests.push(format!("{}:{}", quote("cpu"), quote(cpu)));
        }
        if let Some(memory) = &options.memory_request {
            requests.push(format!("{}:{}", quote("memory"), quote(memory)));
        }
        resources = format!(
            ",\"resources\":{{\"requests\":{{{}}}}}",
            requests.join(",")
        );
    }
    Ok(format!(
        concat!(
            "{{",
            "\"apiVersion\":\"batch/v1\",",
            "\"kind\":\"Job\",",
            "\"metadata\":{{",
            "\"name\":{},",
            "\"namespace\":{},",
            "\"labels\":{{\"dev.knotless.runner\":{},\"dev.knotless.run\":{}}},",
            "\"annotations\":{{\"dev.knotless.runner\":{},\"dev.knotless.run\":{}}}",
            "}},",
            "\"spec\":{{",
            "\"backoffLimit\":0,",
            "\"activeDeadlineSeconds\":{},",
            "\"template\":{{",
            "\"metadata\":{{\"labels\":{{\"dev.knotless.runner\":{},\"dev.knotless.run\":{}}}}},",
            "\"spec\":{{",
            "\"restartPolicy\":\"Never\",",
            "\"automountServiceAccountToken\":false,",
            "\"enableServiceLinks\":false,",
            "\"containers\":[{{",
            "\"name\":\"run\",",
            "\"image\":{},",
            "\"command\":[\"sh\",\"-c\",\"sleep infinity\"]",
            "{}",
            "}}]",
            "}}",
            "}}",
            "}}",
            "}}"
        ),
        quote(&name),
        namespace,
        quote(&runner_label),
        quote(&run_label),
        runner,
        run,
        options.timeout.as_secs().max(1),
        quote(&runner_label),
        quote(&run_label),
        image,
        resources
    ))
}

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

    #[test]
    fn names() {
        assert_eq!(job_name("ci-1").unwrap(), "ci-1");
        assert_eq!(job_name("CI 1/Test").unwrap(), "ci-1-test");
        assert_eq!(job_name("---").unwrap_err(), "\"---\" has no letters or digits for a Job name");
        let long = job_name(&"a".repeat(100)).unwrap();
        assert_eq!(long.len(), 63);
        assert!(long.starts_with("aaaaaaaa"));
    }

    #[test]
    fn labels() {
        assert_eq!(label_value("did:plc:abc"), "did-plc-abc");
        assert_eq!(label_value(""), "unknown");
    }

    #[test]
    fn manifest() {
        let options = Options {
            name: "ci 1".into(),
            runner: "did:plc:runner".into(),
            image: "rust:1.98".into(),
            namespace: "ci".into(),
            timeout: Duration::from_secs(120),
            cpu_request: Some("1".into()),
            ..Options::default()
        };
        let json = job_json(&options).unwrap();
        assert!(json.contains("\"name\":\"ci-1\""));
        assert!(json.contains("\"namespace\":\"ci\""));
        assert!(json.contains("\"dev.knotless.runner\":\"did-plc-runner\""));
        assert!(json.contains("\"dev.knotless.runner\":\"did:plc:runner\""));
        assert!(json.contains("\"activeDeadlineSeconds\":120"));
        assert!(json.contains("\"cpu\":\"1\""));
        assert!(json.contains("\"automountServiceAccountToken\":false"));
    }
}