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