1use serde::{Deserialize, Serialize};
2
3use crate::wire::Shell;
4
5#[derive(Serialize, Deserialize, Debug, Clone)]
8pub struct Manifest {
9 pub id: String,
10 pub version: String,
11 #[serde(default)]
12 pub description: Option<String>,
13 pub target: Target,
14 pub execute: Execute,
15 #[serde(default)]
21 pub rollout: Option<Rollout>,
22 #[serde(default)]
23 pub require_approval: bool,
24 #[serde(default)]
30 pub inventory: Option<InventoryHint>,
31}
32
33#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct InventoryHint {
38 pub display: Vec<DisplayField>,
40}
41
42#[derive(Serialize, Deserialize, Debug, Clone)]
43pub struct DisplayField {
44 pub field: String,
46 pub label: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 #[serde(rename = "type")]
52 pub kind: Option<String>,
53}
54
55#[derive(Serialize, Deserialize, Debug, Clone)]
56pub struct Rollout {
57 #[serde(default)]
58 pub strategy: RolloutStrategy,
59 pub waves: Vec<Wave>,
60}
61
62#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
63#[serde(rename_all = "lowercase")]
64pub enum RolloutStrategy {
65 #[default]
66 Wave,
67}
68
69#[derive(Serialize, Deserialize, Debug, Clone)]
70pub struct Wave {
71 pub group: String,
72 pub delay: String,
75}
76
77#[derive(Serialize, Deserialize, Debug, Clone, Default)]
78pub struct Target {
79 #[serde(default)]
80 pub groups: Vec<String>,
81 #[serde(default)]
82 pub pcs: Vec<String>,
83 #[serde(default)]
84 pub all: bool,
85}
86
87impl Target {
88 pub fn is_specified(&self) -> bool {
90 self.all || !self.groups.is_empty() || !self.pcs.is_empty()
91 }
92}
93
94#[derive(Serialize, Deserialize, Debug, Clone)]
95pub struct Execute {
96 pub shell: ExecuteShell,
97 pub script: String,
98 pub timeout: String,
100 #[serde(default)]
102 pub jitter: Option<String>,
103}
104
105#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
106#[serde(rename_all = "lowercase")]
107pub enum ExecuteShell {
108 Powershell,
109 Cmd,
110}
111
112impl From<ExecuteShell> for Shell {
113 fn from(s: ExecuteShell) -> Self {
114 match s {
115 ExecuteShell::Powershell => Shell::Powershell,
116 ExecuteShell::Cmd => Shell::Cmd,
117 }
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn target_is_specified_requires_at_least_one_field() {
127 let empty = Target::default();
128 assert!(!empty.is_specified());
129
130 let with_all = Target {
131 all: true,
132 ..Target::default()
133 };
134 assert!(with_all.is_specified());
135
136 let with_groups = Target {
137 groups: vec!["canary".into()],
138 ..Target::default()
139 };
140 assert!(with_groups.is_specified());
141
142 let with_pcs = Target {
143 pcs: vec!["minipc".into()],
144 ..Target::default()
145 };
146 assert!(with_pcs.is_specified());
147 }
148
149 #[test]
150 fn manifest_deserialises_minimal_yaml() {
151 let yaml = r#"
153id: echo-test
154version: 0.0.1
155target:
156 pcs: [minipc]
157execute:
158 shell: powershell
159 script: "echo 'kanade'"
160 timeout: 30s
161"#;
162 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
163 assert_eq!(m.id, "echo-test");
164 assert_eq!(m.version, "0.0.1");
165 assert!(m.target.is_specified());
166 assert_eq!(m.target.pcs, vec!["minipc"]);
167 assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
168 assert_eq!(m.execute.script.trim(), "echo 'kanade'");
169 assert_eq!(m.execute.timeout, "30s");
170 assert!(m.execute.jitter.is_none());
171 assert!(m.rollout.is_none());
172 assert!(!m.require_approval);
173 }
174
175 #[test]
176 fn manifest_deserialises_wave_rollout() {
177 let yaml = r#"
178id: cleanup
179version: 1.0.0
180target:
181 groups: [canary, wave1]
182execute:
183 shell: cmd
184 script: "rmdir /S /Q C:\\temp"
185 timeout: 5m
186 jitter: 30s
187rollout:
188 strategy: wave
189 waves:
190 - { group: canary, delay: 0s }
191 - { group: wave1, delay: 5s }
192"#;
193 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
194 assert!(matches!(m.execute.shell, ExecuteShell::Cmd));
195 assert_eq!(m.execute.jitter.as_deref(), Some("30s"));
196 let rollout = m.rollout.expect("rollout present");
197 assert_eq!(rollout.waves.len(), 2);
198 assert_eq!(rollout.waves[0].group, "canary");
199 assert_eq!(rollout.waves[0].delay, "0s");
200 assert_eq!(rollout.waves[1].delay, "5s");
201 assert_eq!(rollout.strategy, RolloutStrategy::Wave);
202 }
203
204 #[test]
205 fn schedule_embeds_full_manifest() {
206 let yaml = r#"
207id: every-10s
208cron: "*/10 * * * * *"
209enabled: true
210manifest:
211 id: scheduled-echo
212 version: 1.0.0
213 target:
214 pcs: [minipc]
215 execute:
216 shell: powershell
217 script: "echo hi"
218 timeout: 30s
219"#;
220 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
221 assert_eq!(s.id, "every-10s");
222 assert_eq!(s.cron, "*/10 * * * * *");
223 assert!(s.enabled);
224 assert_eq!(s.manifest.id, "scheduled-echo");
225 }
226
227 #[test]
228 fn schedule_enabled_defaults_to_true() {
229 let yaml = r#"
230id: x
231cron: "* * * * * *"
232manifest:
233 id: y
234 version: 1.0.0
235 target:
236 all: true
237 execute:
238 shell: powershell
239 script: "echo"
240 timeout: 1s
241"#;
242 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
243 assert!(s.enabled);
244 }
245
246 #[test]
247 fn execute_shell_into_wire_shell() {
248 assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
249 assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
250 }
251
252 #[test]
253 fn missing_required_field_errors() {
254 let yaml = r#"
256version: 1.0.0
257target: { all: true }
258execute:
259 shell: powershell
260 script: "echo"
261 timeout: 1s
262"#;
263 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
264 assert!(r.is_err(), "expected error, got {:?}", r);
265 }
266}
267
268#[derive(Serialize, Deserialize, Debug, Clone)]
273pub struct Schedule {
274 pub id: String,
275 pub cron: String,
278 pub manifest: Manifest,
279 #[serde(default = "default_true")]
280 pub enabled: bool,
281}
282
283fn default_true() -> bool {
284 true
285}