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