1use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value};
8
9pub const PROTOCOL_VERSION: u32 = 3;
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum ToAgent {
20 Hello {
21 protocol: u32,
22 },
23 RunBatch {
25 id: u64,
26 tasks: Vec<Task>,
27 },
28 Cancel {
29 id: u64,
30 },
31}
32
33#[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 Failed {
72 at: usize,
73 },
74 Cancelled {
76 at: usize,
77 },
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct Task {
83 pub module: String,
85 #[serde(default)]
86 pub args: Map<String, Value>,
87 #[serde(default)]
88 pub ignore_errors: bool,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub timeout: Option<u64>,
92 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96 pub environment: BTreeMap<String, String>,
97}
98
99#[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 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 #[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}