Skip to main content

kanade_shared/
manifest.rs

1use serde::{Deserialize, Serialize};
2
3use crate::wire::{RunAs, 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    /// Token + session combination the agent uses to launch the
139    /// script (v0.21). Default = [`RunAs::System`] (Session 0,
140    /// LocalSystem privileges, no GUI) — matches pre-v0.21 behavior.
141    #[serde(default)]
142    pub run_as: RunAs,
143}
144
145#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
146#[serde(rename_all = "lowercase")]
147pub enum ExecuteShell {
148    Powershell,
149    Cmd,
150}
151
152impl From<ExecuteShell> for Shell {
153    fn from(s: ExecuteShell) -> Self {
154        match s {
155            ExecuteShell::Powershell => Shell::Powershell,
156            ExecuteShell::Cmd => Shell::Cmd,
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn target_is_specified_requires_at_least_one_field() {
167        let empty = Target::default();
168        assert!(!empty.is_specified());
169
170        let with_all = Target {
171            all: true,
172            ..Target::default()
173        };
174        assert!(with_all.is_specified());
175
176        let with_groups = Target {
177            groups: vec!["canary".into()],
178            ..Target::default()
179        };
180        assert!(with_groups.is_specified());
181
182        let with_pcs = Target {
183            pcs: vec!["minipc".into()],
184            ..Target::default()
185        };
186        assert!(with_pcs.is_specified());
187    }
188
189    #[test]
190    fn manifest_deserialises_minimal_yaml() {
191        // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
192        // — those live on the schedule / exec request now.
193        let yaml = r#"
194id: echo-test
195version: 0.0.1
196execute:
197  shell: powershell
198  script: "echo 'kanade'"
199  timeout: 30s
200"#;
201        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
202        assert_eq!(m.id, "echo-test");
203        assert_eq!(m.version, "0.0.1");
204        assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
205        assert_eq!(m.execute.script.trim(), "echo 'kanade'");
206        assert_eq!(m.execute.timeout, "30s");
207        assert!(!m.require_approval);
208    }
209
210    #[test]
211    fn schedule_carries_target_and_rollout() {
212        let yaml = r#"
213id: hourly-cleanup-canary
214cron: "0 0 * * * *"
215job_id: cleanup
216enabled: true
217target:
218  groups: [canary, wave1]
219jitter: 30s
220rollout:
221  strategy: wave
222  waves:
223    - { group: canary, delay: 0s }
224    - { group: wave1,  delay: 5s }
225"#;
226        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
227        assert_eq!(s.id, "hourly-cleanup-canary");
228        assert_eq!(s.job_id, "cleanup");
229        assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
230        assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
231        let rollout = s.plan.rollout.expect("rollout present");
232        assert_eq!(rollout.waves.len(), 2);
233        assert_eq!(rollout.waves[0].group, "canary");
234        assert_eq!(rollout.waves[1].delay, "5s");
235        assert_eq!(rollout.strategy, RolloutStrategy::Wave);
236    }
237
238    #[test]
239    fn schedule_minimal_target_all() {
240        let yaml = r#"
241id: every-10s
242cron: "*/10 * * * * *"
243enabled: true
244job_id: scheduled-echo
245target: { all: true }
246"#;
247        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
248        assert_eq!(s.id, "every-10s");
249        assert_eq!(s.cron, "*/10 * * * * *");
250        assert!(s.enabled);
251        assert_eq!(s.job_id, "scheduled-echo");
252        assert!(s.plan.target.all);
253        assert!(s.plan.rollout.is_none());
254        assert!(s.plan.jitter.is_none());
255    }
256
257    #[test]
258    fn schedule_enabled_defaults_to_true() {
259        let yaml = r#"
260id: x
261cron: "* * * * * *"
262job_id: y
263target: { all: true }
264"#;
265        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
266        assert!(s.enabled);
267    }
268
269    #[test]
270    fn schedule_mode_defaults_to_every_tick() {
271        let yaml = r#"
272id: x
273cron: "* * * * * *"
274job_id: y
275target: { all: true }
276"#;
277        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
278        assert_eq!(s.mode, ExecMode::EveryTick);
279        assert!(s.cooldown.is_none());
280        assert!(!s.auto_disable_when_done);
281    }
282
283    #[test]
284    fn schedule_mode_serialises_snake_case() {
285        for (mode, expected) in [
286            (ExecMode::EveryTick, "every_tick"),
287            (ExecMode::OncePerPc, "once_per_pc"),
288            (ExecMode::OncePerTarget, "once_per_target"),
289        ] {
290            let s = serde_json::to_value(mode).expect("serialise");
291            assert_eq!(s, serde_json::Value::String(expected.into()));
292            let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
293                .expect("deserialise");
294            assert_eq!(back, mode, "round-trip for {expected}");
295        }
296    }
297
298    #[test]
299    fn schedule_kitting_yaml_parses() {
300        let yaml = r#"
301id: kitting-setup
302cron: "*/30 * * * * *"
303job_id: install-baseline
304target: { all: true }
305mode: once_per_pc
306"#;
307        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
308        assert_eq!(s.mode, ExecMode::OncePerPc);
309        assert!(s.cooldown.is_none());
310        assert!(!s.auto_disable_when_done);
311    }
312
313    #[test]
314    fn schedule_batch_campaign_yaml_parses() {
315        let yaml = r#"
316id: q3-patch-batch
317cron: "*/5 * * * * *"
318job_id: install-patch
319target:
320  pcs: [pc-001, pc-002, pc-003]
321mode: once_per_pc
322auto_disable_when_done: true
323"#;
324        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
325        assert_eq!(s.mode, ExecMode::OncePerPc);
326        assert!(s.cooldown.is_none());
327        assert!(s.auto_disable_when_done);
328        assert_eq!(s.plan.target.pcs.len(), 3);
329    }
330
331    #[test]
332    fn schedule_throttled_yaml_parses() {
333        let yaml = r#"
334id: daily-compliance
335cron: "*/5 * * * * *"
336job_id: check-av-status
337target: { all: true }
338mode: once_per_pc
339cooldown: 1d
340"#;
341        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
342        assert_eq!(s.mode, ExecMode::OncePerPc);
343        assert_eq!(s.cooldown.as_deref(), Some("1d"));
344    }
345
346    #[test]
347    fn schedule_once_per_target_yaml_parses() {
348        let yaml = r#"
349id: license-checkin
350cron: "*/10 * * * * *"
351job_id: hit-license-server
352target: { all: true }
353mode: once_per_target
354cooldown: 24h
355"#;
356        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
357        assert_eq!(s.mode, ExecMode::OncePerTarget);
358        assert_eq!(s.cooldown.as_deref(), Some("24h"));
359    }
360
361    #[test]
362    fn execute_shell_into_wire_shell() {
363        assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
364        assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
365    }
366
367    #[test]
368    fn missing_required_field_errors() {
369        // `id` missing.
370        let yaml = r#"
371version: 1.0.0
372target: { all: true }
373execute:
374  shell: powershell
375  script: "echo"
376  timeout: 1s
377"#;
378        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
379        assert!(r.is_err(), "expected error, got {:?}", r);
380    }
381}
382
383/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
384/// (target + optional rollout + optional jitter) inline; the
385/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
386/// script body. Two schedules of the same job can target different
387/// groups on different cadences without copying the manifest.
388#[derive(Serialize, Deserialize, Debug, Clone)]
389pub struct Schedule {
390    pub id: String,
391    /// 6-field cron expression (`sec min hour day month day-of-week`),
392    /// matching `tokio-cron-scheduler` syntax.
393    pub cron: String,
394    /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
395    /// Manifest's `id`.
396    pub job_id: String,
397    /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
398    /// carry these any more — same job + different fanout = different
399    /// schedule.
400    #[serde(flatten)]
401    pub plan: FanoutPlan,
402    /// Per-pc/per-target dedup semantics (v0.19). Default
403    /// `EveryTick` keeps the historical "fire every cron tick at the
404    /// whole target" behavior.
405    #[serde(default)]
406    pub mode: ExecMode,
407    /// Humantime cooldown for `OncePerPc` / `OncePerTarget`. Once a
408    /// pc/target has succeeded, the scheduler waits this long before
409    /// considering it eligible again. Omit for "succeed once, then
410    /// permanently skip" — i.e. cooldown = infinity.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub cooldown: Option<String>,
413    /// When true AND the schedule's lifecycle is permanently
414    /// terminated (`cooldown = None` + dedup says nothing more to
415    /// do), the scheduler flips `enabled = false` and emits an
416    /// audit event. No-op when `cooldown` is set (re-arming
417    /// schedules never finish).
418    #[serde(default)]
419    pub auto_disable_when_done: bool,
420    #[serde(default = "default_true")]
421    pub enabled: bool,
422}
423
424/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
425#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
426#[serde(rename_all = "snake_case")]
427pub enum ExecMode {
428    /// Fire on every cron tick at the whole target. Historical
429    /// (pre-v0.19) behavior; no dedup.
430    #[default]
431    EveryTick,
432    /// Fire at each pc until that pc succeeds; then skip it until
433    /// the optional cooldown elapses (or forever if no cooldown).
434    /// Use for kitting / first-boot / per-pc compliance checks.
435    OncePerPc,
436    /// Fire at the whole target until **any** pc succeeds; then
437    /// skip the whole target until the optional cooldown elapses
438    /// (or forever if no cooldown). Use for "one delegate is
439    /// enough" tasks like license check-in.
440    OncePerTarget,
441}
442
443fn default_true() -> bool {
444    true
445}