kanade_shared/manifest.rs
1use serde::{Deserialize, Serialize};
2
3use crate::wire::{RunAs, Shell, Staleness};
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, schemars::JsonSchema, 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 /// Issue #246: opt-in marker that this job emits per-line
34 /// observability events on stdout (one JSON `ObsEvent` per
35 /// newline). When present, the agent — after the script exits
36 /// successfully — parses each non-empty stdout line as an
37 /// `ObsEvent`, publishes it on `obs.<pc_id>` via the
38 /// `obs_outbox`, and (intentionally) **omits the stdout from
39 /// the `ExecResult`** so the timeline data doesn't double up
40 /// in `execution_results.stdout` (which would multiply rows
41 /// by ~50/day/PC of noise).
42 ///
43 /// Distinct from `inventory:` (single JSON object → projector
44 /// upsert) — events are append-only timeline points consumed
45 /// by the dedicated `obs_events` table.
46 #[serde(default)]
47 pub emit: Option<EmitConfig>,
48 /// v0.26: Layer 2 staleness policy (SPEC.md §2.6.2). Controls
49 /// what the agent does at fire time when it can't verify the
50 /// `script_current` / `script_status` KV values are fresh —
51 /// especially relevant for `runs_on: agent` schedules where
52 /// the agent may fire from cache while offline. Defaults to
53 /// `Staleness::Cached` (silently use cached values), which
54 /// matches every pre-v0.26 Manifest.
55 #[serde(default)]
56 pub staleness: Staleness,
57}
58
59/// "Who + how + when-to-stagger" — the fanout-plan side of an exec.
60/// Used both as the POST `/api/exec/{job_id}` body and as the embedded
61/// `target` / `rollout` / `jitter` slot on [`Schedule`]. Centralising
62/// here keeps the validation + serialisation logic in one place.
63#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
64pub struct FanoutPlan {
65 #[serde(default)]
66 pub target: Target,
67 /// Optional wave rollout — when present, the backend publishes
68 /// each wave's group subject on its own delay schedule instead
69 /// of fanning out the `target` block in one go. `target` then
70 /// only labels the deploy for the audit log.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub rollout: Option<Rollout>,
73 /// Optional humantime jitter; agent uses it to randomise
74 /// execution start. Lives here (not on the script) so different
75 /// schedules / ad-hoc fires of the same job can pick different
76 /// stagger windows.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub jitter: Option<String>,
79 /// Absolute time the scheduler stamps on each emitted Command
80 /// when this exec was driven by a [`Schedule`] with
81 /// `starting_deadline`. Agents receiving a Command after this
82 /// instant publish a synthetic skipped-result instead of
83 /// running the script. `None` (default) = no deadline / catch
84 /// up whenever delivered. Operators don't usually set this
85 /// directly — the scheduler computes it from `tick_at +
86 /// starting_deadline`.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
89}
90
91/// Manifest sub-section: how the SPA should render the inventory
92/// facts this job produces. Each field name (`field`) is a top-level
93/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
94///
95/// Two render modes:
96/// * `display` — vertical "field / value" per PC, used by the
97/// `/inventory?pc=<id>` detail view. ALL columns the operator
98/// wants visible on the detail page.
99/// * `summary` — horizontal table across the fleet (row = PC,
100/// column = field) on `/inventory`. Optional; when omitted the
101/// SPA falls back to `display`, but operators usually want a
102/// trimmer "hostname / OS / CPU / RAM" set for the fleet view.
103#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
104pub struct InventoryHint {
105 /// Detail-view columns, in order.
106 pub display: Vec<DisplayField>,
107 /// Optional fleet-list columns (row = PC). Defaults to `display`
108 /// when omitted, but operators usually pick a 3-5 column subset.
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub summary: Option<Vec<DisplayField>>,
111 /// v0.31 / #40: payload arrays that should be exploded into
112 /// per-element rows of a derived SQLite table. Lets operators
113 /// answer cross-PC questions ("which PCs still have Chrome <
114 /// 120?", "C: >90% full") with normal SQL filters + indexes
115 /// instead of grepping JSON. The projector creates the derived
116 /// table on register and replaces this PC's rows on each result
117 /// (DELETE WHERE pc_id=? AND job_id=? + bulk INSERT). See
118 /// [`ExplodeSpec`] for the per-spec schema.
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub explode: Option<Vec<ExplodeSpec>>,
121 /// v0.35 / #93: top-level scalar fields whose changes the
122 /// projector logs to `inventory_history` (one event per
123 /// changed field per scan). Pairs with `explode[].track_history`
124 /// — that covers array elements; this covers single-valued
125 /// fields like `ram_bytes` / `os_version` / `cpu_model` /
126 /// `os_build` that operators want to track for "did the RAM
127 /// get upgraded?" / "when did Win 11 land on this PC?" /
128 /// "BIOS / firmware bumped?" questions. Field name = `field_path`
129 /// in the history row, `identity_json` is NULL, `before_json`
130 /// / `after_json` each carry `{"value": <prior or new value>}`.
131 /// First-ever observation of a scalar (no prior facts row)
132 /// emits `added`; subsequent value changes emit `changed`. No
133 /// `removed` events — a scalar disappearing from the payload
134 /// is rare and the operator can still see the last value via
135 /// the `before_json` of the most recent change.
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub history_scalars: Option<Vec<String>>,
138}
139
140/// Issue #246 — `emit:` manifest block for jobs whose stdout is
141/// NDJSON observability events (one `ObsEvent` per line). Parallel
142/// to `inventory:` but for the append-only timeline pipeline; see
143/// `Manifest::emit` for the full contract.
144#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
145#[serde(deny_unknown_fields)]
146pub struct EmitConfig {
147 /// What kind of payload the agent should expect on stdout. Only
148 /// `events` is defined today (parses each non-empty line as
149 /// `ObsEvent` and publishes on `obs.<pc_id>`); future variants
150 /// (e.g. metrics streams, structured trace events) plug in here.
151 #[serde(rename = "type")]
152 pub kind: EmitKind,
153 /// Operator hint for where the script keeps its own state — the
154 /// watermark file the PowerShell / sh body reads + writes
155 /// between runs so it only emits NEW events since the last
156 /// poll. The agent doesn't read this; it's documentation that
157 /// the SPA (and `kanade job edit`) can surface to operators
158 /// reviewing the manifest. Optional; the script is allowed to
159 /// keep state anywhere (registry, env, etc.) — the field's
160 /// presence makes the convention discoverable.
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub watermark_path: Option<String>,
163}
164
165/// `emit.type` enum. Lowercase serde so manifests read
166/// `type: events` rather than `Events`.
167#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
168#[serde(rename_all = "lowercase")]
169pub enum EmitKind {
170 /// Per-line `ObsEvent` JSON. Agent parses + publishes on
171 /// `obs.<pc_id>`, drops the stdout from the resulting
172 /// `ExecResult`.
173 Events,
174}
175
176/// v0.31 / #40: declarative "flatten this JSON array into a real
177/// SQLite table" spec on an inventory manifest. The projector
178/// creates the table on first registration (CREATE TABLE IF NOT
179/// EXISTS + indexes) and writes a row per element of
180/// `payload[field]` on every result, scoped by (pc_id, job_id) so
181/// each PC's rows replace cleanly without a per-PC schema.
182#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
183pub struct ExplodeSpec {
184 /// JSON array key under the payload to explode. E.g. `"apps"`
185 /// for `payload: { apps: [{...}, {...}] }`.
186 pub field: String,
187 /// Derived SQLite table name. Operators choose this — pick
188 /// something namespaced + stable (`inventory_sw_apps`, not
189 /// `apps`) so multiple inventory manifests don't collide on a
190 /// generic name.
191 pub table: String,
192 /// Element-level fields that uniquely identify a row inside one
193 /// PC's payload. The full PK is `(pc_id, job_id) + these
194 /// columns`. Required — operators must think about uniqueness
195 /// (e.g. `["name", "source"]` for installed apps because the
196 /// same name appears in multiple uninstall hives).
197 ///
198 /// v0.31 / #41: same tuple drives history identity. When
199 /// `track_history` is on, the projector serialises these
200 /// fields' values into `inventory_history.identity_json` for
201 /// every change event, so queries like "every PC that ever
202 /// installed Chrome (any source)" filter on identity_json
203 /// content without a per-manifest schema.
204 pub primary_key: Vec<String>,
205 /// Per-element fields that become columns in the derived table.
206 pub columns: Vec<ExplodeColumn>,
207 /// v0.31 / #41: when true (default false), the projector
208 /// diffs each PC's incoming payload against the prior rows
209 /// for the same (pc_id, job_id) BEFORE the DELETE-then-INSERT
210 /// replace, and writes added / removed / changed events into
211 /// `inventory_history`. Lets operators answer time-dimension
212 /// questions ("when did Chrome 120 first appear on PC X?",
213 /// "what's the Win 11 23H2 rollout curve") without storing
214 /// per-scan snapshots. Off by default so operators opt in
215 /// per-spec — history has a real storage cost on long-lived
216 /// deployments (mitigated by the 90-day default retention
217 /// sweeper, see `cleanup` module).
218 #[serde(default)]
219 pub track_history: bool,
220}
221
222/// One column in an [`ExplodeSpec`]'s derived table.
223#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
224pub struct ExplodeColumn {
225 /// JSON key under each array element. Becomes the column name
226 /// in the derived SQLite table — we don't rename.
227 pub field: String,
228 /// SQLite affinity: `"text"` (default), `"integer"`, `"real"`.
229 /// Storage maps directly via `sqlx::query.bind(...)`; type
230 /// mismatches at INSERT-time fail loudly rather than silently
231 /// dropping the row.
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 #[serde(rename = "type")]
234 pub kind: Option<String>,
235 /// When true, the projector creates a `CREATE INDEX` on this
236 /// column at table-creation time. Boost for the common-filter
237 /// columns (`name`, `version`) — operators mark them
238 /// explicitly, the projector won't guess.
239 #[serde(default)]
240 pub index: bool,
241}
242
243#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
244pub struct DisplayField {
245 /// Top-level key in the stdout JSON.
246 pub field: String,
247 /// Human-readable column header.
248 pub label: String,
249 /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`,
250 /// or `"table"` (#39). Defaults to plain text rendering on the
251 /// SPA side. `"table"` expects the field's value to be a JSON
252 /// array of objects and renders a nested sub-table on the
253 /// per-PC detail page using `columns` as the schema; the fleet
254 /// summary view falls back to showing the row count for
255 /// `"table"` cells so the wide list stays compact.
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 #[serde(rename = "type")]
258 pub kind: Option<String>,
259 /// v0.30 / #39: when `kind == "table"`, the SPA renders the
260 /// field's value (an array of objects like
261 /// `disks: [{ device_id, size_bytes, ... }]`) as a nested
262 /// sub-table using these columns. Each column is itself a
263 /// `DisplayField`, so the nested cells reuse the same render
264 /// hints (`bytes`, `number`, `timestamp`) — no parallel format
265 /// pipeline. Ignored for any other `kind`.
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub columns: Option<Vec<DisplayField>>,
268}
269
270#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
271pub struct Rollout {
272 #[serde(default)]
273 pub strategy: RolloutStrategy,
274 pub waves: Vec<Wave>,
275}
276
277#[derive(
278 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
279)]
280#[serde(rename_all = "lowercase")]
281pub enum RolloutStrategy {
282 #[default]
283 Wave,
284}
285
286#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
287pub struct Wave {
288 pub group: String,
289 /// humantime delay measured from the deploy's publish time. wave[0]
290 /// typically has "0s"; subsequent waves use minutes / hours.
291 pub delay: String,
292}
293
294#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
295pub struct Target {
296 #[serde(default)]
297 pub groups: Vec<String>,
298 #[serde(default)]
299 pub pcs: Vec<String>,
300 #[serde(default)]
301 pub all: bool,
302}
303
304impl Target {
305 /// At least one of all / groups / pcs is set.
306 pub fn is_specified(&self) -> bool {
307 self.all || !self.groups.is_empty() || !self.pcs.is_empty()
308 }
309}
310
311#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
312#[serde(deny_unknown_fields)]
313pub struct Execute {
314 pub shell: ExecuteShell,
315 /// Inline script body. Mutually exclusive with [`script_file`]
316 /// and [`script_object`]; exactly one of the three must be set
317 /// (enforced by [`Execute::validate_script_source`] at the
318 /// write-side parse boundaries — `kanade job create` and
319 /// `POST /api/jobs`).
320 ///
321 /// Empty string is treated as **unset** so operators can swap
322 /// to a `script_file:` / `script_object:` alternative just by
323 /// commenting out the body, without having to also drop the
324 /// `script:` key entirely.
325 ///
326 /// [`script_file`]: Self::script_file
327 /// [`script_object`]: Self::script_object
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub script: Option<String>,
330 /// Repo-local file path resolved by the operator-side CLI at
331 /// `kanade job create` time. The CLI reads the file, slots its
332 /// contents into `script`, and clears this field before
333 /// POSTing — so the backend / agents never see `script_file`
334 /// in stored manifests. SPEC §2.4.1.
335 ///
336 /// Resolver lands in a follow-up PR
337 /// (yukimemi/kanade#210); today this field passes parse-time
338 /// validation but the operator-side CLI bails with "not yet
339 /// implemented" until the resolver ships, so manifests that
340 /// reach the backend with `script_file` set are treated as a
341 /// schema-bug.
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub script_file: Option<String>,
344 /// Object Store reference (`<name>/<version>`) into the
345 /// `scripts` bucket (`OBJECT_SCRIPTS`). Agents fetch the body
346 /// at Execute time via `/api/script-objects/{name}/{version}`
347 /// and cache it locally. SPEC §2.4.1.
348 ///
349 /// Resolver lands in the same follow-up PR as `script_file`;
350 /// today this field passes parse-time validation but the
351 /// backend / agent exec paths bail with "not yet implemented"
352 /// when they see it.
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub script_object: Option<String>,
355 /// humantime duration string (e.g. "30s", "10m"). Script-intrinsic
356 /// — represents how long this script reasonably takes to run.
357 pub timeout: String,
358 /// Token + session combination the agent uses to launch the
359 /// script (v0.21). Default = [`RunAs::System`] (Session 0,
360 /// LocalSystem privileges, no GUI) — matches pre-v0.21 behavior.
361 #[serde(default)]
362 pub run_as: RunAs,
363 /// Working directory for the spawned child (v0.21.1). When
364 /// unset, the child inherits the agent's cwd — on Windows that
365 /// means `%SystemRoot%\System32` for the prod service, which is
366 /// almost never what operators actually want. Use an absolute
367 /// path; relative paths are passed through to the OS verbatim.
368 /// `%PROGRAMDATA%` works for `run_as: system`; for `run_as: user`
369 /// you'd want `%USERPROFILE%` (but expansion happens in the
370 /// shell, so write `$env:USERPROFILE` for PowerShell, or set
371 /// it via teravars before `kanade job create`).
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub cwd: Option<String>,
374}
375
376impl Execute {
377 /// Treat an empty `script:` body as "intentionally unset". Operators
378 /// commenting out a block-scalar tend to leave the key behind, and
379 /// failing the validator on `script: ""` would surprise them.
380 fn has_inline_script(&self) -> bool {
381 matches!(&self.script, Some(s) if !s.is_empty())
382 }
383
384 /// Enforce that exactly one of `script` / `script_file` /
385 /// `script_object` is set. Called at the write-side parse
386 /// boundaries (CLI `kanade job create` + backend
387 /// `POST /api/jobs`) so ambiguous YAML is rejected before it
388 /// reaches the JOBS KV. Read paths (projector, agent
389 /// scheduler, list endpoints) skip this check — they only ever
390 /// see what the write path already validated.
391 pub fn validate_script_source(&self) -> Result<(), String> {
392 let inline = self.has_inline_script();
393 let file = self.script_file.is_some();
394 let obj = self.script_object.is_some();
395 let set = [inline, file, obj].into_iter().filter(|b| *b).count();
396 match set {
397 1 => Ok(()),
398 0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
399 _ => Err(format!(
400 "execute: only one of `script` / `script_file` / `script_object` may be set \
401 (got script={inline}, script_file={file}, script_object={obj})"
402 )),
403 }
404 }
405}
406
407impl Manifest {
408 /// Cross-field semantic checks that don't fit into pure serde
409 /// derive. Currently delegates to
410 /// [`Execute::validate_script_source`] — see that method's
411 /// docs for the rationale on which call sites should run this.
412 pub fn validate(&self) -> Result<(), String> {
413 self.execute.validate_script_source()?;
414 Ok(())
415 }
416}
417
418#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
419#[serde(rename_all = "lowercase")]
420pub enum ExecuteShell {
421 Powershell,
422 Cmd,
423}
424
425impl From<ExecuteShell> for Shell {
426 fn from(s: ExecuteShell) -> Self {
427 match s {
428 ExecuteShell::Powershell => Shell::Powershell,
429 ExecuteShell::Cmd => Shell::Cmd,
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn target_is_specified_requires_at_least_one_field() {
440 let empty = Target::default();
441 assert!(!empty.is_specified());
442
443 let with_all = Target {
444 all: true,
445 ..Target::default()
446 };
447 assert!(with_all.is_specified());
448
449 let with_groups = Target {
450 groups: vec!["canary".into()],
451 ..Target::default()
452 };
453 assert!(with_groups.is_specified());
454
455 let with_pcs = Target {
456 pcs: vec!["minipc".into()],
457 ..Target::default()
458 };
459 assert!(with_pcs.is_specified());
460 }
461
462 #[test]
463 fn manifest_deserialises_minimal_yaml() {
464 // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
465 // — those live on the schedule / exec request now.
466 let yaml = r#"
467id: echo-test
468version: 0.0.1
469execute:
470 shell: powershell
471 script: "echo 'kanade'"
472 timeout: 30s
473"#;
474 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
475 assert_eq!(m.id, "echo-test");
476 assert_eq!(m.version, "0.0.1");
477 assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
478 assert_eq!(
479 m.execute.script.as_deref().map(str::trim),
480 Some("echo 'kanade'")
481 );
482 assert!(m.execute.script_file.is_none());
483 assert!(m.execute.script_object.is_none());
484 assert_eq!(m.execute.timeout, "30s");
485 assert!(!m.require_approval);
486 m.validate()
487 .expect("inline-script manifest passes validation");
488 }
489
490 fn execute_with(
491 script: Option<&str>,
492 script_file: Option<&str>,
493 script_object: Option<&str>,
494 ) -> Execute {
495 Execute {
496 shell: ExecuteShell::Powershell,
497 script: script.map(str::to_owned),
498 script_file: script_file.map(str::to_owned),
499 script_object: script_object.map(str::to_owned),
500 timeout: "30s".into(),
501 run_as: RunAs::default(),
502 cwd: None,
503 }
504 }
505
506 #[test]
507 fn validate_accepts_inline_script() {
508 let e = execute_with(Some("echo hi"), None, None);
509 assert!(e.validate_script_source().is_ok());
510 }
511
512 #[test]
513 fn validate_accepts_script_file_alone() {
514 let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
515 assert!(e.validate_script_source().is_ok());
516 }
517
518 #[test]
519 fn validate_accepts_script_object_alone() {
520 let e = execute_with(None, None, Some("cleanup/1.0.0"));
521 assert!(e.validate_script_source().is_ok());
522 }
523
524 #[test]
525 fn validate_treats_empty_inline_script_as_unset() {
526 // `script: ""` + `script_object` set is the natural shape
527 // when an operator comments out the YAML block-scalar body
528 // but leaves the key. Should pass.
529 let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
530 assert!(e.validate_script_source().is_ok());
531 }
532
533 #[test]
534 fn validate_rejects_zero_sources() {
535 let e = execute_with(None, None, None);
536 let err = e.validate_script_source().unwrap_err();
537 assert!(err.contains("must be set"), "got: {err}");
538 }
539
540 #[test]
541 fn validate_rejects_empty_inline_only() {
542 let e = execute_with(Some(""), None, None);
543 let err = e.validate_script_source().unwrap_err();
544 assert!(err.contains("must be set"), "got: {err}");
545 }
546
547 #[test]
548 fn validate_rejects_inline_plus_file() {
549 let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
550 let err = e.validate_script_source().unwrap_err();
551 assert!(err.contains("only one of"), "got: {err}");
552 }
553
554 #[test]
555 fn validate_rejects_inline_plus_object() {
556 let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
557 let err = e.validate_script_source().unwrap_err();
558 assert!(err.contains("only one of"), "got: {err}");
559 }
560
561 #[test]
562 fn validate_rejects_file_plus_object() {
563 let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
564 let err = e.validate_script_source().unwrap_err();
565 assert!(err.contains("only one of"), "got: {err}");
566 }
567
568 #[test]
569 fn validate_rejects_all_three() {
570 let e = execute_with(
571 Some("echo hi"),
572 Some("scripts/cleanup.ps1"),
573 Some("cleanup/1.0.0"),
574 );
575 let err = e.validate_script_source().unwrap_err();
576 assert!(err.contains("only one of"), "got: {err}");
577 }
578
579 #[test]
580 fn manifest_deserialises_script_object_yaml() {
581 // SPEC §2.4.1 example shape with the Object Store
582 // reference picked over inline.
583 let yaml = r#"
584id: cleanup-disk-temp
585version: 1.0.1
586execute:
587 shell: powershell
588 script_object: cleanup-disk-temp/1.0.1
589 timeout: 600s
590"#;
591 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
592 assert_eq!(
593 m.execute.script_object.as_deref(),
594 Some("cleanup-disk-temp/1.0.1")
595 );
596 assert!(m.execute.script.is_none());
597 m.validate()
598 .expect("script_object-only manifest passes validation");
599 }
600
601 #[test]
602 fn manifest_rejects_typo_in_script_field_name() {
603 // `deny_unknown_fields` on Execute catches `script_objectt`
604 // and similar fat-fingers at parse time instead of letting
605 // them silently fall through to "all three unset".
606 let yaml = r#"
607id: typo
608version: 1.0.0
609execute:
610 shell: powershell
611 script_objectt: oops
612 timeout: 30s
613"#;
614 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
615 assert!(r.is_err(), "expected parse error, got {r:?}");
616 }
617
618 #[test]
619 fn schedule_carries_target_and_rollout() {
620 let yaml = r#"
621id: hourly-cleanup-canary
622cron: "0 0 * * * *"
623job_id: cleanup
624enabled: true
625target:
626 groups: [canary, wave1]
627jitter: 30s
628rollout:
629 strategy: wave
630 waves:
631 - { group: canary, delay: 0s }
632 - { group: wave1, delay: 5s }
633"#;
634 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
635 assert_eq!(s.id, "hourly-cleanup-canary");
636 assert_eq!(s.job_id, "cleanup");
637 assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
638 assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
639 let rollout = s.plan.rollout.expect("rollout present");
640 assert_eq!(rollout.waves.len(), 2);
641 assert_eq!(rollout.waves[0].group, "canary");
642 assert_eq!(rollout.waves[1].delay, "5s");
643 assert_eq!(rollout.strategy, RolloutStrategy::Wave);
644 }
645
646 #[test]
647 fn schedule_minimal_target_all() {
648 let yaml = r#"
649id: every-10s
650cron: "*/10 * * * * *"
651enabled: true
652job_id: scheduled-echo
653target: { all: true }
654"#;
655 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
656 assert_eq!(s.id, "every-10s");
657 assert_eq!(s.cron, "*/10 * * * * *");
658 assert!(s.enabled);
659 assert_eq!(s.job_id, "scheduled-echo");
660 assert!(s.plan.target.all);
661 assert!(s.plan.rollout.is_none());
662 assert!(s.plan.jitter.is_none());
663 }
664
665 #[test]
666 fn schedule_enabled_defaults_to_true() {
667 let yaml = r#"
668id: x
669cron: "* * * * * *"
670job_id: y
671target: { all: true }
672"#;
673 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
674 assert!(s.enabled);
675 }
676
677 #[test]
678 fn schedule_mode_defaults_to_every_tick() {
679 let yaml = r#"
680id: x
681cron: "* * * * * *"
682job_id: y
683target: { all: true }
684"#;
685 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
686 assert_eq!(s.mode, ExecMode::EveryTick);
687 assert!(s.cooldown.is_none());
688 assert!(!s.auto_disable_when_done);
689 }
690
691 #[test]
692 fn schedule_mode_serialises_snake_case() {
693 for (mode, expected) in [
694 (ExecMode::EveryTick, "every_tick"),
695 (ExecMode::OncePerPc, "once_per_pc"),
696 (ExecMode::OncePerTarget, "once_per_target"),
697 ] {
698 let s = serde_json::to_value(mode).expect("serialise");
699 assert_eq!(s, serde_json::Value::String(expected.into()));
700 let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
701 .expect("deserialise");
702 assert_eq!(back, mode, "round-trip for {expected}");
703 }
704 }
705
706 #[test]
707 fn schedule_kitting_yaml_parses() {
708 let yaml = r#"
709id: kitting-setup
710cron: "*/30 * * * * *"
711job_id: install-baseline
712target: { all: true }
713mode: once_per_pc
714"#;
715 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
716 assert_eq!(s.mode, ExecMode::OncePerPc);
717 assert!(s.cooldown.is_none());
718 assert!(!s.auto_disable_when_done);
719 }
720
721 #[test]
722 fn schedule_batch_campaign_yaml_parses() {
723 let yaml = r#"
724id: q3-patch-batch
725cron: "*/5 * * * * *"
726job_id: install-patch
727target:
728 pcs: [pc-001, pc-002, pc-003]
729mode: once_per_pc
730auto_disable_when_done: true
731"#;
732 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
733 assert_eq!(s.mode, ExecMode::OncePerPc);
734 assert!(s.cooldown.is_none());
735 assert!(s.auto_disable_when_done);
736 assert_eq!(s.plan.target.pcs.len(), 3);
737 }
738
739 #[test]
740 fn schedule_throttled_yaml_parses() {
741 let yaml = r#"
742id: daily-compliance
743cron: "*/5 * * * * *"
744job_id: check-av-status
745target: { all: true }
746mode: once_per_pc
747cooldown: 1d
748"#;
749 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
750 assert_eq!(s.mode, ExecMode::OncePerPc);
751 assert_eq!(s.cooldown.as_deref(), Some("1d"));
752 }
753
754 #[test]
755 fn schedule_runs_on_defaults_to_backend() {
756 let yaml = r#"
757id: x
758cron: "* * * * * *"
759job_id: y
760target: { all: true }
761"#;
762 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
763 assert_eq!(s.runs_on, RunsOn::Backend);
764 }
765
766 #[test]
767 fn schedule_runs_on_agent_parses() {
768 let yaml = r#"
769id: offline-inv
770cron: "0 0 * * * *"
771job_id: inventory-hw
772target: { all: true }
773runs_on: agent
774mode: once_per_pc
775"#;
776 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
777 assert_eq!(s.runs_on, RunsOn::Agent);
778 assert_eq!(s.mode, ExecMode::OncePerPc);
779 }
780
781 #[test]
782 fn runs_on_serialises_snake_case() {
783 for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
784 let s = serde_json::to_value(mode).expect("serialise");
785 assert_eq!(s, serde_json::Value::String(expected.into()));
786 let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
787 .expect("deserialise");
788 assert_eq!(back, mode);
789 }
790 }
791
792 #[test]
793 fn schedule_once_per_target_yaml_parses() {
794 let yaml = r#"
795id: license-checkin
796cron: "*/10 * * * * *"
797job_id: hit-license-server
798target: { all: true }
799mode: once_per_target
800cooldown: 24h
801"#;
802 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
803 assert_eq!(s.mode, ExecMode::OncePerTarget);
804 assert_eq!(s.cooldown.as_deref(), Some("24h"));
805 }
806
807 #[test]
808 fn execute_shell_into_wire_shell() {
809 assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
810 assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
811 }
812
813 #[test]
814 fn manifest_staleness_defaults_to_cached() {
815 let yaml = r#"
816id: x
817version: 1.0.0
818execute:
819 shell: powershell
820 script: "echo"
821 timeout: 1s
822"#;
823 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
824 assert_eq!(m.staleness, Staleness::Cached);
825 }
826
827 #[test]
828 fn manifest_strict_staleness_parses() {
829 let yaml = r#"
830id: urgent-patch
831version: 2.5.1
832execute:
833 shell: powershell
834 script: Install-Hotfix
835 timeout: 5m
836staleness:
837 mode: strict
838 max_cache_age: 0s
839"#;
840 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
841 match m.staleness {
842 Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
843 other => panic!("expected strict, got {other:?}"),
844 }
845 }
846
847 #[test]
848 fn manifest_unchecked_staleness_parses() {
849 let yaml = r#"
850id: legacy
851version: 0.1.0
852execute:
853 shell: cmd
854 script: "echo"
855 timeout: 1s
856staleness:
857 mode: unchecked
858"#;
859 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
860 assert_eq!(m.staleness, Staleness::Unchecked);
861 }
862
863 #[test]
864 fn missing_required_field_errors() {
865 // `id` missing.
866 let yaml = r#"
867version: 1.0.0
868target: { all: true }
869execute:
870 shell: powershell
871 script: "echo"
872 timeout: 1s
873"#;
874 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
875 assert!(r.is_err(), "expected error, got {:?}", r);
876 }
877
878 #[test]
879 fn display_field_table_kind_round_trips_with_nested_columns() {
880 // #39: `type: table` + `columns:` on a DisplayField gets
881 // round-tripped through serde so the SPA receives the
882 // nested schema verbatim. Nested columns themselves are
883 // DisplayFields so they can carry `type: bytes` /
884 // `type: number` for cell formatting.
885 let yaml = r#"
886id: inv-hw
887version: 1.0.0
888execute:
889 shell: powershell
890 script: "echo"
891 timeout: 60s
892inventory:
893 display:
894 - field: hostname
895 label: Hostname
896 - field: disks
897 label: Disks
898 type: table
899 columns:
900 - field: device_id
901 label: Drive
902 - field: size_bytes
903 label: Size
904 type: bytes
905 - field: free_bytes
906 label: Free
907 type: bytes
908 - field: file_system
909 label: FS
910"#;
911 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
912 let inv = m.inventory.as_ref().expect("inventory hint");
913 let disks = inv
914 .display
915 .iter()
916 .find(|d| d.field == "disks")
917 .expect("disks display row");
918 assert_eq!(disks.kind.as_deref(), Some("table"));
919 let cols = disks.columns.as_ref().expect("table needs columns");
920 assert_eq!(cols.len(), 4);
921 assert_eq!(cols[1].field, "size_bytes");
922 assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
923 }
924
925 #[test]
926 fn display_field_scalar_kind_keeps_columns_none() {
927 // Defensive: when type is a scalar (`bytes` / `number` /
928 // `timestamp`) the `columns` field stays None — the SPA
929 // uses its presence as the "render nested table" signal,
930 // so it must not leak in via serde defaults.
931 let yaml = r#"
932id: x
933version: 1.0.0
934execute:
935 shell: powershell
936 script: "echo"
937 timeout: 5s
938inventory:
939 display:
940 - { field: ram_bytes, label: RAM, type: bytes }
941"#;
942 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
943 let inv = m.inventory.as_ref().unwrap();
944 assert!(inv.display[0].columns.is_none());
945 }
946}
947
948/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
949/// (target + optional rollout + optional jitter) inline; the
950/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
951/// script body. Two schedules of the same job can target different
952/// groups on different cadences without copying the manifest.
953#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
954pub struct Schedule {
955 pub id: String,
956 /// 6-field cron expression (`sec min hour day month day-of-week`),
957 /// matching `tokio-cron-scheduler` syntax.
958 pub cron: String,
959 /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
960 /// Manifest's `id`.
961 pub job_id: String,
962 /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
963 /// carry these any more — same job + different fanout = different
964 /// schedule.
965 #[serde(flatten)]
966 pub plan: FanoutPlan,
967 /// Per-pc/per-target dedup semantics (v0.19). Default
968 /// `EveryTick` keeps the historical "fire every cron tick at the
969 /// whole target" behavior.
970 #[serde(default)]
971 pub mode: ExecMode,
972 /// Humantime cooldown for `OncePerPc` / `OncePerTarget`. Once a
973 /// pc/target has succeeded, the scheduler waits this long before
974 /// considering it eligible again. Omit for "succeed once, then
975 /// permanently skip" — i.e. cooldown = infinity.
976 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub cooldown: Option<String>,
978 /// When true AND the schedule's lifecycle is permanently
979 /// terminated (`cooldown = None` + dedup says nothing more to
980 /// do), the scheduler flips `enabled = false` and emits an
981 /// audit event. No-op when `cooldown` is set (re-arming
982 /// schedules never finish).
983 #[serde(default)]
984 pub auto_disable_when_done: bool,
985 /// v0.22: optional humantime window after a cron tick during
986 /// which the Command is still considered "live". The scheduler
987 /// computes `tick_at + starting_deadline` and stamps it onto
988 /// each Command as `deadline_at`; agents skip Commands they
989 /// receive after that absolute time. `None` (default) = no
990 /// deadline, meaning a Command queued in the broker / stream
991 /// during agent downtime runs whenever the agent reconnects —
992 /// good for kitting / inventory / cleanup. Set this for
993 /// time-of-day notifications, lunch reminders, etc., where
994 /// "fire 3 hours late" would be wrong.
995 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub starting_deadline: Option<String>,
997 /// v0.23: where does the cron tick happen? `Backend` (default,
998 /// historical) = backend's scheduler fires Commands via NATS;
999 /// agents passively receive. `Agent` = each targeted agent runs
1000 /// its own internal cron and fires locally, so the schedule
1001 /// keeps ticking even when the broker is unreachable (laptop on
1002 /// the train, broker maintenance window, full WAN outage). The
1003 /// two locations are mutually exclusive — when `Agent`, the
1004 /// backend scheduler stays out and just keeps the definition in
1005 /// KV for agents to read.
1006 #[serde(default)]
1007 pub runs_on: RunsOn,
1008 #[serde(default = "default_true")]
1009 pub enabled: bool,
1010}
1011
1012/// v0.23 — where the cron tick fires from.
1013#[derive(
1014 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
1015)]
1016#[serde(rename_all = "snake_case")]
1017pub enum RunsOn {
1018 /// Backend's central scheduler ticks and publishes Commands to
1019 /// NATS. Historical default, what every pre-v0.23 schedule
1020 /// uses. Agent offline ⇒ Command queued in STREAM_EXEC; agent
1021 /// reconnects ⇒ catch-up via [`command_replay`](crate)
1022 /// (see kanade-agent's command_replay module).
1023 #[default]
1024 Backend,
1025 /// Each targeted agent runs the cron tick locally. Survives
1026 /// broker / WAN outages. Best for laptops / mobile devices that
1027 /// roam off the corporate network. Agent must be online for the
1028 /// initial schedule + job-catalog pull, but once cached the
1029 /// agent fires the script standalone.
1030 Agent,
1031}
1032
1033/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
1034#[derive(
1035 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
1036)]
1037#[serde(rename_all = "snake_case")]
1038pub enum ExecMode {
1039 /// Fire on every cron tick at the whole target. Historical
1040 /// (pre-v0.19) behavior; no dedup.
1041 #[default]
1042 EveryTick,
1043 /// Fire at each pc until that pc succeeds; then skip it until
1044 /// the optional cooldown elapses (or forever if no cooldown).
1045 /// Use for kitting / first-boot / per-pc compliance checks.
1046 OncePerPc,
1047 /// Fire at the whole target until **any** pc succeeds; then
1048 /// skip the whole target until the optional cooldown elapses
1049 /// (or forever if no cooldown). Use for "one delegate is
1050 /// enough" tasks like license check-in.
1051 OncePerTarget,
1052}
1053
1054fn default_true() -> bool {
1055 true
1056}