use serde::{Deserialize, Serialize};
use crate::wire::{RunAs, Shell};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub id: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
pub execute: Execute,
#[serde(default)]
pub require_approval: bool,
#[serde(default)]
pub inventory: Option<InventoryHint>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct FanoutPlan {
#[serde(default)]
pub target: Target,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rollout: Option<Rollout>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jitter: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InventoryHint {
pub display: Vec<DisplayField>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<Vec<DisplayField>>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DisplayField {
pub field: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "type")]
pub kind: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Rollout {
#[serde(default)]
pub strategy: RolloutStrategy,
pub waves: Vec<Wave>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum RolloutStrategy {
#[default]
Wave,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Wave {
pub group: String,
pub delay: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Target {
#[serde(default)]
pub groups: Vec<String>,
#[serde(default)]
pub pcs: Vec<String>,
#[serde(default)]
pub all: bool,
}
impl Target {
pub fn is_specified(&self) -> bool {
self.all || !self.groups.is_empty() || !self.pcs.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Execute {
pub shell: ExecuteShell,
pub script: String,
pub timeout: String,
#[serde(default)]
pub run_as: RunAs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ExecuteShell {
Powershell,
Cmd,
}
impl From<ExecuteShell> for Shell {
fn from(s: ExecuteShell) -> Self {
match s {
ExecuteShell::Powershell => Shell::Powershell,
ExecuteShell::Cmd => Shell::Cmd,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_is_specified_requires_at_least_one_field() {
let empty = Target::default();
assert!(!empty.is_specified());
let with_all = Target {
all: true,
..Target::default()
};
assert!(with_all.is_specified());
let with_groups = Target {
groups: vec!["canary".into()],
..Target::default()
};
assert!(with_groups.is_specified());
let with_pcs = Target {
pcs: vec!["minipc".into()],
..Target::default()
};
assert!(with_pcs.is_specified());
}
#[test]
fn manifest_deserialises_minimal_yaml() {
let yaml = r#"
id: echo-test
version: 0.0.1
execute:
shell: powershell
script: "echo 'kanade'"
timeout: 30s
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(m.id, "echo-test");
assert_eq!(m.version, "0.0.1");
assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
assert_eq!(m.execute.script.trim(), "echo 'kanade'");
assert_eq!(m.execute.timeout, "30s");
assert!(!m.require_approval);
}
#[test]
fn schedule_carries_target_and_rollout() {
let yaml = r#"
id: hourly-cleanup-canary
cron: "0 0 * * * *"
job_id: cleanup
enabled: true
target:
groups: [canary, wave1]
jitter: 30s
rollout:
strategy: wave
waves:
- { group: canary, delay: 0s }
- { group: wave1, delay: 5s }
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.id, "hourly-cleanup-canary");
assert_eq!(s.job_id, "cleanup");
assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
let rollout = s.plan.rollout.expect("rollout present");
assert_eq!(rollout.waves.len(), 2);
assert_eq!(rollout.waves[0].group, "canary");
assert_eq!(rollout.waves[1].delay, "5s");
assert_eq!(rollout.strategy, RolloutStrategy::Wave);
}
#[test]
fn schedule_minimal_target_all() {
let yaml = r#"
id: every-10s
cron: "*/10 * * * * *"
enabled: true
job_id: scheduled-echo
target: { all: true }
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.id, "every-10s");
assert_eq!(s.cron, "*/10 * * * * *");
assert!(s.enabled);
assert_eq!(s.job_id, "scheduled-echo");
assert!(s.plan.target.all);
assert!(s.plan.rollout.is_none());
assert!(s.plan.jitter.is_none());
}
#[test]
fn schedule_enabled_defaults_to_true() {
let yaml = r#"
id: x
cron: "* * * * * *"
job_id: y
target: { all: true }
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert!(s.enabled);
}
#[test]
fn schedule_mode_defaults_to_every_tick() {
let yaml = r#"
id: x
cron: "* * * * * *"
job_id: y
target: { all: true }
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.mode, ExecMode::EveryTick);
assert!(s.cooldown.is_none());
assert!(!s.auto_disable_when_done);
}
#[test]
fn schedule_mode_serialises_snake_case() {
for (mode, expected) in [
(ExecMode::EveryTick, "every_tick"),
(ExecMode::OncePerPc, "once_per_pc"),
(ExecMode::OncePerTarget, "once_per_target"),
] {
let s = serde_json::to_value(mode).expect("serialise");
assert_eq!(s, serde_json::Value::String(expected.into()));
let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
.expect("deserialise");
assert_eq!(back, mode, "round-trip for {expected}");
}
}
#[test]
fn schedule_kitting_yaml_parses() {
let yaml = r#"
id: kitting-setup
cron: "*/30 * * * * *"
job_id: install-baseline
target: { all: true }
mode: once_per_pc
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.mode, ExecMode::OncePerPc);
assert!(s.cooldown.is_none());
assert!(!s.auto_disable_when_done);
}
#[test]
fn schedule_batch_campaign_yaml_parses() {
let yaml = r#"
id: q3-patch-batch
cron: "*/5 * * * * *"
job_id: install-patch
target:
pcs: [pc-001, pc-002, pc-003]
mode: once_per_pc
auto_disable_when_done: true
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.mode, ExecMode::OncePerPc);
assert!(s.cooldown.is_none());
assert!(s.auto_disable_when_done);
assert_eq!(s.plan.target.pcs.len(), 3);
}
#[test]
fn schedule_throttled_yaml_parses() {
let yaml = r#"
id: daily-compliance
cron: "*/5 * * * * *"
job_id: check-av-status
target: { all: true }
mode: once_per_pc
cooldown: 1d
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.mode, ExecMode::OncePerPc);
assert_eq!(s.cooldown.as_deref(), Some("1d"));
}
#[test]
fn schedule_once_per_target_yaml_parses() {
let yaml = r#"
id: license-checkin
cron: "*/10 * * * * *"
job_id: hit-license-server
target: { all: true }
mode: once_per_target
cooldown: 24h
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.mode, ExecMode::OncePerTarget);
assert_eq!(s.cooldown.as_deref(), Some("24h"));
}
#[test]
fn execute_shell_into_wire_shell() {
assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
}
#[test]
fn missing_required_field_errors() {
let yaml = r#"
version: 1.0.0
target: { all: true }
execute:
shell: powershell
script: "echo"
timeout: 1s
"#;
let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
assert!(r.is_err(), "expected error, got {:?}", r);
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Schedule {
pub id: String,
pub cron: String,
pub job_id: String,
#[serde(flatten)]
pub plan: FanoutPlan,
#[serde(default)]
pub mode: ExecMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cooldown: Option<String>,
#[serde(default)]
pub auto_disable_when_done: bool,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ExecMode {
#[default]
EveryTick,
OncePerPc,
OncePerTarget,
}
fn default_true() -> bool {
true
}