diff --git a/src/presentation/controllers/provision/handler.rs b/src/presentation/controllers/provision/handler.rs index ef97d21e6..f33b91445 100644 --- a/src/presentation/controllers/provision/handler.rs +++ b/src/presentation/controllers/provision/handler.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use parking_lot::ReentrantMutex; +use crate::application::command_handlers::show::info::ServiceInfo; use crate::application::command_handlers::ProvisionCommandHandler; use crate::domain::environment::name::EnvironmentName; use crate::domain::environment::repository::EnvironmentRepository; @@ -16,6 +17,9 @@ use crate::domain::environment::Environment; use crate::presentation::views::commands::provision::connection_details::{ ConnectionDetailsData, ConnectionDetailsView, }; +use crate::presentation::views::commands::provision::dns_reminder::{ + DnsReminderData, DnsReminderView, +}; use crate::presentation::views::progress::ProgressReporter; use crate::presentation::views::UserOutput; use crate::shared::clock::Clock; @@ -104,6 +108,8 @@ impl ProvisionCommandController { /// 3. Create command handler /// 4. Provision infrastructure /// 5. Complete with success message + /// 6. Display connection details + /// 7. Display DNS setup reminder (if domains are configured) /// /// # Arguments /// @@ -136,6 +142,8 @@ impl ProvisionCommandController { self.display_connection_details(&provisioned)?; + self.display_dns_reminder(&provisioned)?; + Ok(provisioned) } @@ -265,6 +273,72 @@ impl ProvisionCommandController { Ok(()) } + + /// Display DNS setup reminder after successful provisioning + /// + /// Orchestrates a functional pipeline to display DNS configuration reminder: + /// `Environment` → extract domains → `DnsReminderData` → `String` → stdout + /// + /// Only displays the reminder if domains are actually configured in the environment. + /// The output is written to stdout (not stderr) as it represents the final + /// command result rather than progress information. + /// + /// # MVC Architecture + /// + /// Following the MVC pattern with functional composition: + /// - Model: `Environment` (domain model) + /// - Extract: Domain information from `ServiceInfo` + /// - DTO: `DnsReminderData` (data transformation) + /// - View: `DnsReminderView::render()` (formatting) + /// - Controller (this method): Orchestrates the pipeline + /// - Output: `ProgressReporter::result()` (routing to stdout) + /// + /// # Arguments + /// + /// * `provisioned` - The provisioned environment containing service configuration + /// + /// # Errors + /// + /// Returns `ProvisionSubcommandError` if: + /// - Instance IP is not available (required for DNS reminder) + /// - The `ProgressReporter` encounters a mutex error while writing to stdout + #[allow(clippy::result_large_err)] + fn display_dns_reminder( + &mut self, + provisioned: &Environment, + ) -> Result<(), ProvisionSubcommandError> { + // Extract service information from the provisioned environment + let instance_ip = provisioned.instance_ip(); + let tracker_config = provisioned.tracker_config(); + let grafana_config = provisioned.grafana_config(); + + // Early return if no IP is available (shouldn't happen after provisioning) + let Some(ip) = instance_ip else { + return Ok(()); + }; + + // Build service info to extract domains + let services = ServiceInfo::from_tracker_config(tracker_config, ip, grafana_config); + + // Extract all domains from service configuration + let domains = DnsReminderView::extract_all_domains(&services); + + // Only display reminder if domains are configured + if domains.is_empty() { + return Ok(()); + } + + // Pipeline: domains → DnsReminderData → render → output to stdout + let reminder_data = DnsReminderData { + instance_ip: ip, + domains, + }; + + self.progress + .result(&DnsReminderView::render(&reminder_data))?; + + Ok(()) + } } #[cfg(test)] diff --git a/src/presentation/views/commands/provision/dns_reminder.rs b/src/presentation/views/commands/provision/dns_reminder.rs new file mode 100644 index 000000000..1f3ff0bd5 --- /dev/null +++ b/src/presentation/views/commands/provision/dns_reminder.rs @@ -0,0 +1,277 @@ +//! DNS Setup Reminder View for Provision Command +//! +//! This module provides a view for rendering DNS setup reminders after +//! successful infrastructure provisioning when domains are configured. + +use std::fmt::Write; +use std::net::IpAddr; + +use crate::application::command_handlers::show::info::ServiceInfo; + +/// DNS reminder data for rendering +/// +/// This struct holds all the data needed to render DNS setup reminders +/// for a provisioned instance with configured domains. +#[derive(Debug, Clone)] +pub struct DnsReminderData { + /// Instance IP address + pub instance_ip: IpAddr, + /// List of all configured domains + pub domains: Vec, +} + +/// View for rendering DNS setup reminders +/// +/// This view is responsible for formatting and rendering DNS setup information +/// that users need to configure after provisioning when domains are used. +/// +/// # Design +/// +/// Following MVC pattern, this view: +/// - Receives data from the controller +/// - Formats the output for display +/// - Only displays when domains are actually configured +/// - Returns a string ready for output to stdout +/// +/// # Examples +/// +/// ```rust +/// use std::net::{IpAddr, Ipv4Addr}; +/// use torrust_tracker_deployer_lib::presentation::views::commands::provision::dns_reminder::DnsReminderData; +/// use torrust_tracker_deployer_lib::presentation::views::commands::provision::DnsReminderView; +/// +/// let data = DnsReminderData { +/// instance_ip: IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), +/// domains: vec![ +/// "http.tracker.example.com".to_string(), +/// "api.tracker.example.com".to_string(), +/// "grafana.example.com".to_string(), +/// ], +/// }; +/// +/// let output = DnsReminderView::render(&data); +/// assert!(output.contains("DNS Setup Required")); +/// assert!(output.contains("http.tracker.example.com")); +/// ``` +pub struct DnsReminderView; + +impl DnsReminderView { + /// Render DNS setup reminder as a formatted string + /// + /// Takes DNS reminder data and produces a human-readable output suitable + /// for displaying to users via stdout. + /// + /// # Arguments + /// + /// * `data` - DNS reminder data to render + /// + /// # Returns + /// + /// A formatted string containing: + /// - Warning icon and header + /// - Explanation message + /// - Server IP address + /// - List of all configured domains + /// + /// # Examples + /// + /// ```rust + /// use std::net::{IpAddr, Ipv4Addr}; + /// use torrust_tracker_deployer_lib::presentation::views::commands::provision::dns_reminder::DnsReminderData; + /// use torrust_tracker_deployer_lib::presentation::views::commands::provision::DnsReminderView; + /// + /// let data = DnsReminderData { + /// instance_ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), + /// domains: vec![ + /// "tracker.example.com".to_string(), + /// ], + /// }; + /// + /// let output = DnsReminderView::render(&data); + /// assert!(output.contains("192.168.1.100")); + /// assert!(output.contains("tracker.example.com")); + /// ``` + #[must_use] + pub fn render(data: &DnsReminderData) -> String { + let mut output = String::new(); + + output.push_str("\n⚠️ DNS Setup Required:\n"); + output.push_str( + " Your configuration uses custom domains. Remember to update your DNS records\n", + ); + let _ = writeln!( + output, + " to point your domains to the server IP: {}", + data.instance_ip + ); + output.push_str("\n Configured domains:\n"); + + for domain in &data.domains { + let _ = writeln!(output, " - {domain}"); + } + + output + } + + /// Extract all domains from `ServiceInfo` + /// + /// This helper method collects all unique domains from the service configuration, + /// including domains from HTTP trackers, API, health check, and Grafana. + /// + /// # Arguments + /// + /// * `services` - Service information containing domain configuration + /// + /// # Returns + /// + /// A vector of unique domain names, or empty vector if no domains are configured. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{ServiceInfo, TlsDomainInfo}; + /// use torrust_tracker_deployer_lib::presentation::views::commands::provision::DnsReminderView; + /// + /// let services = ServiceInfo::new( + /// vec![], + /// vec!["https://http.tracker.local/announce".to_string()], + /// vec![], + /// vec![], + /// "https://api.tracker.local/api".to_string(), + /// true, + /// false, + /// "https://health.tracker.local/health_check".to_string(), + /// true, + /// false, + /// vec![ + /// TlsDomainInfo::new("http.tracker.local".to_string(), 7070), + /// TlsDomainInfo::new("api.tracker.local".to_string(), 1212), + /// TlsDomainInfo::new("health.tracker.local".to_string(), 1313), + /// TlsDomainInfo::new("grafana.tracker.local".to_string(), 3000), + /// ], + /// ); + /// + /// let domains = DnsReminderView::extract_all_domains(&services); + /// assert_eq!(domains.len(), 4); + /// assert!(domains.contains(&"http.tracker.local".to_string())); + /// assert!(domains.contains(&"api.tracker.local".to_string())); + /// ``` + #[must_use] + pub fn extract_all_domains(services: &ServiceInfo) -> Vec { + // Currently, ServiceInfo only tracks TLS domains + // This returns all domain names from tls_domains + services + .tls_domain_names() + .iter() + .map(|s| (*s).to_string()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + use crate::application::command_handlers::show::info::TlsDomainInfo; + + #[test] + fn it_should_render_dns_reminder_with_single_domain() { + let data = DnsReminderData { + instance_ip: IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), + domains: vec!["tracker.example.com".to_string()], + }; + + let output = DnsReminderView::render(&data); + + assert!(output.contains("⚠️ DNS Setup Required:")); + assert!(output.contains("10.140.190.171")); + assert!(output.contains("tracker.example.com")); + assert!(output.contains("Configured domains:")); + } + + #[test] + fn it_should_render_dns_reminder_with_multiple_domains() { + let data = DnsReminderData { + instance_ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), + domains: vec![ + "http.tracker.example.com".to_string(), + "api.tracker.example.com".to_string(), + "grafana.example.com".to_string(), + ], + }; + + let output = DnsReminderView::render(&data); + + assert!(output.contains("DNS Setup Required")); + assert!(output.contains("192.168.1.100")); + assert!(output.contains("http.tracker.example.com")); + assert!(output.contains("api.tracker.example.com")); + assert!(output.contains("grafana.example.com")); + } + + #[test] + fn it_should_extract_all_domains_from_service_info() { + let services = ServiceInfo::new( + vec![], + vec!["https://http.tracker.local/announce".to_string()], + vec![], + vec![], + "https://api.tracker.local/api".to_string(), + true, + false, + "https://health.tracker.local/health_check".to_string(), + true, + false, + vec![ + TlsDomainInfo::new("http.tracker.local".to_string(), 7070), + TlsDomainInfo::new("api.tracker.local".to_string(), 1212), + TlsDomainInfo::new("health.tracker.local".to_string(), 1313), + ], + ); + + let domains = DnsReminderView::extract_all_domains(&services); + + assert_eq!(domains.len(), 3); + assert!(domains.contains(&"http.tracker.local".to_string())); + assert!(domains.contains(&"api.tracker.local".to_string())); + assert!(domains.contains(&"health.tracker.local".to_string())); + } + + #[test] + fn it_should_return_empty_vec_when_no_domains_configured() { + let services = ServiceInfo::new( + vec!["udp://10.0.0.1:6969/announce".to_string()], + vec![], + vec!["http://10.0.0.1:7070/announce".to_string()], // DevSkim: ignore DS137138 + vec![], + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, + vec![], // No TLS domains + ); + + let domains = DnsReminderView::extract_all_domains(&services); + + assert!(domains.is_empty()); + } + + #[test] + fn it_should_format_output_with_proper_indentation() { + let data = DnsReminderData { + instance_ip: IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)), + domains: vec!["example.com".to_string()], + }; + + let output = DnsReminderView::render(&data); + + // Check for proper indentation and formatting + assert!(output.contains(" Your configuration")); + assert!(output.contains(" to point")); + assert!(output.contains(" Configured domains:")); + assert!(output.contains(" - example.com")); + } +} diff --git a/src/presentation/views/commands/provision/mod.rs b/src/presentation/views/commands/provision/mod.rs index e157bfe55..356492a5f 100644 --- a/src/presentation/views/commands/provision/mod.rs +++ b/src/presentation/views/commands/provision/mod.rs @@ -3,5 +3,7 @@ //! This module contains view components for rendering provision command output. pub mod connection_details; +pub mod dns_reminder; pub use connection_details::ConnectionDetailsView; +pub use dns_reminder::DnsReminderView;