Skip to main content

kanade_shared/
manifest.rs

1use serde::{Deserialize, Serialize};
2
3use crate::wire::Shell;
4
5/// YAML job manifest (= registered "what to run", v0.18.0+).
6///
7/// Owns only script-intrinsic fields. **Who** (`target`), **how to
8/// phase fanout** (`rollout`), and **when to stagger start**
9/// (`jitter`) all moved to the Schedule / exec request side — same
10/// script can now be fired against different targets / rollouts
11/// without copying the script body.
12///
13/// `deny_unknown_fields` makes operators copy-pasting an older yaml
14/// that still has `target:` / `rollout:` see a clear parse error at
15/// `kanade job create` time instead of mysteriously losing it.
16#[derive(Serialize, Deserialize, Debug, Clone)]
17#[serde(deny_unknown_fields)]
18pub struct Manifest {
19    pub id: String,
20    pub version: String,
21    #[serde(default)]
22    pub description: Option<String>,
23    pub execute: Execute,
24    #[serde(default)]
25    pub require_approval: bool,
26    /// Opt-in marker that this job produces a JSON inventory fact
27    /// payload on stdout. When present, the backend's results
28    /// projector parses `ExecResult.stdout` as JSON and upserts an
29    /// `inventory_facts` row keyed by `(pc_id, manifest.id)`. The
30    /// `display` sub-config drives the SPA's Inventory page render.
31    #[serde(default)]
32    pub inventory: Option<InventoryHint>,
33}
34
35/// "Who + how + when-to-stagger" — the fanout-plan side of an exec.
36/// Used both as the POST `/api/exec/{job_id}` body and as the embedded
37/// `target` / `rollout` / `jitter` slot on [`Schedule`]. Centralising
38/// here keeps the validation + serialisation logic in one place.
39#[derive(Serialize, Deserialize, Debug, Clone, Default)]
40pub struct FanoutPlan {
41    #[serde(default)]
42    pub target: Target,
43    /// Optional wave rollout — when present, the backend publishes
44    /// each wave's group subject on its own delay schedule instead
45    /// of fanning out the `target` block in one go. `target` then
46    /// only labels the deploy for the audit log.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub rollout: Option<Rollout>,
49    /// Optional humantime jitter; agent uses it to randomise
50    /// execution start. Lives here (not on the script) so different
51    /// schedules / ad-hoc fires of the same job can pick different
52    /// stagger windows.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub jitter: Option<String>,
55}
56
57/// Manifest sub-section: how the SPA should render the inventory
58/// facts this job produces. Each field name (`field`) is a top-level
59/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
60///
61/// Two render modes:
62///   * `display` — vertical "field / value" per PC, used by the
63///     `/inventory?pc=<id>` detail view. ALL columns the operator
64///     wants visible on the detail page.
65///   * `summary` — horizontal table across the fleet (row = PC,
66///     column = field) on `/inventory`. Optional; when omitted the
67///     SPA falls back to `display`, but operators usually want a
68///     trimmer "hostname / OS / CPU / RAM" set for the fleet view.
69#[derive(Serialize, Deserialize, Debug, Clone)]
70pub struct InventoryHint {
71    /// Detail-view columns, in order.
72    pub display: Vec<DisplayField>,
73    /// Optional fleet-list columns (row = PC). Defaults to `display`
74    /// when omitted, but operators usually pick a 3-5 column subset.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub summary: Option<Vec<DisplayField>>,
77}
78
79#[derive(Serialize, Deserialize, Debug, Clone)]
80pub struct DisplayField {
81    /// Top-level key in the stdout JSON.
82    pub field: String,
83    /// Human-readable column header.
84    pub label: String,
85    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`.
86    /// Defaults to plain text rendering on the SPA side.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    #[serde(rename = "type")]
89    pub kind: Option<String>,
90}
91
92#[derive(Serialize, Deserialize, Debug, Clone)]
93pub struct Rollout {
94    #[serde(default)]
95    pub strategy: RolloutStrategy,
96    pub waves: Vec<Wave>,
97}
98
99#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
100#[serde(rename_all = "lowercase")]
101pub enum RolloutStrategy {
102    #[default]
103    Wave,
104}
105
106#[derive(Serialize, Deserialize, Debug, Clone)]
107pub struct Wave {
108    pub group: String,
109    /// humantime delay measured from the deploy's publish time. wave[0]
110    /// typically has "0s"; subsequent waves use minutes / hours.
111    pub delay: String,
112}
113
114#[derive(Serialize, Deserialize, Debug, Clone, Default)]
115pub struct Target {
116    #[serde(default)]
117    pub groups: Vec<String>,
118    #[serde(default)]
119    pub pcs: Vec<String>,
120    #[serde(default)]
121    pub all: bool,
122}
123
124impl Target {
125    /// At least one of all / groups / pcs is set.
126    pub fn is_specified(&self) -> bool {
127        self.all || !self.groups.is_empty() || !self.pcs.is_empty()
128    }
129}
130
131#[derive(Serialize, Deserialize, Debug, Clone)]
132pub struct Execute {
133    pub shell: ExecuteShell,
134    pub script: String,
135    /// humantime duration string (e.g. "30s", "10m"). Script-intrinsic
136    /// — represents how long this script reasonably takes to run.
137    pub timeout: String,
138}
139
140#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
141#[serde(rename_all = "lowercase")]
142pub enum ExecuteShell {
143    Powershell,
144    Cmd,
145}
146
147impl From<ExecuteShell> for Shell {
148    fn from(s: ExecuteShell) -> Self {
149        match s {
150            ExecuteShell::Powershell => Shell::Powershell,
151            ExecuteShell::Cmd => Shell::Cmd,
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn target_is_specified_requires_at_least_one_field() {
162        let empty = Target::default();
163        assert!(!empty.is_specified());
164
165        let with_all = Target {
166            all: true,
167            ..Target::default()
168        };
169        assert!(with_all.is_specified());
170
171        let with_groups = Target {
172            groups: vec!["canary".into()],
173            ..Target::default()
174        };
175        assert!(with_groups.is_specified());
176
177        let with_pcs = Target {
178            pcs: vec!["minipc".into()],
179            ..Target::default()
180        };
181        assert!(with_pcs.is_specified());
182    }
183
184    #[test]
185    fn manifest_deserialises_minimal_yaml() {
186        // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
187        // — those live on the schedule / exec request now.
188        let yaml = r#"
189id: echo-test
190version: 0.0.1
191execute:
192  shell: powershell
193  script: "echo 'kanade'"
194  timeout: 30s
195"#;
196        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
197        assert_eq!(m.id, "echo-test");
198        assert_eq!(m.version, "0.0.1");
199        assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
200        assert_eq!(m.execute.script.trim(), "echo 'kanade'");
201        assert_eq!(m.execute.timeout, "30s");
202        assert!(!m.require_approval);
203    }
204
205    #[test]
206    fn schedule_carries_target_and_rollout() {
207        let yaml = r#"
208id: hourly-cleanup-canary
209cron: "0 0 * * * *"
210job_id: cleanup
211enabled: true
212target:
213  groups: [canary, wave1]
214jitter: 30s
215rollout:
216  strategy: wave
217  waves:
218    - { group: canary, delay: 0s }
219    - { group: wave1,  delay: 5s }
220"#;
221        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
222        assert_eq!(s.id, "hourly-cleanup-canary");
223        assert_eq!(s.job_id, "cleanup");
224        assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
225        assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
226        let rollout = s.plan.rollout.expect("rollout present");
227        assert_eq!(rollout.waves.len(), 2);
228        assert_eq!(rollout.waves[0].group, "canary");
229        assert_eq!(rollout.waves[1].delay, "5s");
230        assert_eq!(rollout.strategy, RolloutStrategy::Wave);
231    }
232
233    #[test]
234    fn schedule_minimal_target_all() {
235        let yaml = r#"
236id: every-10s
237cron: "*/10 * * * * *"
238enabled: true
239job_id: scheduled-echo
240target: { all: true }
241"#;
242        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
243        assert_eq!(s.id, "every-10s");
244        assert_eq!(s.cron, "*/10 * * * * *");
245        assert!(s.enabled);
246        assert_eq!(s.job_id, "scheduled-echo");
247        assert!(s.plan.target.all);
248        assert!(s.plan.rollout.is_none());
249        assert!(s.plan.jitter.is_none());
250    }
251
252    #[test]
253    fn schedule_enabled_defaults_to_true() {
254        let yaml = r#"
255id: x
256cron: "* * * * * *"
257job_id: y
258target: { all: true }
259"#;
260        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
261        assert!(s.enabled);
262    }
263
264    #[test]
265    fn schedule_mode_defaults_to_every_tick() {
266        let yaml = r#"
267id: x
268cron: "* * * * * *"
269job_id: y
270target: { all: true }
271"#;
272        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
273        assert_eq!(s.mode, ExecMode::EveryTick);
274        assert!(s.cooldown.is_none());
275        assert!(!s.auto_disable_when_done);
276    }
277
278    #[test]
279    fn schedule_mode_serialises_snake_case() {
280        for (mode, expected) in [
281            (ExecMode::EveryTick, "every_tick"),
282            (ExecMode::OncePerPc, "once_per_pc"),
283            (ExecMode::OncePerTarget, "once_per_target"),
284        ] {
285            let s = serde_json::to_value(mode).expect("serialise");
286            assert_eq!(s, serde_json::Value::String(expected.into()));
287            let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
288                .expect("deserialise");
289            assert_eq!(back, mode, "round-trip for {expected}");
290        }
291    }
292
293    #[test]
294    fn schedule_kitting_yaml_parses() {
295        let yaml = r#"
296id: kitting-setup
297cron: "*/30 * * * * *"
298job_id: install-baseline
299target: { all: true }
300mode: once_per_pc
301"#;
302        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
303        assert_eq!(s.mode, ExecMode::OncePerPc);
304        assert!(s.cooldown.is_none());
305        assert!(!s.auto_disable_when_done);
306    }
307
308    #[test]
309    fn schedule_batch_campaign_yaml_parses() {
310        let yaml = r#"
311id: q3-patch-batch
312cron: "*/5 * * * * *"
313job_id: install-patch
314target:
315  pcs: [pc-001, pc-002, pc-003]
316mode: once_per_pc
317auto_disable_when_done: true
318"#;
319        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
320        assert_eq!(s.mode, ExecMode::OncePerPc);
321        assert!(s.cooldown.is_none());
322        assert!(s.auto_disable_when_done);
323        assert_eq!(s.plan.target.pcs.len(), 3);
324    }
325
326    #[test]
327    fn schedule_throttled_yaml_parses() {
328        let yaml = r#"
329id: daily-compliance
330cron: "*/5 * * * * *"
331job_id: check-av-status
332target: { all: true }
333mode: once_per_pc
334cooldown: 1d
335"#;
336        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
337        assert_eq!(s.mode, ExecMode::OncePerPc);
338        assert_eq!(s.cooldown.as_deref(), Some("1d"));
339    }
340
341    #[test]
342    fn schedule_once_per_target_yaml_parses() {
343        let yaml = r#"
344id: license-checkin
345cron: "*/10 * * * * *"
346job_id: hit-license-server
347target: { all: true }
348mode: once_per_target
349cooldown: 24h
350"#;
351        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
352        assert_eq!(s.mode, ExecMode::OncePerTarget);
353        assert_eq!(s.cooldown.as_deref(), Some("24h"));
354    }
355
356    #[test]
357    fn execute_shell_into_wire_shell() {
358        assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
359        assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
360    }
361
362    #[test]
363    fn missing_required_field_errors() {
364        // `id` missing.
365        let yaml = r#"
366version: 1.0.0
367target: { all: true }
368execute:
369  shell: powershell
370  script: "echo"
371  timeout: 1s
372"#;
373        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
374        assert!(r.is_err(), "expected error, got {:?}", r);
375    }
376}
377
378/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
379/// (target + optional rollout + optional jitter) inline; the
380/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
381/// script body. Two schedules of the same job can target different
382/// groups on different cadences without copying the manifest.
383#[derive(Serialize, Deserialize, Debug, Clone)]
384pub struct Schedule {
385    pub id: String,
386    /// 6-field cron expression (`sec min hour day month day-of-week`),
387    /// matching `tokio-cron-scheduler` syntax.
388    pub cron: String,
389    /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
390    /// Manifest's `id`.
391    pub job_id: String,
392    /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
393    /// carry these any more — same job + different fanout = different
394    /// schedule.
395    #[serde(flatten)]
396    pub plan: FanoutPlan,
397    /// Per-pc/per-target dedup semantics (v0.19). Default
398    /// `EveryTick` keeps the historical "fire every cron tick at the
399    /// whole target" behavior.
400    #[serde(default)]
401    pub mode: ExecMode,
402    /// Humantime cooldown for `OncePerPc` / `OncePerTarget`. Once a
403    /// pc/target has succeeded, the scheduler waits this long before
404    /// considering it eligible again. Omit for "succeed once, then
405    /// permanently skip" — i.e. cooldown = infinity.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub cooldown: Option<String>,
408    /// When true AND the schedule's lifecycle is permanently
409    /// terminated (`cooldown = None` + dedup says nothing more to
410    /// do), the scheduler flips `enabled = false` and emits an
411    /// audit event. No-op when `cooldown` is set (re-arming
412    /// schedules never finish).
413    #[serde(default)]
414    pub auto_disable_when_done: bool,
415    #[serde(default = "default_true")]
416    pub enabled: bool,
417}
418
419/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
420#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
421#[serde(rename_all = "snake_case")]
422pub enum ExecMode {
423    /// Fire on every cron tick at the whole target. Historical
424    /// (pre-v0.19) behavior; no dedup.
425    #[default]
426    EveryTick,
427    /// Fire at each pc until that pc succeeds; then skip it until
428    /// the optional cooldown elapses (or forever if no cooldown).
429    /// Use for kitting / first-boot / per-pc compliance checks.
430    OncePerPc,
431    /// Fire at the whole target until **any** pc succeeds; then
432    /// skip the whole target until the optional cooldown elapses
433    /// (or forever if no cooldown). Use for "one delegate is
434    /// enough" tasks like license check-in.
435    OncePerTarget,
436}
437
438fn default_true() -> bool {
439    true
440}