Skip to main content

kanade_shared/
manifest.rs

1use serde::{Deserialize, Serialize};
2
3use crate::wire::Shell;
4
5/// YAML job manifest (spec §2.4.1, Sprint 4a covers everything except
6/// `execute.script_file` / `execute.script_object` / `on_failure`).
7#[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    /// Optional wave rollout — when present, the backend publishes each
16    /// wave's group subject on its own delay schedule instead of fanning
17    /// out the `target` block at deploy time. `target` is then only used
18    /// as a fallback (e.g. `target.all: true` to mark the manifest as
19    /// fleet-wide for the audit log).
20    #[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    /// humantime delay measured from the deploy's publish time. wave[0]
44    /// typically has "0s"; subsequent waves use minutes / hours.
45    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    /// At least one of all / groups / pcs is set.
60    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    /// humantime duration string (e.g. "30s", "10m").
70    pub timeout: String,
71    /// Optional humantime jitter; agent uses it to randomise execution start.
72    #[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        // Matches jobs/echo-test.yaml.
123        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        // `id` missing.
226        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/// Periodic schedule (spec §2.4.3). The full job [`Manifest`] is embedded
240/// so the scheduler can deploy it without a separate Git lookup; once a
241/// dedicated job-catalog API lands, `manifest` can become a `job_id`
242/// reference instead.
243#[derive(Serialize, Deserialize, Debug, Clone)]
244pub struct Schedule {
245    pub id: String,
246    /// 6-field cron expression (`sec min hour day month day-of-week`),
247    /// matching `tokio-cron-scheduler` syntax.
248    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}