|
| 1 | +//! Connection Details View for Provision Command |
| 2 | +//! |
| 3 | +//! This module provides a view for rendering SSH connection details after |
| 4 | +//! successful infrastructure provisioning. |
| 5 | +
|
| 6 | +use std::net::IpAddr; |
| 7 | +use std::path::PathBuf; |
| 8 | + |
| 9 | +use crate::domain::environment::state::Provisioned; |
| 10 | +use crate::domain::environment::Environment; |
| 11 | + |
| 12 | +/// Connection details data for rendering |
| 13 | +/// |
| 14 | +/// This struct holds all the data needed to render SSH connection information |
| 15 | +/// for a provisioned instance. |
| 16 | +#[derive(Debug, Clone)] |
| 17 | +pub struct ConnectionDetailsData { |
| 18 | + /// Instance IP address (None if not available) |
| 19 | + pub instance_ip: Option<IpAddr>, |
| 20 | + /// SSH port for connections |
| 21 | + pub ssh_port: u16, |
| 22 | + /// Path to SSH private key |
| 23 | + pub ssh_priv_key_path: PathBuf, |
| 24 | + /// SSH username for connections |
| 25 | + pub ssh_username: String, |
| 26 | +} |
| 27 | + |
| 28 | +/// Conversion from domain model to presentation DTO |
| 29 | +/// |
| 30 | +/// This `From` trait implementation is placed in the presentation layer |
| 31 | +/// (not in the domain layer) to maintain proper DDD layering: |
| 32 | +/// |
| 33 | +/// - Domain layer should not depend on presentation layer DTOs |
| 34 | +/// - Presentation layer can depend on domain models (allowed) |
| 35 | +/// - This keeps the domain clean and focused on business logic |
| 36 | +/// |
| 37 | +/// Alternative approaches considered: |
| 38 | +/// - Adding method to `Environment<Provisioned>`: Would violate DDD by making |
| 39 | +/// domain depend on presentation DTOs |
| 40 | +/// - Keeping mapping in controller: Works but less idiomatic than `From` trait |
| 41 | +impl From<&Environment<Provisioned>> for ConnectionDetailsData { |
| 42 | + fn from(provisioned: &Environment<Provisioned>) -> Self { |
| 43 | + Self { |
| 44 | + instance_ip: provisioned.instance_ip(), |
| 45 | + ssh_port: provisioned.ssh_port(), |
| 46 | + ssh_priv_key_path: provisioned.ssh_private_key_path().clone(), |
| 47 | + ssh_username: provisioned.ssh_username().as_str().to_string(), |
| 48 | + } |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +/// View for rendering SSH connection details |
| 53 | +/// |
| 54 | +/// This view is responsible for formatting and rendering the connection |
| 55 | +/// information that users need to SSH into a provisioned instance. |
| 56 | +/// |
| 57 | +/// # Design |
| 58 | +/// |
| 59 | +/// Following MVC pattern, this view: |
| 60 | +/// - Receives data from the controller |
| 61 | +/// - Formats the output for display |
| 62 | +/// - Handles missing data gracefully |
| 63 | +/// - Returns a string ready for output to stdout |
| 64 | +/// |
| 65 | +/// # Examples |
| 66 | +/// |
| 67 | +/// ```rust |
| 68 | +/// use std::net::{IpAddr, Ipv4Addr}; |
| 69 | +/// use std::path::PathBuf; |
| 70 | +/// use torrust_tracker_deployer_lib::presentation::views::commands::provision::ConnectionDetailsView; |
| 71 | +/// use torrust_tracker_deployer_lib::presentation::views::commands::provision::connection_details::ConnectionDetailsData; |
| 72 | +/// |
| 73 | +/// let data = ConnectionDetailsData { |
| 74 | +/// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171))), |
| 75 | +/// ssh_port: 22, |
| 76 | +/// ssh_priv_key_path: PathBuf::from("fixtures/testing_rsa"), |
| 77 | +/// ssh_username: "torrust".to_string(), |
| 78 | +/// }; |
| 79 | +/// |
| 80 | +/// let output = ConnectionDetailsView::render(&data); |
| 81 | +/// assert!(output.contains("Instance Connection Details")); |
| 82 | +/// assert!(output.contains("10.140.190.171")); |
| 83 | +/// ``` |
| 84 | +pub struct ConnectionDetailsView; |
| 85 | + |
| 86 | +impl ConnectionDetailsView { |
| 87 | + /// Render connection details as a formatted string |
| 88 | + /// |
| 89 | + /// Takes connection data and produces a human-readable output suitable |
| 90 | + /// for displaying to users via stdout. |
| 91 | + /// |
| 92 | + /// # Arguments |
| 93 | + /// |
| 94 | + /// * `data` - Connection details to render |
| 95 | + /// |
| 96 | + /// # Returns |
| 97 | + /// |
| 98 | + /// A formatted string containing: |
| 99 | + /// - Section header |
| 100 | + /// - IP address (or warning if missing) |
| 101 | + /// - SSH port |
| 102 | + /// - SSH private key path (absolute - as provided) |
| 103 | + /// - SSH username |
| 104 | + /// - Ready-to-copy SSH command (if IP is available) |
| 105 | + /// |
| 106 | + /// # Missing IP Handling |
| 107 | + /// |
| 108 | + /// If the instance IP is not available (which should not happen after |
| 109 | + /// successful provisioning), the view displays a warning message and |
| 110 | + /// omits the SSH connection command. |
| 111 | + /// |
| 112 | + /// # Path Handling |
| 113 | + /// |
| 114 | + /// SSH private key paths are expected to be absolute paths and are |
| 115 | + /// displayed as-is without further resolution. |
| 116 | + /// |
| 117 | + /// # Examples |
| 118 | + /// |
| 119 | + /// ```rust |
| 120 | + /// use std::net::{IpAddr, Ipv4Addr}; |
| 121 | + /// use std::path::PathBuf; |
| 122 | + /// use torrust_tracker_deployer_lib::presentation::views::commands::provision::ConnectionDetailsView; |
| 123 | + /// use torrust_tracker_deployer_lib::presentation::views::commands::provision::connection_details::ConnectionDetailsData; |
| 124 | + /// |
| 125 | + /// let data = ConnectionDetailsData { |
| 126 | + /// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))), |
| 127 | + /// ssh_port: 2222, |
| 128 | + /// ssh_priv_key_path: PathBuf::from("/home/user/.ssh/deploy_key"), |
| 129 | + /// ssh_username: "admin".to_string(), |
| 130 | + /// }; |
| 131 | + /// |
| 132 | + /// let output = ConnectionDetailsView::render(&data); |
| 133 | + /// assert!(output.contains("192.168.1.100")); |
| 134 | + /// assert!(output.contains("2222")); |
| 135 | + /// assert!(output.contains("admin")); |
| 136 | + /// ``` |
| 137 | + #[must_use] |
| 138 | + pub fn render(data: &ConnectionDetailsData) -> String { |
| 139 | + match data.instance_ip { |
| 140 | + Some(ip) => format!( |
| 141 | + "\nInstance Connection Details:\n\ |
| 142 | + \x20 IP Address: {}\n\ |
| 143 | + \x20 SSH Port: {}\n\ |
| 144 | + \x20 SSH Private Key: {}\n\ |
| 145 | + \x20 SSH Username: {}\n\ |
| 146 | + \n\ |
| 147 | + Connect using:\n\ |
| 148 | + \x20 ssh -i {} {}@{} -p {}", |
| 149 | + ip, |
| 150 | + data.ssh_port, |
| 151 | + data.ssh_priv_key_path.display(), |
| 152 | + data.ssh_username, |
| 153 | + data.ssh_priv_key_path.display(), |
| 154 | + data.ssh_username, |
| 155 | + ip, |
| 156 | + data.ssh_port |
| 157 | + ), |
| 158 | + None => format!( |
| 159 | + "\nInstance Connection Details:\n\ |
| 160 | + \x20 IP Address: <not available>\n\ |
| 161 | + \x20 WARNING: Instance IP not captured - this is an unexpected state.\n\ |
| 162 | + \x20 The environment may not be fully provisioned.\n\ |
| 163 | + \x20 SSH Port: {}\n\ |
| 164 | + \x20 SSH Private Key: {}\n\ |
| 165 | + \x20 SSH Username: {}", |
| 166 | + data.ssh_port, |
| 167 | + data.ssh_priv_key_path.display(), |
| 168 | + data.ssh_username |
| 169 | + ), |
| 170 | + } |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +#[cfg(test)] |
| 175 | +mod tests { |
| 176 | + use super::*; |
| 177 | + use std::net::{IpAddr, Ipv4Addr}; |
| 178 | + |
| 179 | + #[test] |
| 180 | + fn it_should_render_complete_connection_details_when_ip_is_available() { |
| 181 | + let data = ConnectionDetailsData { |
| 182 | + instance_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171))), |
| 183 | + ssh_port: 22, |
| 184 | + ssh_priv_key_path: PathBuf::from("fixtures/testing_rsa"), |
| 185 | + ssh_username: "torrust".to_string(), |
| 186 | + }; |
| 187 | + |
| 188 | + let output = ConnectionDetailsView::render(&data); |
| 189 | + |
| 190 | + assert!(output.contains("Instance Connection Details")); |
| 191 | + assert!(output.contains("10.140.190.171")); |
| 192 | + assert!(output.contains("SSH Port: 22")); |
| 193 | + assert!(output.contains("SSH Username: torrust")); |
| 194 | + assert!(output.contains("Connect using:")); |
| 195 | + assert!(output.contains("ssh -i")); |
| 196 | + } |
| 197 | + |
| 198 | + #[test] |
| 199 | + fn it_should_render_warning_when_ip_is_missing() { |
| 200 | + let data = ConnectionDetailsData { |
| 201 | + instance_ip: None, |
| 202 | + ssh_port: 22, |
| 203 | + ssh_priv_key_path: PathBuf::from("fixtures/testing_rsa"), |
| 204 | + ssh_username: "torrust".to_string(), |
| 205 | + }; |
| 206 | + |
| 207 | + let output = ConnectionDetailsView::render(&data); |
| 208 | + |
| 209 | + assert!(output.contains("Instance Connection Details")); |
| 210 | + assert!(output.contains("<not available>")); |
| 211 | + assert!(output.contains("WARNING")); |
| 212 | + assert!(output.contains("unexpected state")); |
| 213 | + assert!(!output.contains("Connect using:")); |
| 214 | + } |
| 215 | + |
| 216 | + #[test] |
| 217 | + fn it_should_display_custom_ssh_port() { |
| 218 | + let data = ConnectionDetailsData { |
| 219 | + instance_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))), |
| 220 | + ssh_port: 2222, |
| 221 | + ssh_priv_key_path: PathBuf::from("/home/user/.ssh/key"), |
| 222 | + ssh_username: "admin".to_string(), |
| 223 | + }; |
| 224 | + |
| 225 | + let output = ConnectionDetailsView::render(&data); |
| 226 | + |
| 227 | + assert!(output.contains("SSH Port: 2222")); |
| 228 | + assert!(output.contains("-p 2222")); |
| 229 | + } |
| 230 | + |
| 231 | + #[test] |
| 232 | + fn it_should_include_absolute_path_in_output() { |
| 233 | + let data = ConnectionDetailsData { |
| 234 | + instance_ip: Some(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))), |
| 235 | + ssh_port: 22, |
| 236 | + ssh_priv_key_path: PathBuf::from("/absolute/path/to/key"), |
| 237 | + ssh_username: "user".to_string(), |
| 238 | + }; |
| 239 | + |
| 240 | + let output = ConnectionDetailsView::render(&data); |
| 241 | + |
| 242 | + // Should contain the path as provided |
| 243 | + assert!(output.contains("SSH Private Key:")); |
| 244 | + assert!(output.contains("/absolute/path/to/key")); |
| 245 | + } |
| 246 | + |
| 247 | + #[test] |
| 248 | + fn it_should_preserve_absolute_paths() { |
| 249 | + let data = ConnectionDetailsData { |
| 250 | + instance_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), |
| 251 | + ssh_port: 22, |
| 252 | + ssh_priv_key_path: PathBuf::from("/absolute/path/to/key"), |
| 253 | + ssh_username: "deploy".to_string(), |
| 254 | + }; |
| 255 | + |
| 256 | + let output = ConnectionDetailsView::render(&data); |
| 257 | + |
| 258 | + assert!(output.contains("/absolute/path/to/key")); |
| 259 | + } |
| 260 | +} |
0 commit comments