1use serde::{Deserialize, Serialize};
2
3use crate::wire::{RunAs, Shell};
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}
34
35#[derive(Serialize, Deserialize, Debug, Clone, Default)]
40pub struct FanoutPlan {
41 #[serde(default)]
42 pub target: Target,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub rollout: Option<Rollout>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub jitter: Option<String>,
55}
56
57#[derive(Serialize, Deserialize, Debug, Clone)]
70pub struct InventoryHint {
71 pub display: Vec<DisplayField>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub summary: Option<Vec<DisplayField>>,
77}
78
79#[derive(Serialize, Deserialize, Debug, Clone)]
80pub struct DisplayField {
81 pub field: String,
83 pub label: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
88 #[serde(rename = "type")]
89 pub kind: Option<String>,
90}
91
92#[derive(Serialize, Deserialize, Debug, Clone)]
93pub struct Rollout {
94 #[serde(default)]
95 pub strategy: RolloutStrategy,
96 pub waves: Vec<Wave>,
97}
98
99#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
100#[serde(rename_all = "lowercase")]
101pub enum RolloutStrategy {
102 #[default]
103 Wave,
104}
105
106#[derive(Serialize, Deserialize, Debug, Clone)]
107pub struct Wave {
108 pub group: String,
109 pub delay: String,
112}
113
114#[derive(Serialize, Deserialize, Debug, Clone, Default)]
115pub struct Target {
116 #[serde(default)]
117 pub groups: Vec<String>,
118 #[serde(default)]
119 pub pcs: Vec<String>,
120 #[serde(default)]
121 pub all: bool,
122}
123
124impl Target {
125 pub fn is_specified(&self) -> bool {
127 self.all || !self.groups.is_empty() || !self.pcs.is_empty()
128 }
129}
130
131#[derive(Serialize, Deserialize, Debug, Clone)]
132pub struct Execute {
133 pub shell: ExecuteShell,
134 pub script: String,
135 pub timeout: String,
138 #[serde(default)]
142 pub run_as: RunAs,
143}
144
145#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
146#[serde(rename_all = "lowercase")]
147pub enum ExecuteShell {
148 Powershell,
149 Cmd,
150}
151
152impl From<ExecuteShell> for Shell {
153 fn from(s: ExecuteShell) -> Self {
154 match s {
155 ExecuteShell::Powershell => Shell::Powershell,
156 ExecuteShell::Cmd => Shell::Cmd,
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn target_is_specified_requires_at_least_one_field() {
167 let empty = Target::default();
168 assert!(!empty.is_specified());
169
170 let with_all = Target {
171 all: true,
172 ..Target::default()
173 };
174 assert!(with_all.is_specified());
175
176 let with_groups = Target {
177 groups: vec!["canary".into()],
178 ..Target::default()
179 };
180 assert!(with_groups.is_specified());
181
182 let with_pcs = Target {
183 pcs: vec!["minipc".into()],
184 ..Target::default()
185 };
186 assert!(with_pcs.is_specified());
187 }
188
189 #[test]
190 fn manifest_deserialises_minimal_yaml() {
191 let yaml = r#"
194id: echo-test
195version: 0.0.1
196execute:
197 shell: powershell
198 script: "echo 'kanade'"
199 timeout: 30s
200"#;
201 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
202 assert_eq!(m.id, "echo-test");
203 assert_eq!(m.version, "0.0.1");
204 assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
205 assert_eq!(m.execute.script.trim(), "echo 'kanade'");
206 assert_eq!(m.execute.timeout, "30s");
207 assert!(!m.require_approval);
208 }
209
210 #[test]
211 fn schedule_carries_target_and_rollout() {
212 let yaml = r#"
213id: hourly-cleanup-canary
214cron: "0 0 * * * *"
215job_id: cleanup
216enabled: true
217target:
218 groups: [canary, wave1]
219jitter: 30s
220rollout:
221 strategy: wave
222 waves:
223 - { group: canary, delay: 0s }
224 - { group: wave1, delay: 5s }
225"#;
226 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
227 assert_eq!(s.id, "hourly-cleanup-canary");
228 assert_eq!(s.job_id, "cleanup");
229 assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
230 assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
231 let rollout = s.plan.rollout.expect("rollout present");
232 assert_eq!(rollout.waves.len(), 2);
233 assert_eq!(rollout.waves[0].group, "canary");
234 assert_eq!(rollout.waves[1].delay, "5s");
235 assert_eq!(rollout.strategy, RolloutStrategy::Wave);
236 }
237
238 #[test]
239 fn schedule_minimal_target_all() {
240 let yaml = r#"
241id: every-10s
242cron: "*/10 * * * * *"
243enabled: true
244job_id: scheduled-echo
245target: { all: true }
246"#;
247 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
248 assert_eq!(s.id, "every-10s");
249 assert_eq!(s.cron, "*/10 * * * * *");
250 assert!(s.enabled);
251 assert_eq!(s.job_id, "scheduled-echo");
252 assert!(s.plan.target.all);
253 assert!(s.plan.rollout.is_none());
254 assert!(s.plan.jitter.is_none());
255 }
256
257 #[test]
258 fn schedule_enabled_defaults_to_true() {
259 let yaml = r#"
260id: x
261cron: "* * * * * *"
262job_id: y
263target: { all: true }
264"#;
265 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
266 assert!(s.enabled);
267 }
268
269 #[test]
270 fn schedule_mode_defaults_to_every_tick() {
271 let yaml = r#"
272id: x
273cron: "* * * * * *"
274job_id: y
275target: { all: true }
276"#;
277 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
278 assert_eq!(s.mode, ExecMode::EveryTick);
279 assert!(s.cooldown.is_none());
280 assert!(!s.auto_disable_when_done);
281 }
282
283 #[test]
284 fn schedule_mode_serialises_snake_case() {
285 for (mode, expected) in [
286 (ExecMode::EveryTick, "every_tick"),
287 (ExecMode::OncePerPc, "once_per_pc"),
288 (ExecMode::OncePerTarget, "once_per_target"),
289 ] {
290 let s = serde_json::to_value(mode).expect("serialise");
291 assert_eq!(s, serde_json::Value::String(expected.into()));
292 let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
293 .expect("deserialise");
294 assert_eq!(back, mode, "round-trip for {expected}");
295 }
296 }
297
298 #[test]
299 fn schedule_kitting_yaml_parses() {
300 let yaml = r#"
301id: kitting-setup
302cron: "*/30 * * * * *"
303job_id: install-baseline
304target: { all: true }
305mode: once_per_pc
306"#;
307 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
308 assert_eq!(s.mode, ExecMode::OncePerPc);
309 assert!(s.cooldown.is_none());
310 assert!(!s.auto_disable_when_done);
311 }
312
313 #[test]
314 fn schedule_batch_campaign_yaml_parses() {
315 let yaml = r#"
316id: q3-patch-batch
317cron: "*/5 * * * * *"
318job_id: install-patch
319target:
320 pcs: [pc-001, pc-002, pc-003]
321mode: once_per_pc
322auto_disable_when_done: true
323"#;
324 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
325 assert_eq!(s.mode, ExecMode::OncePerPc);
326 assert!(s.cooldown.is_none());
327 assert!(s.auto_disable_when_done);
328 assert_eq!(s.plan.target.pcs.len(), 3);
329 }
330
331 #[test]
332 fn schedule_throttled_yaml_parses() {
333 let yaml = r#"
334id: daily-compliance
335cron: "*/5 * * * * *"
336job_id: check-av-status
337target: { all: true }
338mode: once_per_pc
339cooldown: 1d
340"#;
341 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
342 assert_eq!(s.mode, ExecMode::OncePerPc);
343 assert_eq!(s.cooldown.as_deref(), Some("1d"));
344 }
345
346 #[test]
347 fn schedule_once_per_target_yaml_parses() {
348 let yaml = r#"
349id: license-checkin
350cron: "*/10 * * * * *"
351job_id: hit-license-server
352target: { all: true }
353mode: once_per_target
354cooldown: 24h
355"#;
356 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
357 assert_eq!(s.mode, ExecMode::OncePerTarget);
358 assert_eq!(s.cooldown.as_deref(), Some("24h"));
359 }
360
361 #[test]
362 fn execute_shell_into_wire_shell() {
363 assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
364 assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
365 }
366
367 #[test]
368 fn missing_required_field_errors() {
369 let yaml = r#"
371version: 1.0.0
372target: { all: true }
373execute:
374 shell: powershell
375 script: "echo"
376 timeout: 1s
377"#;
378 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
379 assert!(r.is_err(), "expected error, got {:?}", r);
380 }
381}
382
383#[derive(Serialize, Deserialize, Debug, Clone)]
389pub struct Schedule {
390 pub id: String,
391 pub cron: String,
394 pub job_id: String,
397 #[serde(flatten)]
401 pub plan: FanoutPlan,
402 #[serde(default)]
406 pub mode: ExecMode,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub cooldown: Option<String>,
413 #[serde(default)]
419 pub auto_disable_when_done: bool,
420 #[serde(default = "default_true")]
421 pub enabled: bool,
422}
423
424#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
426#[serde(rename_all = "snake_case")]
427pub enum ExecMode {
428 #[default]
431 EveryTick,
432 OncePerPc,
436 OncePerTarget,
441}
442
443fn default_true() -> bool {
444 true
445}