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