Skip to main content

torrust_tracker_deployer_lib/adapters/tofu/
json_parser.rs

1//! JSON parsing utilities for `OpenTofu` command output
2//!
3//! This module provides the `OpenTofuJsonParser` which handles parsing of complex JSON
4//! responses from `OpenTofu` commands and converts them into structured Rust types.
5//!
6//! ## Key Features
7//!
8//! - Parsing `OpenTofu` output command JSON into instance information
9//! - IP address extraction from Terraform state outputs
10//! - Error handling for malformed or unexpected JSON structures
11//! - Type-safe conversion from JSON to Rust structs
12//! - Support for complex nested JSON structures from Terraform state
13//!
14//! The parser encapsulates all JSON handling logic and provides a clean interface
15//! for converting `OpenTofu` command output into usable data structures.
16
17use std::net::IpAddr;
18use std::str::FromStr;
19
20use serde_json::Value;
21use thiserror::Error;
22
23use super::client::InstanceInfo;
24
25/// Errors that can occur during `OpenTofu` JSON parsing
26#[derive(Error, Debug)]
27pub enum ParseError {
28    /// JSON deserialization failed
29    #[error("Failed to parse JSON: {message}")]
30    JsonError { message: String },
31
32    /// Required field is missing or has wrong type
33    #[error("Field error: {message}")]
34    FieldError { message: String },
35}
36
37/// A JSON parser for `OpenTofu` command outputs.
38///
39/// This parser handles the complex JSON structure returned by `OpenTofu` commands
40/// and converts them into structured Rust types. It encapsulates all the
41/// JSON parsing logic and can be unit tested independently.
42pub(crate) struct OpenTofuJsonParser;
43
44impl OpenTofuJsonParser {
45    /// Parse `instance_info` from `OpenTofu` JSON output
46    ///
47    /// # Arguments
48    ///
49    /// * `json_output` - JSON string from `tofu output -json` command
50    ///
51    /// # Returns
52    ///
53    /// * `Ok(InstanceInfo)` - Parsed instance information
54    /// * `Err(ParseError)` - Parsing error
55    ///
56    /// # Errors
57    ///
58    /// This function will return an error if:
59    /// * The JSON cannot be parsed
60    /// * The `instance_info` section is missing
61    /// * Required fields are missing or have wrong types
62    pub fn parse_instance_info(json_output: &str) -> Result<InstanceInfo, ParseError> {
63        let outputs: Value =
64            serde_json::from_str(json_output).map_err(|e| ParseError::JsonError {
65                message: format!("Failed to parse OpenTofu output as JSON: {e}"),
66            })?;
67
68        let instance_info_value = outputs
69            .get("instance_info")
70            .and_then(|v| v.get("value"))
71            .ok_or_else(|| ParseError::FieldError {
72                message: "instance_info section not found in OpenTofu outputs".to_string(),
73            })?;
74
75        let image = instance_info_value
76            .get("image")
77            .and_then(|v| v.as_str())
78            .ok_or_else(|| ParseError::FieldError {
79                message: "image field missing or not a string".to_string(),
80            })?
81            .to_string();
82
83        let ip_address_str = instance_info_value
84            .get("ip_address")
85            .and_then(|v| v.as_str())
86            .ok_or_else(|| ParseError::FieldError {
87                message: "ip_address field missing or not a string".to_string(),
88            })?;
89
90        let ip_address = IpAddr::from_str(ip_address_str).map_err(|e| ParseError::FieldError {
91            message: format!("ip_address field is not a valid IP address: {e}"),
92        })?;
93
94        let name = instance_info_value
95            .get("name")
96            .and_then(|v| v.as_str())
97            .ok_or_else(|| ParseError::FieldError {
98                message: "name field missing or not a string".to_string(),
99            })?
100            .to_string();
101
102        let status = instance_info_value
103            .get("status")
104            .and_then(|v| v.as_str())
105            .ok_or_else(|| ParseError::FieldError {
106                message: "status field missing or not a string".to_string(),
107            })?
108            .to_string();
109
110        Ok(InstanceInfo {
111            image,
112            ip_address,
113            name,
114            status,
115        })
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn it_should_parse_instance_info_from_valid_json() {
125        let json_output = r#"{
126            "instance_info": {
127                "value": {
128                    "image": "ubuntu:24.04",
129                    "ip_address": "10.140.190.68",
130                    "name": "torrust-tracker-vm",
131                    "status": "Running"
132                }
133            }
134        }"#;
135
136        let result = OpenTofuJsonParser::parse_instance_info(json_output).unwrap();
137
138        assert_eq!(result.image, "ubuntu:24.04");
139        assert_eq!(
140            result.ip_address,
141            IpAddr::from_str("10.140.190.68").unwrap()
142        );
143        assert_eq!(result.name, "torrust-tracker-vm");
144        assert_eq!(result.status, "Running");
145    }
146
147    #[test]
148    fn it_should_fail_with_invalid_json() {
149        let invalid_json = "not valid json";
150
151        let result = OpenTofuJsonParser::parse_instance_info(invalid_json);
152
153        assert!(result.is_err());
154        assert!(matches!(result.unwrap_err(), ParseError::JsonError { .. }));
155    }
156
157    #[test]
158    fn it_should_fail_when_instance_info_section_missing() {
159        let json_output = r#"{
160            "other_output": {
161                "value": "some value"
162            }
163        }"#;
164
165        let result = OpenTofuJsonParser::parse_instance_info(json_output);
166
167        assert!(result.is_err());
168        let error = result.unwrap_err();
169        assert!(matches!(error, ParseError::FieldError { .. }));
170        assert!(error
171            .to_string()
172            .contains("instance_info section not found"));
173    }
174
175    #[test]
176    fn it_should_fail_when_required_field_missing() {
177        let json_output = r#"{
178            "instance_info": {
179                "value": {
180                    "image": "ubuntu:24.04",
181                    "ip_address": "10.140.190.68",
182                    "name": "torrust-tracker-vm"
183                }
184            }
185        }"#;
186
187        let result = OpenTofuJsonParser::parse_instance_info(json_output);
188
189        assert!(result.is_err());
190        let error = result.unwrap_err();
191        assert!(matches!(error, ParseError::FieldError { .. }));
192        assert!(error.to_string().contains("status field missing"));
193    }
194
195    #[test]
196    fn it_should_fail_when_field_has_wrong_type() {
197        let json_output = r#"{
198            "instance_info": {
199                "value": {
200                    "image": 123,
201                    "ip_address": "10.140.190.68",
202                    "name": "torrust-tracker-vm",
203                    "status": "Running"
204                }
205            }
206        }"#;
207
208        let result = OpenTofuJsonParser::parse_instance_info(json_output);
209
210        assert!(result.is_err());
211        let error = result.unwrap_err();
212        assert!(matches!(error, ParseError::FieldError { .. }));
213        assert!(error
214            .to_string()
215            .contains("image field missing or not a string"));
216    }
217
218    #[test]
219    fn it_should_fail_when_ip_address_is_invalid() {
220        let json_output = r#"{
221            "instance_info": {
222                "value": {
223                    "image": "ubuntu:24.04",
224                    "ip_address": "invalid-ip-address",
225                    "name": "torrust-tracker-vm",
226                    "status": "Running"
227                }
228            }
229        }"#;
230
231        let result = OpenTofuJsonParser::parse_instance_info(json_output);
232
233        assert!(result.is_err());
234        let error = result.unwrap_err();
235        assert!(matches!(error, ParseError::FieldError { .. }));
236        assert!(error
237            .to_string()
238            .contains("ip_address field is not a valid IP address"));
239    }
240}