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///
37/// Two render modes:
38///   * `display` — vertical "field / value" per PC, used by the
39///     `/inventory?pc=<id>` detail view. ALL columns the operator
40///     wants visible on the detail page.
41///   * `summary` — horizontal table across the fleet (row = PC,
42///     column = field) on `/inventory`. Optional; when omitted the
43///     SPA falls back to `display`, but operators usually want a
44///     trimmer "hostname / OS / CPU / RAM" set for the fleet view.
45#[derive(Serialize, Deserialize, Debug, Clone)]
46pub struct InventoryHint {
47    /// Detail-view columns, in order.
48    pub display: Vec<DisplayField>,
49    /// Optional fleet-list columns (row = PC). Defaults to `display`
50    /// when omitted, but operators usually pick a 3-5 column subset.
51    #[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    /// Top-level key in the stdout JSON.
58    pub field: String,
59    /// Human-readable column header.
60    pub label: String,
61    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`.
62    /// Defaults to plain text rendering on the SPA side.
63    #[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    /// humantime delay measured from the deploy's publish time. wave[0]
86    /// typically has "0s"; subsequent waves use minutes / hours.
87    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    /// At least one of all / groups / pcs is set.
102    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    /// humantime duration string (e.g. "30s", "10m").
112    pub timeout: String,
113    /// Optional humantime jitter; agent uses it to randomise execution start.
114    #[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        // Matches jobs/echo-test.yaml.
165        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        // `id` missing.
268        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/// Periodic schedule (spec §2.4.3). The full job [`Manifest`] is embedded
282/// so the scheduler can deploy it without a separate Git lookup; once a
283/// dedicated job-catalog API lands, `manifest` can become a `job_id`
284/// reference instead.
285#[derive(Serialize, Deserialize, Debug, Clone)]
286pub struct Schedule {
287    pub id: String,
288    /// 6-field cron expression (`sec min hour day month day-of-week`),
289    /// matching `tokio-cron-scheduler` syntax.
290    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}