use serde::{Deserialize, Serialize};
use crate::{
AdmissionPolicy, BackoffPolicy, Labels, RestartPolicy, RunnerSelector, Slot, TaskKind, Timeout,
error::{ModelError, ModelResult},
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(try_from = "raw::TaskSpecRaw")]
pub struct TaskSpec {
slot: Slot,
kind: TaskKind,
timeout: Timeout,
restart: RestartPolicy,
backoff: BackoffPolicy,
admission: AdmissionPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
runner_selector: Option<RunnerSelector>,
#[serde(default, skip_serializing_if = "Labels::is_empty")]
labels: Labels,
}
impl TaskSpec {
#[inline]
pub fn slot(&self) -> &Slot {
&self.slot
}
#[inline]
pub fn kind(&self) -> &TaskKind {
&self.kind
}
#[inline]
pub fn timeout(&self) -> Timeout {
self.timeout
}
#[inline]
pub fn restart(&self) -> RestartPolicy {
self.restart
}
#[inline]
pub fn backoff(&self) -> &BackoffPolicy {
&self.backoff
}
#[inline]
pub fn admission(&self) -> AdmissionPolicy {
self.admission
}
#[inline]
pub fn runner_selector(&self) -> Option<&RunnerSelector> {
self.runner_selector.as_ref()
}
#[inline]
pub fn labels(&self) -> &Labels {
&self.labels
}
}
impl TaskSpec {
pub fn builder(
slot: impl Into<Slot>,
kind: TaskKind,
timeout: impl Into<Timeout>,
) -> TaskSpecBuilder {
TaskSpecBuilder::new(slot, kind, timeout)
}
}
impl TaskSpec {
#[inline]
pub fn with_runner_selector(mut self, sel: RunnerSelector) -> Self {
self.runner_selector = Some(sel);
self
}
}
impl TaskSpec {
pub fn validate(&self) -> ModelResult<()> {
self.validate_structural()?;
if matches!(self.kind, TaskKind::Embedded) {
return Err(ModelError::Invalid(
"TaskKind::Embedded cannot be submitted via runner; use submit_with_task".into(),
));
}
Ok(())
}
fn validate_structural(&self) -> ModelResult<()> {
self.slot.validate_format()?;
if self.timeout.as_millis() == 0 {
return Err(ModelError::Invalid(
"timeout must be greater than zero".into(),
));
}
self.kind.validate()?;
self.backoff.validate()?;
if let Some(ref sel) = self.runner_selector {
for req in &sel.match_expressions {
req.validate()?;
}
}
Ok(())
}
}
pub struct TaskSpecBuilder {
runner_selector: Option<RunnerSelector>,
kind: TaskKind,
slot: Slot,
backoff: BackoffPolicy,
restart: RestartPolicy,
timeout: Timeout,
admission: AdmissionPolicy,
labels: Labels,
}
impl TaskSpecBuilder {
fn new(slot: impl Into<Slot>, kind: TaskKind, timeout: impl Into<Timeout>) -> Self {
Self {
runner_selector: None,
kind,
slot: slot.into(),
restart: RestartPolicy::default(),
backoff: BackoffPolicy::default(),
timeout: timeout.into(),
admission: AdmissionPolicy::default(),
labels: Labels::new(),
}
}
#[must_use]
pub fn restart(mut self, restart: RestartPolicy) -> Self {
self.restart = restart;
self
}
#[must_use]
pub fn backoff(mut self, backoff: BackoffPolicy) -> Self {
self.backoff = backoff;
self
}
#[must_use]
pub fn admission(mut self, admission: AdmissionPolicy) -> Self {
self.admission = admission;
self
}
#[must_use]
pub fn runner_selector(mut self, sel: RunnerSelector) -> Self {
self.runner_selector = Some(sel);
self
}
#[must_use]
pub fn labels(mut self, labels: Labels) -> Self {
self.labels = labels;
self
}
pub fn build(self) -> ModelResult<TaskSpec> {
let spec = TaskSpec {
runner_selector: self.runner_selector,
kind: self.kind,
slot: self.slot,
restart: self.restart,
backoff: self.backoff,
timeout: self.timeout,
admission: self.admission,
labels: self.labels,
};
spec.validate_structural()?;
Ok(spec)
}
}
mod raw {
use super::*;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct TaskSpecRaw {
slot: Slot,
kind: TaskKind,
timeout: Timeout,
restart: RestartPolicy,
backoff: BackoffPolicy,
admission: AdmissionPolicy,
#[serde(default)]
labels: Labels,
#[serde(default)]
runner_selector: Option<RunnerSelector>,
}
impl TryFrom<TaskSpecRaw> for TaskSpec {
type Error = ModelError;
fn try_from(r: TaskSpecRaw) -> Result<Self, Self::Error> {
let spec = Self {
runner_selector: r.runner_selector,
kind: r.kind,
slot: r.slot,
restart: r.restart,
backoff: r.backoff,
timeout: r.timeout,
admission: r.admission,
labels: r.labels,
};
spec.validate_structural()?;
Ok(spec)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Flag, SubprocessMode, SubprocessSpec, TaskEnv};
fn valid_spec() -> TaskSpec {
TaskSpec::builder(
"test",
TaskKind::Subprocess(SubprocessSpec {
mode: SubprocessMode::Command {
command: "echo".into(),
args: vec![],
},
env: TaskEnv::default(),
cwd: None,
fail_on_non_zero: Flag::enabled(),
}),
5_000u64,
)
.build()
.expect("test spec must be valid")
}
#[test]
fn valid_spec_passes() {
assert!(valid_spec().validate().is_ok());
}
#[test]
fn builder_rejects_empty_slot() {
let err = TaskSpec::builder("", TaskKind::Embedded, 5_000u64)
.build()
.unwrap_err();
assert!(err.to_string().contains("slot"));
}
#[test]
fn builder_rejects_zero_timeout() {
let err = TaskSpec::builder("test", TaskKind::Embedded, 0u64)
.build()
.unwrap_err();
assert!(err.to_string().contains("timeout"));
}
#[test]
fn builder_allows_embedded_kind() {
let spec = TaskSpec::builder("test", TaskKind::Embedded, 5_000u64)
.build()
.expect("Embedded is structurally valid");
assert!(matches!(spec.kind(), TaskKind::Embedded));
}
#[test]
fn validate_rejects_embedded_kind() {
let spec = TaskSpec::builder("test", TaskKind::Embedded, 5_000u64)
.build()
.unwrap();
let err = spec.validate().unwrap_err();
assert!(err.to_string().contains("TaskKind::Embedded"));
}
#[test]
fn getters_return_expected_values() {
let spec = TaskSpec::builder("my-slot", TaskKind::Embedded, 10_000u64)
.restart(RestartPolicy::OnFailure)
.admission(AdmissionPolicy::Replace)
.build()
.unwrap();
assert_eq!(spec.slot(), "my-slot");
assert_eq!(spec.timeout().as_millis(), 10_000);
assert_eq!(spec.restart(), RestartPolicy::OnFailure);
assert_eq!(spec.admission(), AdmissionPolicy::Replace);
}
#[test]
fn serde_roundtrip() {
let spec = valid_spec();
let json = serde_json::to_string(&spec).unwrap();
let back: TaskSpec = serde_json::from_str(&json).unwrap();
assert_eq!(back, spec);
}
#[test]
fn serde_rejects_empty_slot() {
let spec = valid_spec();
let mut json: serde_json::Value = serde_json::to_value(&spec).unwrap();
json["slot"] = serde_json::Value::String(String::new());
let err = serde_json::from_value::<TaskSpec>(json).unwrap_err();
assert!(err.to_string().contains("slot"), "error: {err}");
}
#[test]
fn serde_rejects_zero_timeout() {
let spec = valid_spec();
let mut json: serde_json::Value = serde_json::to_value(&spec).unwrap();
json["timeout"] = serde_json::json!(0);
let err = serde_json::from_value::<TaskSpec>(json).unwrap_err();
assert!(err.to_string().contains("timeout"), "error: {err}");
}
}