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 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub cwd: Option<String>,
154}
155
156#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
157#[serde(rename_all = "lowercase")]
158pub enum ExecuteShell {
159 Powershell,
160 Cmd,
161}
162
163impl From<ExecuteShell> for Shell {
164 fn from(s: ExecuteShell) -> Self {
165 match s {
166 ExecuteShell::Powershell => Shell::Powershell,
167 ExecuteShell::Cmd => Shell::Cmd,
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn target_is_specified_requires_at_least_one_field() {
178 let empty = Target::default();
179 assert!(!empty.is_specified());
180
181 let with_all = Target {
182 all: true,
183 ..Target::default()
184 };
185 assert!(with_all.is_specified());
186
187 let with_groups = Target {
188 groups: vec!["canary".into()],
189 ..Target::default()
190 };
191 assert!(with_groups.is_specified());
192
193 let with_pcs = Target {
194 pcs: vec!["minipc".into()],
195 ..Target::default()
196 };
197 assert!(with_pcs.is_specified());
198 }
199
200 #[test]
201 fn manifest_deserialises_minimal_yaml() {
202 let yaml = r#"
205id: echo-test
206version: 0.0.1
207execute:
208 shell: powershell
209 script: "echo 'kanade'"
210 timeout: 30s
211"#;
212 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
213 assert_eq!(m.id, "echo-test");
214 assert_eq!(m.version, "0.0.1");
215 assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
216 assert_eq!(m.execute.script.trim(), "echo 'kanade'");
217 assert_eq!(m.execute.timeout, "30s");
218 assert!(!m.require_approval);
219 }
220
221 #[test]
222 fn schedule_carries_target_and_rollout() {
223 let yaml = r#"
224id: hourly-cleanup-canary
225cron: "0 0 * * * *"
226job_id: cleanup
227enabled: true
228target:
229 groups: [canary, wave1]
230jitter: 30s
231rollout:
232 strategy: wave
233 waves:
234 - { group: canary, delay: 0s }
235 - { group: wave1, delay: 5s }
236"#;
237 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
238 assert_eq!(s.id, "hourly-cleanup-canary");
239 assert_eq!(s.job_id, "cleanup");
240 assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
241 assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
242 let rollout = s.plan.rollout.expect("rollout present");
243 assert_eq!(rollout.waves.len(), 2);
244 assert_eq!(rollout.waves[0].group, "canary");
245 assert_eq!(rollout.waves[1].delay, "5s");
246 assert_eq!(rollout.strategy, RolloutStrategy::Wave);
247 }
248
249 #[test]
250 fn schedule_minimal_target_all() {
251 let yaml = r#"
252id: every-10s
253cron: "*/10 * * * * *"
254enabled: true
255job_id: scheduled-echo
256target: { all: true }
257"#;
258 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
259 assert_eq!(s.id, "every-10s");
260 assert_eq!(s.cron, "*/10 * * * * *");
261 assert!(s.enabled);
262 assert_eq!(s.job_id, "scheduled-echo");
263 assert!(s.plan.target.all);
264 assert!(s.plan.rollout.is_none());
265 assert!(s.plan.jitter.is_none());
266 }
267
268 #[test]
269 fn schedule_enabled_defaults_to_true() {
270 let yaml = r#"
271id: x
272cron: "* * * * * *"
273job_id: y
274target: { all: true }
275"#;
276 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
277 assert!(s.enabled);
278 }
279
280 #[test]
281 fn schedule_mode_defaults_to_every_tick() {
282 let yaml = r#"
283id: x
284cron: "* * * * * *"
285job_id: y
286target: { all: true }
287"#;
288 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
289 assert_eq!(s.mode, ExecMode::EveryTick);
290 assert!(s.cooldown.is_none());
291 assert!(!s.auto_disable_when_done);
292 }
293
294 #[test]
295 fn schedule_mode_serialises_snake_case() {
296 for (mode, expected) in [
297 (ExecMode::EveryTick, "every_tick"),
298 (ExecMode::OncePerPc, "once_per_pc"),
299 (ExecMode::OncePerTarget, "once_per_target"),
300 ] {
301 let s = serde_json::to_value(mode).expect("serialise");
302 assert_eq!(s, serde_json::Value::String(expected.into()));
303 let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
304 .expect("deserialise");
305 assert_eq!(back, mode, "round-trip for {expected}");
306 }
307 }
308
309 #[test]
310 fn schedule_kitting_yaml_parses() {
311 let yaml = r#"
312id: kitting-setup
313cron: "*/30 * * * * *"
314job_id: install-baseline
315target: { all: true }
316mode: once_per_pc
317"#;
318 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
319 assert_eq!(s.mode, ExecMode::OncePerPc);
320 assert!(s.cooldown.is_none());
321 assert!(!s.auto_disable_when_done);
322 }
323
324 #[test]
325 fn schedule_batch_campaign_yaml_parses() {
326 let yaml = r#"
327id: q3-patch-batch
328cron: "*/5 * * * * *"
329job_id: install-patch
330target:
331 pcs: [pc-001, pc-002, pc-003]
332mode: once_per_pc
333auto_disable_when_done: true
334"#;
335 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
336 assert_eq!(s.mode, ExecMode::OncePerPc);
337 assert!(s.cooldown.is_none());
338 assert!(s.auto_disable_when_done);
339 assert_eq!(s.plan.target.pcs.len(), 3);
340 }
341
342 #[test]
343 fn schedule_throttled_yaml_parses() {
344 let yaml = r#"
345id: daily-compliance
346cron: "*/5 * * * * *"
347job_id: check-av-status
348target: { all: true }
349mode: once_per_pc
350cooldown: 1d
351"#;
352 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
353 assert_eq!(s.mode, ExecMode::OncePerPc);
354 assert_eq!(s.cooldown.as_deref(), Some("1d"));
355 }
356
357 #[test]
358 fn schedule_once_per_target_yaml_parses() {
359 let yaml = r#"
360id: license-checkin
361cron: "*/10 * * * * *"
362job_id: hit-license-server
363target: { all: true }
364mode: once_per_target
365cooldown: 24h
366"#;
367 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
368 assert_eq!(s.mode, ExecMode::OncePerTarget);
369 assert_eq!(s.cooldown.as_deref(), Some("24h"));
370 }
371
372 #[test]
373 fn execute_shell_into_wire_shell() {
374 assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
375 assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
376 }
377
378 #[test]
379 fn missing_required_field_errors() {
380 let yaml = r#"
382version: 1.0.0
383target: { all: true }
384execute:
385 shell: powershell
386 script: "echo"
387 timeout: 1s
388"#;
389 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
390 assert!(r.is_err(), "expected error, got {:?}", r);
391 }
392}
393
394#[derive(Serialize, Deserialize, Debug, Clone)]
400pub struct Schedule {
401 pub id: String,
402 pub cron: String,
405 pub job_id: String,
408 #[serde(flatten)]
412 pub plan: FanoutPlan,
413 #[serde(default)]
417 pub mode: ExecMode,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub cooldown: Option<String>,
424 #[serde(default)]
430 pub auto_disable_when_done: bool,
431 #[serde(default = "default_true")]
432 pub enabled: bool,
433}
434
435#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
437#[serde(rename_all = "snake_case")]
438pub enum ExecMode {
439 #[default]
442 EveryTick,
443 OncePerPc,
447 OncePerTarget,
452}
453
454fn default_true() -> bool {
455 true
456}