//! 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 { 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 { 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")); } }