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