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