use serde::{Deserialize, Serialize};
use crate::ipc::jobs::JobCategory;
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 check: Option<CheckHint>,
#[serde(default)]
pub staleness: Staleness,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<ClientHint>,
}
#[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 CheckHint {
pub name: String,
#[serde(default = "default_status_field")]
pub status_field: String,
#[serde(default = "default_detail_field")]
pub detail_field: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub troubleshoot: Option<String>,
#[serde(default = "default_fleet")]
pub fleet: bool,
}
fn default_status_field() -> String {
"status".to_string()
}
fn default_detail_field() -> String {
"detail".to_string()
}
fn default_fleet() -> bool {
true
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct ClientHint {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub category: JobCategory,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<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()?;
if self.emit.is_some() && (self.inventory.is_some() || self.check.is_some()) {
return Err(
"`emit:` is incompatible with `inventory:` / `check:` — emit's stdout is NDJSON \
timeline events (and omitted from the result), while inventory/check read a \
single JSON object from stdout"
.to_string(),
);
}
if let Some(check) = &self.check {
for (label, value) in [
("check.name", &check.name),
("check.status_field", &check.status_field),
("check.detail_field", &check.detail_field),
] {
if value.trim().is_empty() {
return Err(format!("{label} must not be empty"));
}
}
if let Some(troubleshoot) = &check.troubleshoot {
if troubleshoot.trim().is_empty() {
return Err("check.troubleshoot must not be empty when set".to_string());
}
}
}
if let Some(client) = &self.client {
if client.name.trim().is_empty() {
return Err("client.name must not be empty".to_string());
}
for (label, value) in [
("client.description", &client.description),
("client.icon", &client.icon),
] {
if let Some(v) = value {
if v.trim().is_empty() {
return Err(format!("{label} must not be empty when set"));
}
}
}
}
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 example_check_job_yamls_parse_and_validate() {
let jobs = [
(
"check-bitlocker",
include_str!("../../../configs/jobs/check-bitlocker.yaml"),
),
(
"check-av-signature",
include_str!("../../../configs/jobs/check-av-signature.yaml"),
),
(
"check-cert-expiry",
include_str!("../../../configs/jobs/check-cert-expiry.yaml"),
),
];
for (name, yaml) in jobs {
let m: Manifest =
serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} parse: {e}"));
m.validate()
.unwrap_or_else(|e| panic!("{name} validate: {e}"));
let check = m
.check
.as_ref()
.unwrap_or_else(|| panic!("{name} must carry a check: hint"));
assert!(!check.name.trim().is_empty(), "{name} check.name empty");
assert_eq!(
m.execute.run_as,
RunAs::System,
"{name} should run_as system"
);
}
}
#[test]
fn example_client_job_yamls_parse_and_validate() {
let jobs = [
(
"fix-teams-cache",
JobCategory::Troubleshoot,
include_str!("../../../configs/jobs/fix-teams-cache.yaml"),
),
(
"chrome-update",
JobCategory::SoftwareUpdate,
include_str!("../../../configs/jobs/chrome-update.yaml"),
),
(
"install-slack",
JobCategory::Catalog,
include_str!("../../../configs/jobs/install-slack.yaml"),
),
];
for (id, category, yaml) in jobs {
let m: Manifest =
serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
m.validate()
.unwrap_or_else(|e| panic!("{id} validate: {e}"));
assert_eq!(m.id, id, "{id} id mismatch");
let client = m
.client
.as_ref()
.unwrap_or_else(|| panic!("{id} must carry a client: block"));
assert!(!client.name.trim().is_empty(), "{id} client.name empty");
assert_eq!(client.category, category, "{id} category");
}
}
#[test]
fn example_check_schedule_yamls_parse_and_validate() {
let schedules = [
(
"check-bitlocker",
include_str!("../../../configs/schedules/check-bitlocker.yaml"),
),
(
"check-av-signature",
include_str!("../../../configs/schedules/check-av-signature.yaml"),
),
(
"check-cert-expiry",
include_str!("../../../configs/schedules/check-cert-expiry.yaml"),
),
];
for (name, yaml) in schedules {
let s: Schedule =
serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
s.validate()
.unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
assert_eq!(s.job_id, name, "{name} schedule must reference its job");
}
}
#[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");
}
#[test]
fn manifest_parses_check_job_and_validates() {
let yaml = r#"
id: check-bitlocker
version: 0.1.0
execute:
shell: powershell
run_as: system
timeout: 15s
script: |
[pscustomobject]@{ status = 'ok'; detail = 'all volumes protected' } | ConvertTo-Json -Compress
check:
name: bitlocker
troubleshoot: fix-bitlocker
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let check = m.check.as_ref().expect("check hint present");
assert_eq!(check.name, "bitlocker");
assert_eq!(check.troubleshoot.as_deref(), Some("fix-bitlocker"));
assert_eq!(check.status_field, "status");
assert_eq!(check.detail_field, "detail");
assert!(m.inventory.is_none() && m.emit.is_none());
m.validate().expect("check-only manifest passes validation");
}
#[test]
fn manifest_check_defaults_and_custom_fields() {
let m: Manifest = serde_yaml::from_str(
r#"
id: check-disk
version: 0.1.0
execute:
shell: powershell
script: "[pscustomobject]@{ status = 'ok' } | ConvertTo-Json -Compress"
timeout: 10s
check:
name: disk_free
"#,
)
.expect("parse");
let c = m.check.as_ref().unwrap();
assert_eq!(c.name, "disk_free");
assert_eq!(c.status_field, "status");
assert_eq!(c.detail_field, "detail");
assert!(c.troubleshoot.is_none());
m.validate().expect("validates");
let m2: Manifest = serde_yaml::from_str(
r#"
id: check-custom
version: 0.1.0
execute:
shell: powershell
script: "echo x"
timeout: 10s
check:
name: patch_level
status_field: compliance
detail_field: summary
"#,
)
.expect("parse");
let c2 = m2.check.as_ref().unwrap();
assert_eq!(c2.status_field, "compliance");
assert_eq!(c2.detail_field, "summary");
}
#[test]
fn manifest_allows_check_composed_with_inventory() {
let yaml = r#"
id: check-bitlocker-detailed
version: 0.1.0
execute:
shell: powershell
script: "echo x"
timeout: 10s
check:
name: bitlocker
inventory:
display:
- { field: status, label: Status }
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
assert!(m.check.is_some() && m.inventory.is_some());
m.validate().expect("check + inventory compose");
}
#[test]
fn manifest_rejects_check_combined_with_emit() {
let yaml = r#"
id: bad-mix
version: 0.1.0
execute:
shell: powershell
script: "echo x"
timeout: 10s
check:
name: bitlocker
emit:
type: events
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().expect_err("emit + check must fail");
assert!(err.contains("incompatible"), "err: {err}");
}
#[test]
fn manifest_rejects_emit_combined_with_inventory() {
let yaml = r#"
id: bad-mix-2
version: 0.1.0
execute:
shell: powershell
script: "echo x"
timeout: 10s
emit:
type: events
inventory:
display:
- { field: status, label: Status }
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().expect_err("emit + inventory must fail");
assert!(err.contains("incompatible"), "err: {err}");
}
#[test]
fn manifest_rejects_empty_check_field_names() {
let base = |inner: &str| {
format!(
"id: c\nversion: 0.1.0\nexecute:\n shell: powershell\n script: \"echo x\"\n timeout: 10s\ncheck:\n{inner}"
)
};
for inner in [
" name: \"\"\n",
" name: ok\n status_field: \"\"\n",
" name: ok\n detail_field: \" \"\n",
" name: ok\n troubleshoot: \" \"\n",
] {
let m: Manifest = serde_yaml::from_str(&base(inner)).expect("parse");
let err = m.validate().expect_err("empty field must fail");
assert!(err.contains("must not be empty"), "err: {err}");
}
}
#[test]
fn manifest_client_absent_by_default() {
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!(m.client.is_none());
m.validate().expect("operator-only job validates");
}
#[test]
fn manifest_client_parses_and_validates() {
let yaml = r#"
id: fix-teams-cache
version: 1.0.0
execute:
shell: powershell
script: "echo clearing"
timeout: 60s
client:
name: "Teams のキャッシュをクリア"
description: "Teams が重いときに試してください"
category: troubleshoot
icon: brush-cleaning
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let c = m.client.as_ref().expect("client block present");
assert_eq!(c.name, "Teams のキャッシュをクリア");
assert_eq!(
c.description.as_deref(),
Some("Teams が重いときに試してください")
);
assert_eq!(c.category, JobCategory::Troubleshoot);
assert_eq!(c.icon.as_deref(), Some("brush-cleaning"));
m.validate().expect("user-invokable job validates");
}
#[test]
fn manifest_client_minimal_only_name_and_category() {
let yaml = r#"
id: install-slack
version: 1.0.0
execute:
shell: powershell
script: "echo install"
timeout: 600s
client:
name: Slack
category: catalog
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let c = m.client.as_ref().expect("client present");
assert_eq!(c.category, JobCategory::Catalog);
assert!(c.description.is_none() && c.icon.is_none());
m.validate().expect("minimal client validates");
}
#[test]
fn manifest_client_rejects_blank_name() {
let yaml = r#"
id: j
version: 1.0.0
execute:
shell: powershell
script: "echo x"
timeout: 30s
client:
name: " "
category: catalog
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().expect_err("blank name must fail");
assert!(err.contains("client.name"), "err: {err}");
}
#[test]
fn manifest_client_rejects_blank_optional_fields() {
for (field, line) in [
("client.description", " description: \" \"\n"),
("client.icon", " icon: \"\"\n"),
] {
let yaml = format!(
"id: j\nversion: 1.0.0\nexecute:\n shell: powershell\n script: \"echo x\"\n timeout: 30s\nclient:\n name: A\n category: catalog\n{line}"
);
let m: Manifest = serde_yaml::from_str(&yaml).expect("parse");
let err = m.validate().expect_err("blank optional field must fail");
assert!(err.contains(field), "expected {field} in err: {err}");
}
}
#[test]
fn manifest_client_requires_category_at_parse() {
let yaml = r#"
id: j
version: 1.0.0
execute:
shell: powershell
script: "echo x"
timeout: 30s
client:
name: "A job"
"#;
let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
assert!(
r.is_err(),
"missing category must be a parse error, got {r:?}"
);
}
#[test]
fn manifest_client_rejects_unknown_field() {
let yaml = r#"
id: j
version: 1.0.0
execute:
shell: powershell
script: "echo x"
timeout: 30s
client:
name: "A job"
category: catalog
displayname: oops
"#;
let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
assert!(
r.is_err(),
"unknown client field must be a parse error, got {r:?}"
);
}
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_calendar_time_parses() {
let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(
" calendar:\n at: \"09:00\"\n days: [mon-fri]",
))
.expect("parse");
match &s.when {
When::Calendar(c) => {
assert_eq!(c.at, "09:00");
assert_eq!(c.days, vec!["mon-fri"]);
}
other => panic!("expected calendar, got {other:?}"),
}
}
#[test]
fn when_calendar_days_default_empty() {
let s: Schedule =
serde_yaml::from_str(&schedule_yaml_with(" calendar:\n at: \"09:00\""))
.expect("parse");
match &s.when {
When::Calendar(c) => assert!(c.days.is_empty(), "days defaults to empty (= daily)"),
other => panic!("expected calendar, got {other:?}"),
}
}
#[test]
fn when_calendar_datetime_parses_all_separators() {
for at in ["2026-06-10 09:00", "2026-06-10T09:00", "2026/06/10 09:00"] {
let block = format!(" calendar:\n at: \"{at}\"");
let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(&block))
.unwrap_or_else(|e| panic!("parse '{at}': {e}"));
match &s.when {
When::Calendar(c) => {
use chrono::Datelike;
let p = c.parse_at().expect("parse_at");
let d = p.date.expect("datetime at carries a date");
assert_eq!((d.year(), d.month(), d.day()), (2026, 6, 10), "for '{at}'");
}
other => panic!("expected calendar, got {other:?}"),
}
}
}
#[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_rejects_retired_cron_escape_hatch() {
let r: Result<Schedule, _> =
serde_yaml::from_str(&schedule_yaml_with(" cron: \"0 0 9 * * mon-fri\""));
assert!(
r.is_err(),
"expected parse error for retired cron, 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(),
})),
calendar("09:00", &["mon-fri"]),
calendar("2026-06-10 09:00", &[]),
] {
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",
),
(calendar("09:00", &["mon-fri"]), "at 09:00 [mon-fri]"),
(calendar("2026-06-10 09:00", &[]), "at 2026-06-10 09:00"),
] {
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(),
constraints: Constraints::default(),
on_failure: OnFailure::default(),
tz: ScheduleTz::default(),
starting_deadline: None,
runs_on,
enabled: true,
}
}
fn calendar(at: &str, days: &[&str]) -> When {
When::Calendar(CalendarSpec {
at: at.into(),
days: days.iter().map(|d| (*d).to_string()).collect(),
})
}
#[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")),
),
(
calendar("09:00", &["mon-fri"]),
("0 0 9 * * mon-fri", ExecMode::EveryTick, None),
),
(
calendar("18:30", &[]),
("0 30 18 * * *", ExecMode::EveryTick, None),
),
(
calendar("2026-06-10 09:00", &[]),
("0 0 9 10 6 * 2026", 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 lowered_carries_schedule_tz() {
for (tz, want) in [
(ScheduleTz::Local, ScheduleTz::Local),
(ScheduleTz::Utc, ScheduleTz::Utc),
] {
let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
s.tz = tz;
assert_eq!(s.lowered().tz, want, "calendar carries tz");
let mut s = schedule_with(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
RunsOn::Backend,
);
s.tz = tz;
assert_eq!(s.lowered().tz, want, "reconcile carries tz");
}
}
#[test]
fn poll_cron_is_accepted_by_the_engine_parser() {
croner::parser::CronParser::builder()
.seconds(croner::parser::Seconds::Required)
.dom_and_dow(true)
.build()
.parse(POLL_CRON)
.expect("POLL_CRON must parse");
}
#[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_accepts_calendar_shapes() {
for when in [
calendar("09:00", &["mon-fri"]), calendar("00:00", &["sun"]), calendar("18:30", &[]), calendar("2026-06-10 09:00", &[]), calendar("2026/12/25 00:00", &[]), ] {
schedule_with(when.clone(), RunsOn::Backend)
.validate()
.unwrap_or_else(|e| panic!("{when} should validate: {e}"));
}
}
#[test]
fn validate_rejects_bad_at() {
for bad in ["25:00", "09:60", "9", "noon", "2026-13-01 09:00"] {
let err = schedule_with(calendar(bad, &[]), RunsOn::Backend)
.validate()
.unwrap_err();
assert!(err.contains("when.at"), "for '{bad}', got: {err}");
}
}
#[test]
fn validate_rejects_datetime_at_with_days() {
let err = schedule_with(calendar("2026-06-10 09:00", &["mon"]), RunsOn::Backend)
.validate()
.unwrap_err();
assert!(
err.contains("one-shot") && err.contains("days"),
"got: {err}"
);
}
#[test]
fn validate_rejects_bad_day_name() {
let err = schedule_with(calendar("09:00", &["funday"]), RunsOn::Backend)
.validate()
.unwrap_err();
assert!(err.contains("when.days"), "got: {err}");
assert!(err.contains("funday"), "names the bad token: {err}");
let err = schedule_with(calendar("09:00", &["mon-"]), RunsOn::Backend)
.validate()
.unwrap_err();
assert!(err.contains("'mon-'"), "names the whole token: {err}");
for ok in [
calendar("09:00", &["mon-fri"]),
calendar("09:00", &["mon", "wed", "sun"]),
calendar("09:00", &["1-5"]),
] {
schedule_with(ok.clone(), RunsOn::Backend)
.validate()
.unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
}
}
#[test]
fn calendar_oneshot_instant_detects_past() {
use chrono::TimeZone;
let c = CalendarSpec {
at: "2024-01-01 09:00".into(),
days: vec![],
};
let t = c
.oneshot_instant(ScheduleTz::Utc)
.expect("one-shot instant");
assert_eq!(
t,
chrono::Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()
);
assert!(t < chrono::Utc::now(), "2024 is in the past");
let rep = CalendarSpec {
at: "09:00".into(),
days: vec!["mon-fri".into()],
};
assert!(rep.oneshot_instant(ScheduleTz::Utc).is_none());
}
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();
let c = |t| active.contains(t, ScheduleTz::Utc);
assert!(!c(at(2026, 6, 30, 23)), "before from");
assert!(c(at(2026, 7, 1, 0)), "at from (inclusive)");
assert!(c(at(2026, 7, 15, 12)), "inside");
assert!(!c(at(2026, 8, 1, 0)), "at until (exclusive)");
assert!(!c(at(2026, 8, 2, 0)), "after until");
}
#[test]
fn active_empty_window_is_always_active() {
assert!(Active::default().contains(chrono::Utc::now(), ScheduleTz::Local));
}
#[test]
fn active_rfc3339_bound_honours_offset_regardless_of_tz() {
use chrono::TimeZone;
let active = Active {
from: Some("2026-07-01T09:00:00+09:00".into()),
until: None,
};
for tz in [ScheduleTz::Utc, ScheduleTz::Local] {
assert!(
!active.contains(
chrono::Utc
.with_ymd_and_hms(2026, 6, 30, 23, 59, 0)
.unwrap(),
tz
)
);
assert!(active.contains(
chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
tz
));
}
}
#[test]
fn active_date_bound_respects_tz() {
use chrono::TimeZone;
let utc = Active::parse_bound("2026-07-01", ScheduleTz::Utc).expect("utc");
assert_eq!(
utc,
chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()
);
let local = Active::parse_bound("2026-07-01", ScheduleTz::Local).expect("local");
let want = chrono::Local
.with_ymd_and_hms(2026, 7, 1, 0, 0, 0)
.single()
.expect("local midnight is unambiguous")
.with_timezone(&chrono::Utc);
assert_eq!(local, want, "date bound resolved in host-local tz");
}
#[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}"
);
}
fn with_window(win: &str) -> Schedule {
let mut s = schedule_with(
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
RunsOn::Backend,
);
s.constraints.window = Some(win.into());
s
}
#[test]
fn constraints_window_parses_and_round_trips() {
let yaml = r#"
id: x
when:
per_pc: { every: 6h }
job_id: y
target: { all: true }
constraints:
window: "22:00-05:00"
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
assert_eq!(s.constraints.window.as_deref(), Some("22:00-05:00"));
let back: Schedule =
serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
assert_eq!(back.constraints.window.as_deref(), Some("22:00-05:00"));
}
#[test]
fn constraints_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("constraints").is_none(),
"empty constraints must not appear on the wire: {json}"
);
}
#[test]
fn window_no_constraint_always_allows() {
let c = Constraints::default();
assert!(c.allows(chrono::Utc::now(), ScheduleTz::Local));
}
#[test]
fn window_same_day_is_half_open() {
use chrono::TimeZone;
let s = with_window("09:00-17:00");
let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
assert!(!a(at(8, 59)), "before start");
assert!(a(at(9, 0)), "at start (inclusive)");
assert!(a(at(16, 59)), "inside");
assert!(!a(at(17, 0)), "at end (exclusive)");
assert!(!a(at(23, 0)), "after end");
}
#[test]
fn window_crossing_midnight() {
use chrono::TimeZone;
let s = with_window("22:00-05:00");
let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
assert!(a(at(22, 0)), "at start tonight");
assert!(a(at(23, 30)), "late tonight");
assert!(a(at(3, 0)), "early tomorrow");
assert!(!a(at(5, 0)), "at end (exclusive)");
assert!(!a(at(12, 0)), "midday outside");
assert!(!a(at(21, 59)), "just before start");
}
#[test]
fn window_respects_tz() {
use chrono::TimeZone;
let s = with_window("09:00-17:00");
let noon_utc = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap();
assert!(s.constraints.allows(noon_utc, ScheduleTz::Utc));
let local_t = noon_utc.with_timezone(&chrono::Local).time();
let in_local = local_t >= chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap()
&& local_t < chrono::NaiveTime::from_hms_opt(17, 0, 0).unwrap();
assert_eq!(s.constraints.allows(noon_utc, ScheduleTz::Local), in_local);
}
#[test]
fn validate_accepts_good_window() {
for w in ["09:00-17:00", "22:00-05:00", "00:00-23:59"] {
with_window(w)
.validate()
.unwrap_or_else(|e| panic!("'{w}' should validate: {e}"));
}
}
#[test]
fn validate_rejects_bad_window() {
for bad in ["9-5", "22:00", "22:00-22:00", "25:00-05:00", "09:00_17:00"] {
let err = with_window(bad).validate().unwrap_err();
assert!(
err.contains("constraints.window"),
"for '{bad}', got: {err}"
);
}
}
fn with_max_concurrent(max: u32, runs_on: RunsOn) -> Schedule {
let mut s = schedule_with(
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
runs_on,
);
s.constraints.max_concurrent = Some(max);
s
}
#[test]
fn validate_accepts_backend_max_concurrent() {
with_max_concurrent(5, RunsOn::Backend)
.validate()
.expect("backend max_concurrent should validate");
}
#[test]
fn validate_rejects_max_concurrent_on_agent() {
let err = with_max_concurrent(5, RunsOn::Agent)
.validate()
.unwrap_err();
assert!(err.contains("constraints.max_concurrent"), "got: {err}");
assert!(err.contains("runs_on: agent"), "got: {err}");
}
#[test]
fn validate_rejects_zero_max_concurrent() {
let err = with_max_concurrent(0, RunsOn::Backend)
.validate()
.unwrap_err();
assert!(err.contains("max_concurrent must be >= 1"), "got: {err}");
}
#[test]
fn max_concurrent_round_trips_and_skips_when_absent() {
let s = with_max_concurrent(3, RunsOn::Backend);
let json = serde_json::to_value(&s.constraints).expect("ser");
assert_eq!(json.get("max_concurrent").and_then(|v| v.as_u64()), Some(3));
let bare = schedule_with(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
RunsOn::Backend,
);
assert!(bare.constraints.is_empty());
}
#[test]
fn window_fail_closed_on_corrupt_blob() {
let s = with_window("22:00_05:00");
assert!(
!s.constraints.allows(chrono::Utc::now(), ScheduleTz::Utc),
"corrupt window fails closed"
);
assert!(
s.bad_window().is_some(),
"bad_window reports the parse error"
);
assert!(with_window("22:00-05:00").bad_window().is_none());
}
#[test]
fn calendar_outside_window_is_flagged() {
let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
s.constraints.window = Some("22:00-05:00".into());
assert!(s.calendar_outside_window(), "09:00 is not in 22:00-05:00");
let mut s = schedule_with(calendar("23:00", &[]), RunsOn::Backend);
s.constraints.window = Some("22:00-05:00".into());
assert!(!s.calendar_outside_window(), "23:00 is in 22:00-05:00");
let mut s = schedule_with(
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
RunsOn::Backend,
);
s.constraints.window = Some("22:00-05:00".into());
assert!(!s.calendar_outside_window(), "reconcile is unaffected");
let s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
assert!(!s.calendar_outside_window());
}
fn with_retry(max: u32, backoff: &str) -> Schedule {
let mut s = schedule_with(
When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
RunsOn::Backend,
);
s.on_failure.retry = Some(Retry {
max,
backoff: backoff.into(),
});
s
}
#[test]
fn on_failure_parses_and_round_trips() {
let yaml = r#"
id: x
when:
per_pc: { every: 6h }
job_id: y
target: { all: true }
on_failure:
retry: { max: 3, backoff: 10m }
"#;
let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
let r = s.on_failure.retry.as_ref().expect("retry present");
assert_eq!(r.max, 3);
assert_eq!(r.backoff, "10m");
let back: Schedule =
serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
assert_eq!(back.on_failure, s.on_failure);
}
#[test]
fn on_failure_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("on_failure").is_none(),
"empty on_failure must not appear on the wire: {json}"
);
}
#[test]
fn validate_accepts_good_retry() {
for (max, backoff) in [(1, "30s"), (3, "10m"), (10, "1h")] {
with_retry(max, backoff)
.validate()
.unwrap_or_else(|e| panic!("retry {{max:{max}, backoff:{backoff}}}: {e}"));
}
}
#[test]
fn validate_rejects_bad_backoff() {
let err = with_retry(3, "soon").validate().unwrap_err();
assert!(err.contains("on_failure.retry.backoff"), "got: {err}");
}
#[test]
fn validate_rejects_sub_second_backoff() {
for bad in ["500ms", "0s", "999ms"] {
let err = with_retry(3, bad).validate().unwrap_err();
assert!(
err.contains("on_failure.retry.backoff must be >= 1s"),
"for '{bad}', got: {err}"
);
}
}
#[test]
fn validate_rejects_out_of_range_max() {
for bad in [0u32, 11, 1000] {
let err = with_retry(bad, "10m").validate().unwrap_err();
assert!(
err.contains("on_failure.retry.max"),
"for max={bad}, got: {err}"
);
}
}
#[test]
fn lowered_retry_reduces_backoff_to_seconds() {
let s = with_retry(3, "10m");
let spec = s.on_failure.lowered_retry().expect("a retry policy");
assert_eq!(spec.max, 3);
assert_eq!(spec.backoff_secs, 600);
}
#[test]
fn lowered_retry_is_none_without_policy() {
let s = schedule_with(
When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
RunsOn::Backend,
);
assert!(s.on_failure.lowered_retry().is_none());
}
#[test]
fn freeze_empty_window_is_always_active() {
let f = Freeze::default();
assert!(f.is_active(chrono::Utc::now()));
}
#[test]
fn freeze_window_is_half_open() {
use chrono::TimeZone;
let f = Freeze {
from: Some("2026-12-20T00:00:00+00:00".into()),
until: Some("2027-01-05T00:00:00+00:00".into()),
reason: Some("year-end".into()),
tz: ScheduleTz::Utc,
};
let at = |y, mo, d| chrono::Utc.with_ymd_and_hms(y, mo, d, 0, 0, 0).unwrap();
assert!(!f.is_active(at(2026, 12, 19)), "before from = not frozen");
assert!(f.is_active(at(2026, 12, 20)), "from is inclusive");
assert!(f.is_active(at(2026, 12, 31)), "inside window");
assert!(!f.is_active(at(2027, 1, 5)), "until is exclusive");
assert!(!f.is_active(at(2027, 1, 6)), "after until = not frozen");
}
#[test]
fn freeze_fails_closed_on_corrupt_bound() {
let f = Freeze {
from: Some("not-a-date".into()),
until: None,
reason: None,
tz: ScheduleTz::Utc,
};
assert!(f.is_active(chrono::Utc::now()), "corrupt bound → frozen");
}
#[test]
fn freeze_validate_accepts_good_bounds() {
Freeze {
from: Some("2026-12-20".into()),
until: Some("2027-01-05T12:00:00+09:00".into()),
reason: None,
tz: ScheduleTz::Local,
}
.validate()
.expect("date + rfc3339 bounds should validate");
Freeze::default().validate().expect("empty freeze is valid");
}
#[test]
fn freeze_validate_rejects_bad_bound_and_inverted_window() {
let err = Freeze {
from: Some("never".into()),
..Default::default()
}
.validate()
.unwrap_err();
assert!(err.contains("freeze:"), "got: {err}");
let inverted = Freeze {
from: Some("2027-01-05".into()),
until: Some("2026-12-20".into()),
..Default::default()
}
.validate()
.unwrap_err();
assert!(inverted.contains("freeze.from"), "got: {inverted}");
}
#[test]
fn freeze_round_trips_and_skips_empty_fields() {
let f = Freeze {
from: None,
until: Some("2027-01-05".into()),
reason: Some("INC-1234".into()),
tz: ScheduleTz::Utc,
};
let json = serde_json::to_value(&f).expect("serialise");
assert!(json.get("from").is_none(), "empty from omitted: {json}");
let back: Freeze = serde_json::from_value(json).expect("round-trip");
assert_eq!(back, f);
}
#[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 = "Constraints::is_empty")]
pub constraints: Constraints,
#[serde(default, skip_serializing_if = "OnFailure::is_empty")]
pub on_failure: OnFailure,
#[serde(default)]
pub tz: ScheduleTz,
#[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),
Calendar(CalendarSpec),
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CalendarSpec {
pub at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub days: Vec<String>,
}
struct ParsedAt {
minute: u32,
hour: u32,
date: Option<chrono::NaiveDate>,
}
impl CalendarSpec {
fn parse_at(&self) -> Result<ParsedAt, String> {
use chrono::Timelike;
let s = self.at.trim();
for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y/%m/%d %H:%M"] {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
return Ok(ParsedAt {
minute: dt.minute(),
hour: dt.hour(),
date: Some(dt.date()),
});
}
}
if let Ok(t) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
return Ok(ParsedAt {
minute: t.minute(),
hour: t.hour(),
date: None,
});
}
Err(format!(
"when.at: unparseable '{}' (want HH:MM or YYYY-MM-DD HH:MM)",
self.at
))
}
fn validate_days(&self) -> Result<(), String> {
const NAMES: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
for tok in &self.days {
let invalid = |reason: &str| {
Err(format!(
"when.days: invalid day token '{tok}' ({reason}; \
want mon..sun, 0-7, a range like mon-fri, or *)"
))
};
for part in tok.split('-') {
let p = part.trim().to_ascii_lowercase();
if p.is_empty() {
return invalid("empty range bound");
}
let ok = p == "*"
|| NAMES.contains(&p.as_str())
|| p.parse::<u8>().map(|n| n <= 7).unwrap_or(false);
if !ok {
return invalid(&format!("'{part}' is not a day"));
}
}
}
Ok(())
}
pub fn oneshot_instant(&self, tz: ScheduleTz) -> Option<chrono::DateTime<chrono::Utc>> {
let p = self.parse_at().ok()?;
let date = p.date?;
let naive = date.and_hms_opt(p.hour, p.minute, 0)?;
tz.naive_to_utc(naive)
}
pub fn fire_time(&self) -> Option<chrono::NaiveTime> {
let p = self.parse_at().ok()?;
chrono::NaiveTime::from_hms_opt(p.hour, p.minute, 0)
}
fn to_cron(&self) -> Result<String, String> {
use chrono::Datelike;
let ParsedAt { minute, hour, date } = self.parse_at()?;
match date {
Some(d) => {
if !self.days.is_empty() {
return Err(
"when.at with a date is a one-shot and cannot be combined with days".into(),
);
}
Ok(format!(
"0 {minute} {hour} {} {} * {}",
d.day(),
d.month(),
d.year()
))
}
None => {
let dow = if self.days.is_empty() {
"*".to_string()
} else {
self.validate_days()?;
self.days.join(",")
};
Ok(format!("0 {minute} {hour} * * {dow}"))
}
}
}
}
#[derive(
Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleTz {
#[default]
Local,
Utc,
}
impl ScheduleTz {
fn naive_to_utc(self, naive: chrono::NaiveDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
use chrono::TimeZone;
match self {
ScheduleTz::Utc => Some(chrono::DateTime::from_naive_utc_and_offset(
naive,
chrono::Utc,
)),
ScheduleTz::Local => chrono::Local
.from_local_datetime(&naive)
.earliest()
.map(|dt| dt.with_timezone(&chrono::Utc)),
}
}
fn wall_time(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveTime {
match self {
ScheduleTz::Utc => now.time(),
ScheduleTz::Local => now.with_timezone(&chrono::Local).time(),
}
}
}
#[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::Calendar(c) if c.days.is_empty() => write!(f, "at {}", c.at),
When::Calendar(c) => write!(f, "at {} [{}]", c.at, c.days.join(",")),
}
}
}
#[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, tz: ScheduleTz) -> 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 tz.naive_to_utc(midnight).ok_or_else(|| {
format!("active: bound '{s}' falls in a DST gap for the schedule's tz")
});
}
Err(format!(
"active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
))
}
pub fn contains(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s, tz).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
}
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Constraints {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent: Option<u32>,
}
impl Constraints {
pub fn is_empty(&self) -> bool {
self.window.is_none() && self.max_concurrent.is_none()
}
pub fn parse_window(s: &str) -> Result<(chrono::NaiveTime, chrono::NaiveTime), String> {
let (a, b) = s
.split_once('-')
.ok_or_else(|| format!("constraints.window: '{s}' must be 'HH:MM-HH:MM'"))?;
let parse = |part: &str| {
chrono::NaiveTime::parse_from_str(part.trim(), "%H:%M")
.map_err(|e| format!("constraints.window: invalid time '{}': {e}", part.trim()))
};
let (start, end) = (parse(a)?, parse(b)?);
if start == end {
return Err(format!(
"constraints.window: start and end are equal ('{s}'); omit window for 'always'"
));
}
Ok((start, end))
}
pub fn allows(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
match self.window.as_deref() {
None => true,
Some(_) => self.window_contains(tz.wall_time(now)).unwrap_or(false),
}
}
fn window_contains(&self, t: chrono::NaiveTime) -> Option<bool> {
let (start, end) = Self::parse_window(self.window.as_deref()?).ok()?;
Some(if start <= end {
start <= t && t < end
} else {
t >= start || t < end
})
}
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OnFailure {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<Retry>,
}
impl OnFailure {
pub fn is_empty(&self) -> bool {
self.retry.is_none()
}
pub fn lowered_retry(&self) -> Option<crate::wire::RetrySpec> {
let r = self.retry.as_ref()?;
let backoff_secs = humantime::parse_duration(&r.backoff).ok()?.as_secs();
Some(crate::wire::RetrySpec {
max: r.max,
backoff_secs,
})
}
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Retry {
pub max: u32,
pub backoff: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Freeze {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default)]
pub tz: ScheduleTz,
}
impl Freeze {
pub fn is_active(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
let bound = |s: &Option<String>| -> Result<Option<chrono::DateTime<chrono::Utc>>, ()> {
match s.as_deref() {
None => Ok(None),
Some(raw) => Active::parse_bound(raw, self.tz).map(Some).map_err(|_| ()),
}
};
let (from, until) = match (bound(&self.from), bound(&self.until)) {
(Ok(f), Ok(u)) => (f, u),
_ => return true,
};
if from.is_some_and(|f| now < f) {
return false;
}
if until.is_some_and(|u| now >= u) {
return false;
}
true
}
pub fn validate(&self) -> Result<(), String> {
let from = self
.from
.as_deref()
.map(|s| Active::parse_bound(s, self.tz))
.transpose()
.map_err(|e| e.replace("active:", "freeze:"))?;
let until = self
.until
.as_deref()
.map(|s| Active::parse_bound(s, self.tz))
.transpose()
.map_err(|e| e.replace("active:", "freeze:"))?;
if let (Some(f), Some(u)) = (from, until) {
if f >= u {
return Err(format!(
"freeze.from ({}) must be strictly before freeze.until ({})",
self.from.as_deref().unwrap_or_default(),
self.until.as_deref().unwrap_or_default(),
));
}
}
Ok(())
}
}
pub const POLL_CRON: &str = "0 * * * * *";
pub struct Lowered {
pub cron: String,
pub mode: ExecMode,
pub cooldown: Option<String>,
pub tz: ScheduleTz,
}
impl Schedule {
pub fn bad_window(&self) -> Option<String> {
let w = self.constraints.window.as_deref()?;
Constraints::parse_window(w).err()
}
pub fn calendar_outside_window(&self) -> bool {
let When::Calendar(c) = &self.when else {
return false;
};
let Some(t) = c.fire_time() else {
return false;
};
matches!(self.constraints.window_contains(t), Some(false))
}
pub fn lowered(&self) -> Lowered {
let tz = self.tz;
match &self.when {
When::PerPc(p) => Lowered {
cron: POLL_CRON.into(),
mode: ExecMode::OncePerPc,
cooldown: p.cooldown(),
tz,
},
When::PerTarget(p) => Lowered {
cron: POLL_CRON.into(),
mode: ExecMode::OncePerTarget,
cooldown: p.cooldown(),
tz,
},
When::Calendar(c) => Lowered {
cron: c
.to_cron()
.unwrap_or_else(|_| "# invalid calendar at".into()),
mode: ExecMode::EveryTick,
cooldown: None,
tz,
},
}
}
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::Calendar(c) = &self.when {
let cron = c.to_cron()?;
croner::parser::CronParser::builder()
.seconds(croner::parser::Seconds::Required)
.dom_and_dow(true)
.build()
.parse(&cron)
.map_err(|e| format!("when.at lowered to invalid cron '{cron}': {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(|s| Active::parse_bound(s, self.tz))
.transpose()?;
let until = self
.active
.until
.as_deref()
.map(|s| Active::parse_bound(s, self.tz))
.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(),
));
}
}
if let Some(w) = self.constraints.window.as_deref() {
Constraints::parse_window(w)?;
}
if let Some(mc) = self.constraints.max_concurrent {
if matches!(self.runs_on, RunsOn::Agent) {
return Err(
"constraints.max_concurrent needs a central counter and is backend-only; \
it cannot be combined with runs_on: agent (each agent self-schedules, \
so there is no fleet-wide count to cap against)"
.into(),
);
}
if mc == 0 {
return Err(
"constraints.max_concurrent must be >= 1 (0 would never fire; \
omit it for no cap)"
.into(),
);
}
}
if let Some(r) = &self.on_failure.retry {
let backoff = humantime::parse_duration(&r.backoff).map_err(|e| {
format!(
"on_failure.retry.backoff: invalid duration '{}': {e}",
r.backoff
)
})?;
if backoff.as_secs() < 1 {
return Err(format!(
"on_failure.retry.backoff must be >= 1s (got '{}'); sub-second backoffs \
round to 0 on the wire",
r.backoff
));
}
if !(1..=10).contains(&r.max) {
return Err(format!(
"on_failure.retry.max must be 1..=10 (got {}); it counts additional \
attempts after the first run",
r.max
));
}
}
Ok(())
}
}
fn default_true() -> bool {
true
}