1use serde::{Deserialize, Serialize};
2
3use crate::wire::{RunAs, Shell, Staleness};
4
5#[derive(Serialize, Deserialize, Debug, Clone)]
17#[serde(deny_unknown_fields)]
18pub struct Manifest {
19 pub id: String,
20 pub version: String,
21 #[serde(default)]
22 pub description: Option<String>,
23 pub execute: Execute,
24 #[serde(default)]
25 pub require_approval: bool,
26 #[serde(default)]
32 pub inventory: Option<InventoryHint>,
33 #[serde(default)]
41 pub staleness: Staleness,
42}
43
44#[derive(Serialize, Deserialize, Debug, Clone, Default)]
49pub struct FanoutPlan {
50 #[serde(default)]
51 pub target: Target,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub rollout: Option<Rollout>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub jitter: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
74}
75
76#[derive(Serialize, Deserialize, Debug, Clone)]
89pub struct InventoryHint {
90 pub display: Vec<DisplayField>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub summary: Option<Vec<DisplayField>>,
96}
97
98#[derive(Serialize, Deserialize, Debug, Clone)]
99pub struct DisplayField {
100 pub field: String,
102 pub label: String,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
107 #[serde(rename = "type")]
108 pub kind: Option<String>,
109}
110
111#[derive(Serialize, Deserialize, Debug, Clone)]
112pub struct Rollout {
113 #[serde(default)]
114 pub strategy: RolloutStrategy,
115 pub waves: Vec<Wave>,
116}
117
118#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
119#[serde(rename_all = "lowercase")]
120pub enum RolloutStrategy {
121 #[default]
122 Wave,
123}
124
125#[derive(Serialize, Deserialize, Debug, Clone)]
126pub struct Wave {
127 pub group: String,
128 pub delay: String,
131}
132
133#[derive(Serialize, Deserialize, Debug, Clone, Default)]
134pub struct Target {
135 #[serde(default)]
136 pub groups: Vec<String>,
137 #[serde(default)]
138 pub pcs: Vec<String>,
139 #[serde(default)]
140 pub all: bool,
141}
142
143impl Target {
144 pub fn is_specified(&self) -> bool {
146 self.all || !self.groups.is_empty() || !self.pcs.is_empty()
147 }
148}
149
150#[derive(Serialize, Deserialize, Debug, Clone)]
151pub struct Execute {
152 pub shell: ExecuteShell,
153 pub script: String,
154 pub timeout: String,
157 #[serde(default)]
161 pub run_as: RunAs,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub cwd: Option<String>,
173}
174
175#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
176#[serde(rename_all = "lowercase")]
177pub enum ExecuteShell {
178 Powershell,
179 Cmd,
180}
181
182impl From<ExecuteShell> for Shell {
183 fn from(s: ExecuteShell) -> Self {
184 match s {
185 ExecuteShell::Powershell => Shell::Powershell,
186 ExecuteShell::Cmd => Shell::Cmd,
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn target_is_specified_requires_at_least_one_field() {
197 let empty = Target::default();
198 assert!(!empty.is_specified());
199
200 let with_all = Target {
201 all: true,
202 ..Target::default()
203 };
204 assert!(with_all.is_specified());
205
206 let with_groups = Target {
207 groups: vec!["canary".into()],
208 ..Target::default()
209 };
210 assert!(with_groups.is_specified());
211
212 let with_pcs = Target {
213 pcs: vec!["minipc".into()],
214 ..Target::default()
215 };
216 assert!(with_pcs.is_specified());
217 }
218
219 #[test]
220 fn manifest_deserialises_minimal_yaml() {
221 let yaml = r#"
224id: echo-test
225version: 0.0.1
226execute:
227 shell: powershell
228 script: "echo 'kanade'"
229 timeout: 30s
230"#;
231 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
232 assert_eq!(m.id, "echo-test");
233 assert_eq!(m.version, "0.0.1");
234 assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
235 assert_eq!(m.execute.script.trim(), "echo 'kanade'");
236 assert_eq!(m.execute.timeout, "30s");
237 assert!(!m.require_approval);
238 }
239
240 #[test]
241 fn schedule_carries_target_and_rollout() {
242 let yaml = r#"
243id: hourly-cleanup-canary
244cron: "0 0 * * * *"
245job_id: cleanup
246enabled: true
247target:
248 groups: [canary, wave1]
249jitter: 30s
250rollout:
251 strategy: wave
252 waves:
253 - { group: canary, delay: 0s }
254 - { group: wave1, delay: 5s }
255"#;
256 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
257 assert_eq!(s.id, "hourly-cleanup-canary");
258 assert_eq!(s.job_id, "cleanup");
259 assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
260 assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
261 let rollout = s.plan.rollout.expect("rollout present");
262 assert_eq!(rollout.waves.len(), 2);
263 assert_eq!(rollout.waves[0].group, "canary");
264 assert_eq!(rollout.waves[1].delay, "5s");
265 assert_eq!(rollout.strategy, RolloutStrategy::Wave);
266 }
267
268 #[test]
269 fn schedule_minimal_target_all() {
270 let yaml = r#"
271id: every-10s
272cron: "*/10 * * * * *"
273enabled: true
274job_id: scheduled-echo
275target: { all: true }
276"#;
277 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
278 assert_eq!(s.id, "every-10s");
279 assert_eq!(s.cron, "*/10 * * * * *");
280 assert!(s.enabled);
281 assert_eq!(s.job_id, "scheduled-echo");
282 assert!(s.plan.target.all);
283 assert!(s.plan.rollout.is_none());
284 assert!(s.plan.jitter.is_none());
285 }
286
287 #[test]
288 fn schedule_enabled_defaults_to_true() {
289 let yaml = r#"
290id: x
291cron: "* * * * * *"
292job_id: y
293target: { all: true }
294"#;
295 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
296 assert!(s.enabled);
297 }
298
299 #[test]
300 fn schedule_mode_defaults_to_every_tick() {
301 let yaml = r#"
302id: x
303cron: "* * * * * *"
304job_id: y
305target: { all: true }
306"#;
307 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
308 assert_eq!(s.mode, ExecMode::EveryTick);
309 assert!(s.cooldown.is_none());
310 assert!(!s.auto_disable_when_done);
311 }
312
313 #[test]
314 fn schedule_mode_serialises_snake_case() {
315 for (mode, expected) in [
316 (ExecMode::EveryTick, "every_tick"),
317 (ExecMode::OncePerPc, "once_per_pc"),
318 (ExecMode::OncePerTarget, "once_per_target"),
319 ] {
320 let s = serde_json::to_value(mode).expect("serialise");
321 assert_eq!(s, serde_json::Value::String(expected.into()));
322 let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
323 .expect("deserialise");
324 assert_eq!(back, mode, "round-trip for {expected}");
325 }
326 }
327
328 #[test]
329 fn schedule_kitting_yaml_parses() {
330 let yaml = r#"
331id: kitting-setup
332cron: "*/30 * * * * *"
333job_id: install-baseline
334target: { all: true }
335mode: once_per_pc
336"#;
337 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
338 assert_eq!(s.mode, ExecMode::OncePerPc);
339 assert!(s.cooldown.is_none());
340 assert!(!s.auto_disable_when_done);
341 }
342
343 #[test]
344 fn schedule_batch_campaign_yaml_parses() {
345 let yaml = r#"
346id: q3-patch-batch
347cron: "*/5 * * * * *"
348job_id: install-patch
349target:
350 pcs: [pc-001, pc-002, pc-003]
351mode: once_per_pc
352auto_disable_when_done: true
353"#;
354 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
355 assert_eq!(s.mode, ExecMode::OncePerPc);
356 assert!(s.cooldown.is_none());
357 assert!(s.auto_disable_when_done);
358 assert_eq!(s.plan.target.pcs.len(), 3);
359 }
360
361 #[test]
362 fn schedule_throttled_yaml_parses() {
363 let yaml = r#"
364id: daily-compliance
365cron: "*/5 * * * * *"
366job_id: check-av-status
367target: { all: true }
368mode: once_per_pc
369cooldown: 1d
370"#;
371 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
372 assert_eq!(s.mode, ExecMode::OncePerPc);
373 assert_eq!(s.cooldown.as_deref(), Some("1d"));
374 }
375
376 #[test]
377 fn schedule_runs_on_defaults_to_backend() {
378 let yaml = r#"
379id: x
380cron: "* * * * * *"
381job_id: y
382target: { all: true }
383"#;
384 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
385 assert_eq!(s.runs_on, RunsOn::Backend);
386 }
387
388 #[test]
389 fn schedule_runs_on_agent_parses() {
390 let yaml = r#"
391id: offline-inv
392cron: "0 0 * * * *"
393job_id: inventory-hw
394target: { all: true }
395runs_on: agent
396mode: once_per_pc
397"#;
398 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
399 assert_eq!(s.runs_on, RunsOn::Agent);
400 assert_eq!(s.mode, ExecMode::OncePerPc);
401 }
402
403 #[test]
404 fn runs_on_serialises_snake_case() {
405 for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
406 let s = serde_json::to_value(mode).expect("serialise");
407 assert_eq!(s, serde_json::Value::String(expected.into()));
408 let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
409 .expect("deserialise");
410 assert_eq!(back, mode);
411 }
412 }
413
414 #[test]
415 fn schedule_once_per_target_yaml_parses() {
416 let yaml = r#"
417id: license-checkin
418cron: "*/10 * * * * *"
419job_id: hit-license-server
420target: { all: true }
421mode: once_per_target
422cooldown: 24h
423"#;
424 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
425 assert_eq!(s.mode, ExecMode::OncePerTarget);
426 assert_eq!(s.cooldown.as_deref(), Some("24h"));
427 }
428
429 #[test]
430 fn execute_shell_into_wire_shell() {
431 assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
432 assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
433 }
434
435 #[test]
436 fn manifest_staleness_defaults_to_cached() {
437 let yaml = r#"
438id: x
439version: 1.0.0
440execute:
441 shell: powershell
442 script: "echo"
443 timeout: 1s
444"#;
445 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
446 assert_eq!(m.staleness, Staleness::Cached);
447 }
448
449 #[test]
450 fn manifest_strict_staleness_parses() {
451 let yaml = r#"
452id: urgent-patch
453version: 2.5.1
454execute:
455 shell: powershell
456 script: Install-Hotfix
457 timeout: 5m
458staleness:
459 mode: strict
460 max_cache_age: 0s
461"#;
462 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
463 match m.staleness {
464 Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
465 other => panic!("expected strict, got {other:?}"),
466 }
467 }
468
469 #[test]
470 fn manifest_unchecked_staleness_parses() {
471 let yaml = r#"
472id: legacy
473version: 0.1.0
474execute:
475 shell: cmd
476 script: "echo"
477 timeout: 1s
478staleness:
479 mode: unchecked
480"#;
481 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
482 assert_eq!(m.staleness, Staleness::Unchecked);
483 }
484
485 #[test]
486 fn missing_required_field_errors() {
487 let yaml = r#"
489version: 1.0.0
490target: { all: true }
491execute:
492 shell: powershell
493 script: "echo"
494 timeout: 1s
495"#;
496 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
497 assert!(r.is_err(), "expected error, got {:?}", r);
498 }
499}
500
501#[derive(Serialize, Deserialize, Debug, Clone)]
507pub struct Schedule {
508 pub id: String,
509 pub cron: String,
512 pub job_id: String,
515 #[serde(flatten)]
519 pub plan: FanoutPlan,
520 #[serde(default)]
524 pub mode: ExecMode,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub cooldown: Option<String>,
531 #[serde(default)]
537 pub auto_disable_when_done: bool,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub starting_deadline: Option<String>,
550 #[serde(default)]
560 pub runs_on: RunsOn,
561 #[serde(default = "default_true")]
562 pub enabled: bool,
563}
564
565#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
567#[serde(rename_all = "snake_case")]
568pub enum RunsOn {
569 #[default]
575 Backend,
576 Agent,
582}
583
584#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
586#[serde(rename_all = "snake_case")]
587pub enum ExecMode {
588 #[default]
591 EveryTick,
592 OncePerPc,
596 OncePerTarget,
601}
602
603fn default_true() -> bool {
604 true
605}