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 std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value};
8
9/// Bumped when a message changes shape. Controller and agent refuse to talk across versions.
10///
11/// 3 added `Task.environment`: a task now carries the variables its module runs with, and an
12/// agent that does not know the field would run the command without them - which is the "accepted
13/// then ignored" shape this project refuses.
14pub const PROTOCOL_VERSION: u32 = 3;
15
16/// Controller to agent.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum ToAgent {
20    Hello {
21        protocol: u32,
22    },
23    /// Run these tasks in order. One batch at a time: the controller waits for `BatchDone`.
24    RunBatch {
25        id: u64,
26        tasks: Vec<Task>,
27    },
28    Cancel {
29        id: u64,
30    },
31}
32
33/// Agent to controller.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum FromAgent {
37    Ready {
38        protocol: u32,
39        version: String,
40        arch: String,
41    },
42    TaskResult {
43        batch: u64,
44        index: usize,
45        result: TaskResult,
46    },
47    BatchDone {
48        batch: u64,
49        outcome: BatchOutcome,
50    },
51    Log {
52        level: LogLevel,
53        message: String,
54    },
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum LogLevel {
60    Debug,
61    Info,
62    Warn,
63    Error,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(tag = "kind", rename_all = "snake_case")]
68pub enum BatchOutcome {
69    Completed,
70    /// The task at `at` failed without `ignore_errors`; later tasks did not run.
71    Failed {
72        at: usize,
73    },
74    /// Cancelled while the task at `at` was running or about to run.
75    Cancelled {
76        at: usize,
77    },
78}
79
80/// One task, fully resolved by the controller: no templates left.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct Task {
83    /// Module name as written in the playbook, short or fully qualified.
84    pub module: String,
85    #[serde(default)]
86    pub args: Map<String, Value>,
87    #[serde(default)]
88    pub ignore_errors: bool,
89    /// Seconds allowed for the module to run; the agent kills it past that (Ansible's `timeout`).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub timeout: Option<u64>,
92    /// Variables the module's process runs with, added to the ones the agent already has
93    /// (Ansible's `environment`). Resolved by the controller: the play's layer, the block's and
94    /// the task's are merged and rendered there, so the agent only sets what it is handed.
95    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96    pub environment: BTreeMap<String, String>,
97}
98
99/// A module result, in the free-form shape Ansible modules return.
100#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
101#[serde(transparent)]
102pub struct TaskResult(pub Map<String, Value>);
103
104impl TaskResult {
105    pub fn flag(&self, key: &str) -> bool {
106        matches!(self.0.get(key), Some(Value::Bool(true)))
107    }
108
109    pub fn changed(&self) -> bool {
110        self.flag("changed")
111    }
112
113    pub fn skipped(&self) -> bool {
114        self.flag("skipped")
115    }
116
117    /// Ansible's rule: a present `failed` (true or false) is the truth; `rc` only decides when
118    /// `failed` is absent. `failed_when: false` can rescue a non-zero `rc`.
119    pub fn failed(&self) -> bool {
120        match self.0.get("failed") {
121            Some(Value::Bool(b)) => *b,
122            _ => matches!(self.0.get("rc"), Some(Value::Number(n)) if n.as_i64() != Some(0)),
123        }
124    }
125
126    pub fn failed_with(msg: impl Into<String>) -> Self {
127        let mut map = Map::new();
128        map.insert("failed".into(), Value::Bool(true));
129        map.insert("msg".into(), Value::String(msg.into()));
130        Self(map)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use serde_json::json;
138
139    #[test]
140    fn hello_has_a_snake_case_type_tag() {
141        let text = serde_json::to_string(&ToAgent::Hello {
142            protocol: PROTOCOL_VERSION,
143        })
144        .unwrap();
145        assert_eq!(
146            text,
147            format!(r#"{{"type":"hello","protocol":{PROTOCOL_VERSION}}}"#)
148        );
149    }
150
151    #[test]
152    fn timeout_is_optional_and_absent_when_unset() {
153        let task: Task = serde_json::from_str(r#"{"module":"raw"}"#).unwrap();
154        assert_eq!(task.timeout, None);
155        assert!(!serde_json::to_string(&task).unwrap().contains("timeout"));
156        let task: Task = serde_json::from_str(r#"{"module":"raw","timeout":5}"#).unwrap();
157        assert_eq!(task.timeout, Some(5));
158    }
159
160    #[test]
161    fn run_batch_round_trips() {
162        let mut environment = BTreeMap::new();
163        environment.insert("PATH".into(), "/opt/bin".into());
164        let msg = ToAgent::RunBatch {
165            id: 7,
166            tasks: vec![Task {
167                module: "ansible.builtin.command".into(),
168                args: json!({"_raw_params": "echo hi"})
169                    .as_object()
170                    .unwrap()
171                    .clone(),
172                ignore_errors: true,
173                timeout: None,
174                environment,
175            }],
176        };
177        let back: ToAgent = serde_json::from_slice(&serde_json::to_vec(&msg).unwrap()).unwrap();
178        assert_eq!(back, msg);
179    }
180
181    #[test]
182    fn task_args_and_ignore_errors_default() {
183        let task: Task = serde_json::from_str(r#"{"module":"raw"}"#).unwrap();
184        assert!(task.args.is_empty());
185        assert!(!task.ignore_errors);
186    }
187
188    /// A task with nothing to add to the environment does not carry the field at all, and one
189    /// that does carries it as a map of strings.
190    ///
191    /// What would make this red: `environment` serialised when it is empty, which would put a
192    /// key on every task of every batch; or the field dropped from the shape, which is how the
193    /// controller would send an environment the agent never sets.
194    #[test]
195    fn the_environment_is_absent_when_empty_and_round_trips_when_set() {
196        let task: Task = serde_json::from_str(r#"{"module":"raw"}"#).unwrap();
197        assert!(task.environment.is_empty());
198        assert!(
199            !serde_json::to_string(&task)
200                .unwrap()
201                .contains("environment")
202        );
203        let task: Task =
204            serde_json::from_str(r#"{"module":"raw","environment":{"A":"1"}}"#).unwrap();
205        assert_eq!(task.environment["A"], "1");
206        assert!(serde_json::to_string(&task).unwrap().contains(r#""A":"1""#));
207    }
208
209    #[test]
210    fn a_non_zero_rc_counts_as_failed() {
211        let ok = TaskResult(
212            json!({"rc": 0, "changed": true})
213                .as_object()
214                .unwrap()
215                .clone(),
216        );
217        let bad = TaskResult(json!({"rc": 2}).as_object().unwrap().clone());
218        let flagged = TaskResult(json!({"failed": true}).as_object().unwrap().clone());
219        assert!(!ok.failed() && ok.changed());
220        assert!(bad.failed() && !bad.changed());
221        assert!(flagged.failed());
222    }
223
224    #[test]
225    fn an_explicit_failed_false_rescues_a_non_zero_rc() {
226        let rescued = TaskResult(
227            json!({"failed": false, "rc": 2})
228                .as_object()
229                .unwrap()
230                .clone(),
231        );
232        assert!(!rescued.failed());
233    }
234
235    #[test]
236    fn batch_outcome_carries_the_index() {
237        let text = serde_json::to_string(&FromAgent::BatchDone {
238            batch: 1,
239            outcome: BatchOutcome::Failed { at: 3 },
240        })
241        .unwrap();
242        assert_eq!(
243            text,
244            r#"{"type":"batch_done","batch":1,"outcome":{"kind":"failed","at":3}}"#
245        );
246    }
247
248    #[test]
249    fn failed_with_builds_the_ansible_shape() {
250        let r = TaskResult::failed_with("boom");
251        assert!(r.failed());
252        assert_eq!(r.0["msg"], "boom");
253    }
254}