Skip to main content

volant_protocol/
messages.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Messages exchanged over frames. Field names are part of the protocol.
3
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7/// Bumped when a message changes shape. Controller and agent refuse to talk across versions.
8pub const PROTOCOL_VERSION: u32 = 1;
9
10/// Controller to agent.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum ToAgent {
14    Hello {
15        protocol: u32,
16    },
17    /// Run these tasks in order. One batch at a time: the controller waits for `BatchDone`.
18    RunBatch {
19        id: u64,
20        tasks: Vec<Task>,
21    },
22    Cancel {
23        id: u64,
24    },
25}
26
27/// Agent to controller.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum FromAgent {
31    Ready {
32        protocol: u32,
33        version: String,
34        arch: String,
35    },
36    TaskResult {
37        batch: u64,
38        index: usize,
39        result: TaskResult,
40    },
41    BatchDone {
42        batch: u64,
43        outcome: BatchOutcome,
44    },
45    Log {
46        level: LogLevel,
47        message: String,
48    },
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum LogLevel {
54    Debug,
55    Info,
56    Warn,
57    Error,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(tag = "kind", rename_all = "snake_case")]
62pub enum BatchOutcome {
63    Completed,
64    /// The task at `at` failed without `ignore_errors`; later tasks did not run.
65    Failed {
66        at: usize,
67    },
68    /// Cancelled while the task at `at` was running or about to run.
69    Cancelled {
70        at: usize,
71    },
72}
73
74/// One task, fully resolved by the controller: no templates left.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct Task {
77    /// Module name as written in the playbook, short or fully qualified.
78    pub module: String,
79    #[serde(default)]
80    pub args: Map<String, Value>,
81    #[serde(default)]
82    pub ignore_errors: bool,
83}
84
85/// A module result, in the free-form shape Ansible modules return.
86#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
87#[serde(transparent)]
88pub struct TaskResult(pub Map<String, Value>);
89
90impl TaskResult {
91    pub fn flag(&self, key: &str) -> bool {
92        matches!(self.0.get(key), Some(Value::Bool(true)))
93    }
94
95    pub fn changed(&self) -> bool {
96        self.flag("changed")
97    }
98
99    pub fn skipped(&self) -> bool {
100        self.flag("skipped")
101    }
102
103    /// Ansible's rule: `failed` set, or an `rc` present and different from zero.
104    pub fn failed(&self) -> bool {
105        self.flag("failed")
106            || matches!(self.0.get("rc"), Some(Value::Number(n)) if n.as_i64() != Some(0))
107    }
108
109    pub fn failed_with(msg: impl Into<String>) -> Self {
110        let mut map = Map::new();
111        map.insert("failed".into(), Value::Bool(true));
112        map.insert("msg".into(), Value::String(msg.into()));
113        Self(map)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use serde_json::json;
121
122    #[test]
123    fn hello_has_a_snake_case_type_tag() {
124        let text = serde_json::to_string(&ToAgent::Hello {
125            protocol: PROTOCOL_VERSION,
126        })
127        .unwrap();
128        assert_eq!(text, r#"{"type":"hello","protocol":1}"#);
129    }
130
131    #[test]
132    fn run_batch_round_trips() {
133        let msg = ToAgent::RunBatch {
134            id: 7,
135            tasks: vec![Task {
136                module: "ansible.builtin.command".into(),
137                args: json!({"_raw_params": "echo hi"})
138                    .as_object()
139                    .unwrap()
140                    .clone(),
141                ignore_errors: true,
142            }],
143        };
144        let back: ToAgent = serde_json::from_slice(&serde_json::to_vec(&msg).unwrap()).unwrap();
145        assert_eq!(back, msg);
146    }
147
148    #[test]
149    fn task_args_and_ignore_errors_default() {
150        let task: Task = serde_json::from_str(r#"{"module":"raw"}"#).unwrap();
151        assert!(task.args.is_empty());
152        assert!(!task.ignore_errors);
153    }
154
155    #[test]
156    fn a_non_zero_rc_counts_as_failed() {
157        let ok = TaskResult(
158            json!({"rc": 0, "changed": true})
159                .as_object()
160                .unwrap()
161                .clone(),
162        );
163        let bad = TaskResult(json!({"rc": 2}).as_object().unwrap().clone());
164        let flagged = TaskResult(json!({"failed": true}).as_object().unwrap().clone());
165        assert!(!ok.failed() && ok.changed());
166        assert!(bad.failed() && !bad.changed());
167        assert!(flagged.failed());
168    }
169
170    #[test]
171    fn batch_outcome_carries_the_index() {
172        let text = serde_json::to_string(&FromAgent::BatchDone {
173            batch: 1,
174            outcome: BatchOutcome::Failed { at: 3 },
175        })
176        .unwrap();
177        assert_eq!(
178            text,
179            r#"{"type":"batch_done","batch":1,"outcome":{"kind":"failed","at":3}}"#
180        );
181    }
182
183    #[test]
184    fn failed_with_builds_the_ansible_shape() {
185        let r = TaskResult::failed_with("boom");
186        assert!(r.failed());
187        assert_eq!(r.0["msg"], "boom");
188    }
189}