use serde::{Deserialize, Serialize};
use crate::wire::{RunAs, Shell, Staleness};
#[derive(Serialize, Deserialize, schemars::JsonSchema, 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>,
#[serde(default)]
pub emit: Option<EmitConfig>,
#[serde(default)]
pub staleness: Staleness,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct InventoryHint {
pub display: Vec<DisplayField>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<Vec<DisplayField>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explode: Option<Vec<ExplodeSpec>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_scalars: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct EmitConfig {
#[serde(rename = "type")]
pub kind: EmitKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watermark_path: Option<String>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum EmitKind {
Events,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct ExplodeSpec {
pub field: String,
pub table: String,
pub primary_key: Vec<String>,
pub columns: Vec<ExplodeColumn>,
#[serde(default)]
pub track_history: bool,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct ExplodeColumn {
pub field: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "type")]
pub kind: Option<String>,
#[serde(default)]
pub index: bool,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<Vec<DisplayField>>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct Rollout {
#[serde(default)]
pub strategy: RolloutStrategy,
pub waves: Vec<Wave>,
}
#[derive(
Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
)]
#[serde(rename_all = "lowercase")]
pub enum RolloutStrategy {
#[default]
Wave,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct Wave {
pub group: String,
pub delay: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, 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, schemars::JsonSchema, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct Execute {
pub shell: ExecuteShell,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_object: Option<String>,
pub timeout: String,
#[serde(default)]
pub run_as: RunAs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
impl Execute {
fn has_inline_script(&self) -> bool {
matches!(&self.script, Some(s) if !s.is_empty())
}
pub fn validate_script_source(&self) -> Result<(), String> {
let inline = self.has_inline_script();
let file = self.script_file.is_some();
let obj = self.script_object.is_some();
let set = [inline, file, obj].into_iter().filter(|b| *b).count();
match set {
1 => Ok(()),
0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
_ => Err(format!(
"execute: only one of `script` / `script_file` / `script_object` may be set \
(got script={inline}, script_file={file}, script_object={obj})"
)),
}
}
}
impl Manifest {
pub fn validate(&self) -> Result<(), String> {
self.execute.validate_script_source()?;
Ok(())
}
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, 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!["pc-01".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.as_deref().map(str::trim),
Some("echo 'kanade'")
);
assert!(m.execute.script_file.is_none());
assert!(m.execute.script_object.is_none());
assert_eq!(m.execute.timeout, "30s");
assert!(!m.require_approval);
m.validate()
.expect("inline-script manifest passes validation");
}
fn execute_with(
script: Option<&str>,
script_file: Option<&str>,
script_object: Option<&str>,
) -> Execute {
Execute {
shell: ExecuteShell::Powershell,
script: script.map(str::to_owned),
script_file: script_file.map(str::to_owned),
script_object: script_object.map(str::to_owned),
timeout: "30s".into(),
run_as: RunAs::default(),
cwd: None,
}
}
#[test]
fn validate_accepts_inline_script() {
let e = execute_with(Some("echo hi"), None, None);
assert!(e.validate_script_source().is_ok());
}
#[test]
fn validate_accepts_script_file_alone() {
let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
assert!(e.validate_script_source().is_ok());
}
#[test]
fn validate_accepts_script_object_alone() {
let e = execute_with(None, None, Some("cleanup/1.0.0"));
assert!(e.validate_script_source().is_ok());
}
#[test]
fn validate_treats_empty_inline_script_as_unset() {
let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
assert!(e.validate_script_source().is_ok());
}
#[test]
fn validate_rejects_zero_sources() {
let e = execute_with(None, None, None);
let err = e.validate_script_source().unwrap_err();
assert!(err.contains("must be set"), "got: {err}");
}
#[test]
fn validate_rejects_empty_inline_only() {
let e = execute_with(Some(""), None, None);
let err = e.validate_script_source().unwrap_err();
assert!(err.contains("must be set"), "got: {err}");
}
#[test]
fn validate_rejects_inline_plus_file() {
let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
let err = e.validate_script_source().unwrap_err();
assert!(err.contains("only one of"), "got: {err}");
}
#[test]
fn validate_rejects_inline_plus_object() {
let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
let err = e.validate_script_source().unwrap_err();
assert!(err.contains("only one of"), "got: {err}");
}
#[test]
fn validate_rejects_file_plus_object() {
let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
let err = e.validate_script_source().unwrap_err();
assert!(err.contains("only one of"), "got: {err}");
}
#[test]
fn validate_rejects_all_three() {
let e = execute_with(
Some("echo hi"),
Some("scripts/cleanup.ps1"),
Some("cleanup/1.0.0"),
);
let err = e.validate_script_source().unwrap_err();
assert!(err.contains("only one of"), "got: {err}");
}
#[test]
fn manifest_deserialises_script_object_yaml() {
let yaml = r#"
id: cleanup-disk-temp
version: 1.0.1
execute:
shell: powershell
script_object: cleanup-disk-temp/1.0.1
timeout: 600s
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(
m.execute.script_object.as_deref(),
Some("cleanup-disk-temp/1.0.1")
);
assert!(m.execute.script.is_none());
m.validate()
.expect("script_object-only manifest passes validation");
}
#[test]
fn manifest_rejects_typo_in_script_field_name() {
let yaml = r#"
id: typo
version: 1.0.0
execute:
shell: powershell
script_objectt: oops
timeout: 30s
"#;
let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
assert!(r.is_err(), "expected parse error, got {r:?}");
}
#[test]
fn schedule_carries_target_and_rollout() {
let yaml = r#"
id: hourly-cleanup-canary
when:
per_pc: { every: 1h }
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: kitting
when:
per_pc: once
enabled: true
job_id: scheduled-echo
target: { all: true }
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.id, "kitting");
assert_eq!(s.when, When::PerPc(PerPolicy::Once(OnceLiteral::Once)));
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());
assert!(s.active.is_empty());
}
#[test]
fn schedule_enabled_defaults_to_true() {
let yaml = r#"
id: x
when:
per_pc: once
job_id: y
target: { all: true }
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert!(s.enabled);
}
fn schedule_yaml_with(when_block: &str) -> String {
format!(
r#"
id: x
when:
{when_block}
job_id: y
target: {{ all: true }}
"#
)
}
#[test]
fn when_per_pc_every_parses_unquoted_humantime() {
let s: Schedule =
serde_yaml::from_str(&schedule_yaml_with(" per_pc: { every: 6h }")).expect("parse");
assert_eq!(
s.when,
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() }))
);
}
#[test]
fn when_per_target_every_parses() {
let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(" per_target: { every: 24h }"))
.expect("parse");
assert_eq!(
s.when,
When::PerTarget(PerPolicy::Every(EverySpec {
every: "24h".into()
}))
);
}
#[test]
fn when_per_target_once_parses() {
let s: Schedule =
serde_yaml::from_str(&schedule_yaml_with(" per_target: once")).expect("parse");
assert_eq!(s.when, When::PerTarget(PerPolicy::Once(OnceLiteral::Once)));
}
#[test]
fn when_cron_escape_hatch_parses() {
let s: Schedule =
serde_yaml::from_str(&schedule_yaml_with(" cron: \"0 0 9 * * mon-fri\""))
.expect("parse");
assert_eq!(s.when, When::Cron("0 0 9 * * mon-fri".into()));
}
#[test]
fn when_rejects_bad_once_keyword() {
let r: Result<Schedule, _> = serde_yaml::from_str(&schedule_yaml_with(" per_pc: onec"));
assert!(r.is_err(), "expected parse error, got {r:?}");
}
#[test]
fn when_rejects_unknown_key_in_every() {
let r: Result<Schedule, _> =
serde_yaml::from_str(&schedule_yaml_with(" per_pc: { evry: 6h }"));
assert!(r.is_err(), "expected parse error, got {r:?}");
}
#[test]
fn when_rejects_unknown_variant() {
let r: Result<Schedule, _> =
serde_yaml::from_str(&schedule_yaml_with(" per_galaxy: once"));
assert!(r.is_err(), "expected parse error, got {r:?}");
}
#[test]
fn when_rejects_old_top_level_cron_field() {
let yaml = r#"
id: x
cron: "* * * * * *"
job_id: y
target: { all: true }
"#;
let r: Result<Schedule, _> = serde_yaml::from_str(yaml);
assert!(r.is_err(), "expected parse error, got {r:?}");
}
#[test]
fn when_round_trips_json_and_yaml() {
for when in [
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
When::PerTarget(PerPolicy::Every(EverySpec {
every: "24h".into(),
})),
When::Cron("0 0 9 * * mon-fri".into()),
] {
let s = schedule_with(when.clone(), RunsOn::Backend);
let json = serde_json::to_string(&s).expect("json serialise");
let back: Schedule = serde_json::from_str(&json).expect("json deserialise");
assert_eq!(back.when, when, "json round-trip for {when}");
let yaml = serde_yaml::to_string(&s).expect("yaml serialise");
assert!(
!yaml.contains('!'),
"yaml must use the map shape, not tags: {yaml}"
);
let back: Schedule = serde_yaml::from_str(&yaml).expect("yaml deserialise");
assert_eq!(back.when, when, "yaml round-trip for {when}");
}
}
#[test]
fn when_once_serialises_as_bare_keyword() {
let json = serde_json::to_value(When::PerPc(PerPolicy::Once(OnceLiteral::Once)))
.expect("serialise");
assert_eq!(json, serde_json::json!({ "per_pc": "once" }));
}
#[test]
fn when_displays_operator_summary() {
for (when, expected) in [
(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
"per_pc once",
),
(
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
"per_pc every 6h",
),
(
When::PerTarget(PerPolicy::Every(EverySpec {
every: "24h".into(),
})),
"per_target every 24h",
),
(
When::Cron("0 0 9 * * mon-fri".into()),
"cron: 0 0 9 * * mon-fri",
),
] {
assert_eq!(when.to_string(), expected);
}
}
fn schedule_with(when: When, runs_on: RunsOn) -> Schedule {
Schedule {
id: "x".into(),
when,
job_id: "y".into(),
plan: FanoutPlan::default(),
active: Active::default(),
starting_deadline: None,
runs_on,
enabled: true,
}
}
#[test]
fn lowering_matches_the_418_table() {
let cases = [
(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
(POLL_CRON, ExecMode::OncePerPc, None),
),
(
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
(POLL_CRON, ExecMode::OncePerPc, Some("6h")),
),
(
When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
(POLL_CRON, ExecMode::OncePerTarget, None),
),
(
When::PerTarget(PerPolicy::Every(EverySpec {
every: "24h".into(),
})),
(POLL_CRON, ExecMode::OncePerTarget, Some("24h")),
),
(
When::Cron("0 0 9 * * mon-fri".into()),
("0 0 9 * * mon-fri", ExecMode::EveryTick, None),
),
];
for (when, (cron, mode, cooldown)) in cases {
let l = schedule_with(when.clone(), RunsOn::Backend).lowered();
assert_eq!(l.cron, cron, "cron for {when}");
assert_eq!(l.mode, mode, "mode for {when}");
assert_eq!(l.cooldown.as_deref(), cooldown, "cooldown for {when}");
}
}
#[test]
fn poll_cron_is_accepted_by_the_engine_parser() {
let s = schedule_with(When::Cron(POLL_CRON.into()), RunsOn::Backend);
s.validate().expect("POLL_CRON must be valid");
}
#[test]
fn validate_accepts_reconcile_shapes() {
for when in [
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
When::PerTarget(PerPolicy::Every(EverySpec {
every: "24h".into(),
})),
] {
schedule_with(when.clone(), RunsOn::Backend)
.validate()
.unwrap_or_else(|e| panic!("{when} should validate: {e}"));
}
}
#[test]
fn validate_accepts_per_pc_on_agent() {
schedule_with(
When::PerPc(PerPolicy::Every(EverySpec { every: "1h".into() })),
RunsOn::Agent,
)
.validate()
.expect("per_pc + agent is the offline-inventory shape");
}
#[test]
fn validate_rejects_per_target_on_agent() {
let err = schedule_with(
When::PerTarget(PerPolicy::Every(EverySpec {
every: "24h".into(),
})),
RunsOn::Agent,
)
.validate()
.unwrap_err();
assert!(err.contains("per_target"), "got: {err}");
assert!(err.contains("runs_on: agent"), "got: {err}");
let err = schedule_with(
When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
RunsOn::Agent,
)
.validate()
.unwrap_err();
assert!(err.contains("per_target"), "got (once): {err}");
assert!(err.contains("runs_on: agent"), "got (once): {err}");
}
#[test]
fn validate_rejects_bad_every_duration() {
let err = schedule_with(
When::PerPc(PerPolicy::Every(EverySpec { every: "6x".into() })),
RunsOn::Backend,
)
.validate()
.unwrap_err();
assert!(err.contains("when.every"), "got: {err}");
}
#[test]
fn validate_rejects_bad_jitter_and_starting_deadline() {
let mut s = schedule_with(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
RunsOn::Backend,
);
s.plan.jitter = Some("5x".into());
let err = s.validate().unwrap_err();
assert!(err.contains("jitter"), "got: {err}");
let mut s = schedule_with(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
RunsOn::Backend,
);
s.starting_deadline = Some("soon".into());
let err = s.validate().unwrap_err();
assert!(err.contains("starting_deadline"), "got: {err}");
}
#[test]
fn validate_rejects_bad_cron() {
for bad in ["not a cron", "* * * * *", "99 * * * * *"] {
let err = schedule_with(When::Cron(bad.into()), RunsOn::Backend)
.validate()
.unwrap_err();
assert!(err.contains("when.cron"), "for '{bad}', got: {err}");
}
}
#[test]
fn validate_accepts_engine_cron() {
schedule_with(When::Cron("0 0 9 * * mon-fri".into()), RunsOn::Backend)
.validate()
.expect("week-day cron should validate");
}
fn schedule_with_active(from: Option<&str>, until: Option<&str>) -> Schedule {
let mut s = schedule_with(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
RunsOn::Backend,
);
s.active = Active {
from: from.map(str::to_owned),
until: until.map(str::to_owned),
};
s
}
#[test]
fn validate_accepts_active_window() {
schedule_with_active(Some("2026-07-01"), Some("2026-08-01T12:00:00+09:00"))
.validate()
.expect("date + rfc3339 bounds should validate");
}
#[test]
fn validate_rejects_unparseable_active_bound() {
let err = schedule_with_active(Some("July 1st"), None)
.validate()
.unwrap_err();
assert!(err.contains("active"), "got: {err}");
}
#[test]
fn validate_rejects_from_not_before_until() {
let err = schedule_with_active(Some("2026-08-01"), Some("2026-07-01"))
.validate()
.unwrap_err();
assert!(err.contains("strictly before"), "got: {err}");
let err = schedule_with_active(Some("2026-07-01"), Some("2026-07-01"))
.validate()
.unwrap_err();
assert!(err.contains("strictly before"), "got: {err}");
}
#[test]
fn active_window_is_half_open() {
use chrono::TimeZone;
let active = Active {
from: Some("2026-07-01".into()),
until: Some("2026-08-01".into()),
};
let at = |y, m, d, h| chrono::Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap();
assert!(!active.contains(at(2026, 6, 30, 23)), "before from");
assert!(active.contains(at(2026, 7, 1, 0)), "at from (inclusive)");
assert!(active.contains(at(2026, 7, 15, 12)), "inside");
assert!(!active.contains(at(2026, 8, 1, 0)), "at until (exclusive)");
assert!(!active.contains(at(2026, 8, 2, 0)), "after until");
}
#[test]
fn active_empty_window_is_always_active() {
assert!(Active::default().contains(chrono::Utc::now()));
}
#[test]
fn active_rfc3339_bound_honours_offset() {
use chrono::TimeZone;
let active = Active {
from: Some("2026-07-01T09:00:00+09:00".into()),
until: None,
};
assert!(
!active.contains(
chrono::Utc
.with_ymd_and_hms(2026, 6, 30, 23, 59, 0)
.unwrap()
)
);
assert!(active.contains(chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()));
}
#[test]
fn active_empty_is_skipped_when_serialising() {
let s = schedule_with(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
RunsOn::Backend,
);
let json = serde_json::to_value(&s).expect("serialise");
assert!(
json.get("active").is_none(),
"empty active must not appear on the wire: {json}"
);
}
#[test]
fn shipped_schedule_configs_parse_and_validate() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs/schedules");
let mut seen = 0;
for entry in std::fs::read_dir(&dir).expect("read configs/schedules") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
let body = std::fs::read_to_string(&path).expect("read yaml");
let s: Schedule = serde_yaml::from_str(&body)
.unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display()));
s.validate()
.unwrap_or_else(|e| panic!("{} failed validate(): {e}", path.display()));
seen += 1;
}
assert!(seen > 0, "no schedule YAMLs found in {}", dir.display());
}
#[test]
fn exec_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_runs_on_defaults_to_backend() {
let yaml = r#"
id: x
when:
per_pc: once
job_id: y
target: { all: true }
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.runs_on, RunsOn::Backend);
}
#[test]
fn schedule_runs_on_agent_parses() {
let yaml = r#"
id: offline-inv
when:
per_pc: { every: 1h }
job_id: inventory-hw
target: { all: true }
runs_on: agent
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.runs_on, RunsOn::Agent);
assert_eq!(s.lowered().mode, ExecMode::OncePerPc);
}
#[test]
fn runs_on_serialises_snake_case() {
for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
let s = serde_json::to_value(mode).expect("serialise");
assert_eq!(s, serde_json::Value::String(expected.into()));
let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
.expect("deserialise");
assert_eq!(back, mode);
}
}
#[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 manifest_staleness_defaults_to_cached() {
let yaml = r#"
id: x
version: 1.0.0
execute:
shell: powershell
script: "echo"
timeout: 1s
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(m.staleness, Staleness::Cached);
}
#[test]
fn manifest_strict_staleness_parses() {
let yaml = r#"
id: urgent-patch
version: 2.5.1
execute:
shell: powershell
script: Install-Hotfix
timeout: 5m
staleness:
mode: strict
max_cache_age: 0s
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
match m.staleness {
Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
other => panic!("expected strict, got {other:?}"),
}
}
#[test]
fn manifest_unchecked_staleness_parses() {
let yaml = r#"
id: legacy
version: 0.1.0
execute:
shell: cmd
script: "echo"
timeout: 1s
staleness:
mode: unchecked
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(m.staleness, Staleness::Unchecked);
}
#[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);
}
#[test]
fn display_field_table_kind_round_trips_with_nested_columns() {
let yaml = r#"
id: inv-hw
version: 1.0.0
execute:
shell: powershell
script: "echo"
timeout: 60s
inventory:
display:
- field: hostname
label: Hostname
- field: disks
label: Disks
type: table
columns:
- field: device_id
label: Drive
- field: size_bytes
label: Size
type: bytes
- field: free_bytes
label: Free
type: bytes
- field: file_system
label: FS
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let inv = m.inventory.as_ref().expect("inventory hint");
let disks = inv
.display
.iter()
.find(|d| d.field == "disks")
.expect("disks display row");
assert_eq!(disks.kind.as_deref(), Some("table"));
let cols = disks.columns.as_ref().expect("table needs columns");
assert_eq!(cols.len(), 4);
assert_eq!(cols[1].field, "size_bytes");
assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
}
#[test]
fn display_field_scalar_kind_keeps_columns_none() {
let yaml = r#"
id: x
version: 1.0.0
execute:
shell: powershell
script: "echo"
timeout: 5s
inventory:
display:
- { field: ram_bytes, label: RAM, type: bytes }
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let inv = m.inventory.as_ref().unwrap();
assert!(inv.display[0].columns.is_none());
}
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct Schedule {
pub id: String,
#[serde(with = "serde_yaml::with::singleton_map")]
#[schemars(with = "When")]
pub when: When,
pub job_id: String,
#[serde(flatten)]
pub plan: FanoutPlan,
#[serde(default, skip_serializing_if = "Active::is_empty")]
pub active: Active,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub starting_deadline: Option<String>,
#[serde(default)]
pub runs_on: RunsOn,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(
Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
)]
#[serde(rename_all = "snake_case")]
pub enum RunsOn {
#[default]
Backend,
Agent,
}
#[derive(
Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
)]
#[serde(rename_all = "snake_case")]
pub enum ExecMode {
#[default]
EveryTick,
OncePerPc,
OncePerTarget,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum When {
PerPc(PerPolicy),
PerTarget(PerPolicy),
Cron(String),
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum PerPolicy {
Once(OnceLiteral),
Every(EverySpec),
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OnceLiteral {
Once,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct EverySpec {
pub every: String,
}
impl PerPolicy {
fn cooldown(&self) -> Option<String> {
match self {
PerPolicy::Once(_) => None,
PerPolicy::Every(EverySpec { every }) => Some(every.clone()),
}
}
}
impl std::fmt::Display for When {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let policy = |p: &PerPolicy| match p {
PerPolicy::Once(_) => "once".to_string(),
PerPolicy::Every(EverySpec { every }) => format!("every {every}"),
};
match self {
When::PerPc(p) => write!(f, "per_pc {}", policy(p)),
When::PerTarget(p) => write!(f, "per_target {}", policy(p)),
When::Cron(expr) => write!(f, "cron: {expr}"),
}
}
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Active {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<String>,
}
impl Active {
pub fn is_empty(&self) -> bool {
self.from.is_none() && self.until.is_none()
}
pub fn parse_bound(s: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&chrono::Utc));
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let midnight = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
return Ok(chrono::DateTime::from_naive_utc_and_offset(
midnight,
chrono::Utc,
));
}
Err(format!(
"active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
))
}
pub fn contains(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s).ok());
if bound(&self.from).is_some_and(|from| now < from) {
return false;
}
if bound(&self.until).is_some_and(|until| now >= until) {
return false;
}
true
}
}
pub const POLL_CRON: &str = "0 * * * * *";
pub struct Lowered {
pub cron: String,
pub mode: ExecMode,
pub cooldown: Option<String>,
}
impl Schedule {
pub fn lowered(&self) -> Lowered {
match &self.when {
When::PerPc(p) => Lowered {
cron: POLL_CRON.into(),
mode: ExecMode::OncePerPc,
cooldown: p.cooldown(),
},
When::PerTarget(p) => Lowered {
cron: POLL_CRON.into(),
mode: ExecMode::OncePerTarget,
cooldown: p.cooldown(),
},
When::Cron(expr) => Lowered {
cron: expr.clone(),
mode: ExecMode::EveryTick,
cooldown: None,
},
}
}
pub fn validate(&self) -> Result<(), String> {
if matches!(self.runs_on, RunsOn::Agent) && matches!(self.when, When::PerTarget(_)) {
return Err(
"when.per_target needs fleet-wide completion data and is backend-only; \
it cannot be combined with runs_on: agent (each agent self-schedules, \
so per-target dedup would be deduping across a target of 1)"
.into(),
);
}
if let Some(cd) = self.lowered().cooldown.as_deref() {
humantime::parse_duration(cd)
.map_err(|e| format!("when.every: invalid duration '{cd}': {e}"))?;
}
if let When::Cron(expr) = &self.when {
croner::parser::CronParser::builder()
.seconds(croner::parser::Seconds::Required)
.dom_and_dow(true)
.build()
.parse(expr)
.map_err(|e| format!("when.cron: invalid 6-field cron '{expr}': {e}"))?;
}
if let Some(j) = &self.plan.jitter {
humantime::parse_duration(j)
.map_err(|e| format!("jitter: invalid duration '{j}': {e}"))?;
}
if let Some(sd) = &self.starting_deadline {
humantime::parse_duration(sd)
.map_err(|e| format!("starting_deadline: invalid duration '{sd}': {e}"))?;
}
let from = self
.active
.from
.as_deref()
.map(Active::parse_bound)
.transpose()?;
let until = self
.active
.until
.as_deref()
.map(Active::parse_bound)
.transpose()?;
if let (Some(f), Some(u)) = (from, until) {
if f >= u {
return Err(format!(
"active.from ({}) must be strictly before active.until ({})",
self.active.from.as_deref().unwrap_or_default(),
self.active.until.as_deref().unwrap_or_default(),
));
}
}
Ok(())
}
}
fn default_true() -> bool {
true
}