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    /// v0.13: opt-in marker that this job produces a JSON inventory
25    /// fact payload on stdout. When present, the backend's results
26    /// projector parses the ExecResult.stdout as JSON and upserts an
27    /// `inventory_facts` row keyed by `(pc_id, manifest.id)`. The
28    /// `display` sub-config drives the SPA's Inventory page render.
29    #[serde(default)]
30    pub inventory: Option<InventoryHint>,
31}
32
33/// Manifest sub-section: how the SPA should render the inventory
34/// facts this job produces. Each field name (`field`) is a top-level
35/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
36#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct InventoryHint {
38    /// Column list, in display order.
39    pub display: Vec<DisplayField>,
40}
41
42#[derive(Serialize, Deserialize, Debug, Clone)]
43pub struct DisplayField {
44    /// Top-level key in the stdout JSON.
45    pub field: String,
46    /// Human-readable column header.
47    pub label: String,
48    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`.
49    /// Defaults to plain text rendering on the SPA side.
50    #[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    /// humantime delay measured from the deploy's publish time. wave[0]
73    /// typically has "0s"; subsequent waves use minutes / hours.
74    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    /// At least one of all / groups / pcs is set.
89    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    /// humantime duration string (e.g. "30s", "10m").
99    pub timeout: String,
100    /// Optional humantime jitter; agent uses it to randomise execution start.
101    #[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        // Matches jobs/echo-test.yaml.
152        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        // `id` missing.
255        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/// Periodic schedule (spec §2.4.3). The full job [`Manifest`] is embedded
269/// so the scheduler can deploy it without a separate Git lookup; once a
270/// dedicated job-catalog API lands, `manifest` can become a `job_id`
271/// reference instead.
272#[derive(Serialize, Deserialize, Debug, Clone)]
273pub struct Schedule {
274    pub id: String,
275    /// 6-field cron expression (`sec min hour day month day-of-week`),
276    /// matching `tokio-cron-scheduler` syntax.
277    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}