diff --git a/docs/user-guide/commands/show.md b/docs/user-guide/commands/show.md index 6fe83b0c1..51416b122 100644 --- a/docs/user-guide/commands/show.md +++ b/docs/user-guide/commands/show.md @@ -9,13 +9,17 @@ Provides a quick, read-only view of environment details including state, infrast ## Command Syntax ```bash -torrust-tracker-deployer show +torrust-tracker-deployer show [OPTIONS] ``` ## Arguments - `` (required) - Name of the environment to display +## Options + +- `-o, --output-format ` (optional) - Output format: `text` (default) or `json` + ## Prerequisites 1. **Environment exists** - Must create environment first with `create environment` @@ -91,6 +95,96 @@ Tracker Services: Tracker is running! Use the URLs above to connect. ``` +## Output Formats + +The `show` command supports two output formats: + +### Text Format (Default) + +Human-readable format suitable for terminal viewing: + +```bash +torrust-tracker-deployer show my-environment +# or explicitly: +torrust-tracker-deployer show my-environment --output-format text +``` + +### JSON Format + +Machine-readable format for automation and scripting: + +```bash +torrust-tracker-deployer show my-environment --output-format json +``` + +#### JSON Output for Provisioned State + +```json +{ + "name": "my-environment", + "state": "Provisioned", + "provider": "LXD", + "created_at": "2026-02-16T17:56:43.788700279Z", + "infrastructure": { + "instance_ip": "10.140.190.85", + "ssh_port": 22, + "ssh_user": "torrust", + "ssh_key_path": "/home/user/.ssh/torrust_key" + }, + "services": null, + "prometheus": null, + "grafana": null, + "state_name": "provisioned" +} +``` + +#### JSON Output for Running State + +```json +{ + "name": "my-environment", + "state": "Running", + "provider": "LXD", + "created_at": "2026-02-11T09:52:28.800407753Z", + "infrastructure": { + "instance_ip": "10.140.190.36", + "ssh_port": 22, + "ssh_user": "torrust", + "ssh_key_path": "/home/user/.ssh/torrust_key" + }, + "services": { + "udp_trackers": ["udp://udp.tracker.local:6969/announce"], + "https_http_trackers": ["https://http.tracker.local/announce"], + "direct_http_trackers": [], + "localhost_http_trackers": [], + "api_endpoint": "https://api.tracker.local/api", + "api_uses_https": true, + "api_is_localhost_only": false, + "health_check_url": "https://health.tracker.local/health_check", + "health_check_uses_https": true, + "health_check_is_localhost_only": false, + "tls_domains": [ + { + "domain": "http.tracker.local", + "internal_port": 7070 + }, + { + "domain": "api.tracker.local", + "internal_port": 1212 + } + ] + }, + "prometheus": { + "access_note": "Internal only (localhost:9090) - not exposed externally" + }, + "grafana": { + "url": "https://grafana.tracker.local/", + "uses_https": true + }, + "state_name": "running" +} +``` + ## Examples ### Basic usage @@ -113,6 +207,37 @@ else fi ``` +### Parse JSON output for automation + +```bash +#!/bin/bash +# Extract tracker URL from environment +API_URL=$(torrust-tracker-deployer show my-env -o json | \ + jq -r '.services.api_endpoint // empty') + +if [ -n "$API_URL" ]; then + echo "API available at: $API_URL" + curl "$API_URL/stats" +else + echo "Service not yet running" +fi +``` + +### Monitor environment state + +```bash +#!/bin/bash +# Check if environment is fully running +STATE=$(torrust-tracker-deployer show my-env -o json | \ + jq -r '.state_name') + +if [ "$STATE" = "running" ]; then + echo "✓ Environment is fully operational" +else + echo "⚠ Environment is in '$STATE' state" +fi +``` + ### Quick reference for SSH connection ```bash diff --git a/src/application/command_handlers/show/info/grafana.rs b/src/application/command_handlers/show/info/grafana.rs index b0ed041e6..4d2e4e968 100644 --- a/src/application/command_handlers/show/info/grafana.rs +++ b/src/application/command_handlers/show/info/grafana.rs @@ -4,6 +4,7 @@ use std::net::IpAddr; +use serde::Serialize; use url::Url; use crate::domain::grafana::GrafanaConfig; @@ -13,7 +14,7 @@ use crate::domain::grafana::GrafanaConfig; /// This information shows the status of the Grafana service when configured. /// Grafana provides dashboards for visualizing tracker metrics. /// Note: Grafana requires Prometheus to be configured. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct GrafanaInfo { /// Grafana dashboard URL pub url: Url, diff --git a/src/application/command_handlers/show/info/mod.rs b/src/application/command_handlers/show/info/mod.rs index 2b2e2fff2..96219ac22 100644 --- a/src/application/command_handlers/show/info/mod.rs +++ b/src/application/command_handlers/show/info/mod.rs @@ -18,6 +18,7 @@ mod tracker; use std::net::IpAddr; use chrono::{DateTime, Utc}; +use serde::Serialize; pub use self::grafana::GrafanaInfo; pub use self::prometheus::PrometheusInfo; @@ -28,7 +29,7 @@ pub use self::tracker::{LocalhostServiceInfo, ServiceInfo, TlsDomainInfo}; /// This DTO contains all information about an environment that can be /// displayed to the user. It is state-aware and contains optional fields /// that are populated based on the environment's current state. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct EnvironmentInfo { /// Name of the environment pub name: String, @@ -113,7 +114,7 @@ impl EnvironmentInfo { /// Infrastructure details for an environment /// /// This information is available after the environment has been provisioned. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct InfrastructureInfo { /// Instance IP address pub instance_ip: IpAddr, diff --git a/src/application/command_handlers/show/info/prometheus.rs b/src/application/command_handlers/show/info/prometheus.rs index ff5604a24..6c2551ca9 100644 --- a/src/application/command_handlers/show/info/prometheus.rs +++ b/src/application/command_handlers/show/info/prometheus.rs @@ -2,12 +2,14 @@ //! //! This module contains DTOs for the Prometheus service. +use serde::Serialize; + /// Prometheus metrics service information for display purposes /// /// This information shows the status of the Prometheus service when configured. /// Prometheus collects and stores metrics from the tracker service. /// It can be used independently or as a data source for Grafana. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct PrometheusInfo { /// Description of how to access Prometheus (internal only) pub access_note: String, diff --git a/src/application/command_handlers/show/info/tracker.rs b/src/application/command_handlers/show/info/tracker.rs index be4b98fda..e400190b2 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -4,6 +4,8 @@ use std::net::IpAddr; +use serde::Serialize; + use crate::domain::grafana::GrafanaConfig; use crate::domain::tracker::config::is_localhost; use crate::domain::tracker::TrackerConfig; @@ -12,7 +14,7 @@ use crate::domain::tracker::TrackerConfig; /// /// This information is available for Released and Running states and shows /// the tracker services configured for the environment. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] #[allow(clippy::struct_excessive_bools)] pub struct ServiceInfo { /// UDP tracker URLs (e.g., `udp://10.0.0.1:6969/announce`) @@ -50,7 +52,7 @@ pub struct ServiceInfo { } /// Information about a localhost-only service (for SSH tunnel hint) -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct LocalhostServiceInfo { /// The service name (e.g., `http_tracker_1`) pub service_name: String, @@ -59,7 +61,7 @@ pub struct LocalhostServiceInfo { } /// Information about a TLS-enabled domain for /etc/hosts hint -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct TlsDomainInfo { /// The domain name pub domain: String, diff --git a/src/presentation/controllers/show/handler.rs b/src/presentation/controllers/show/handler.rs index a555225f3..c6043e898 100644 --- a/src/presentation/controllers/show/handler.rs +++ b/src/presentation/controllers/show/handler.rs @@ -12,7 +12,8 @@ use crate::application::command_handlers::show::info::EnvironmentInfo; use crate::application::command_handlers::show::{ShowCommandHandler, ShowCommandHandlerError}; use crate::domain::environment::name::EnvironmentName; use crate::domain::environment::repository::EnvironmentRepository; -use crate::presentation::views::commands::show::TextView; +use crate::presentation::input::cli::OutputFormat; +use crate::presentation::views::commands::show::{JsonView, TextView}; use crate::presentation::views::progress::ProgressReporter; use crate::presentation::views::UserOutput; @@ -98,11 +99,16 @@ impl ShowCommandController { /// # Arguments /// /// * `environment_name` - Name of the environment to show + /// * `output_format` - Output format (Text or Json) /// /// # Errors /// /// Returns `ShowSubcommandError` if any step fails - pub fn execute(&mut self, environment_name: &str) -> Result<(), ShowSubcommandError> { + pub fn execute( + &mut self, + environment_name: &str, + output_format: OutputFormat, + ) -> Result<(), ShowSubcommandError> { // Step 1: Validate environment name let env_name = self.validate_environment_name(environment_name)?; @@ -110,7 +116,7 @@ impl ShowCommandController { let env_info = self.load_environment(&env_name)?; // Step 3: Display information - self.display_information(&env_info)?; + self.display_information(&env_info, output_format)?; Ok(()) } @@ -185,18 +191,25 @@ impl ShowCommandController { /// /// Following the MVC pattern with functional composition: /// - Model: `EnvironmentInfo` (application layer DTO) - /// - View: `TextView::render()` (formatting) + /// - View: `TextView::render()` or `JsonView::render()` (formatting) /// - Controller (this method): Orchestrates the pipeline /// - Output: `ProgressReporter::result()` (routing to stdout) fn display_information( &mut self, env_info: &EnvironmentInfo, + output_format: OutputFormat, ) -> Result<(), ShowSubcommandError> { self.progress .start_step(ShowStep::DisplayInformation.description())?; + // Render using appropriate view based on output format (Strategy Pattern) + let output = match output_format { + OutputFormat::Text => TextView::render(env_info), + OutputFormat::Json => JsonView::render(env_info), + }; + // Pipeline: EnvironmentInfo → render → output to stdout - self.progress.result(&TextView::render(env_info))?; + self.progress.result(&output)?; self.progress.complete_step(Some("Information displayed"))?; diff --git a/src/presentation/dispatch/router.rs b/src/presentation/dispatch/router.rs index 04db1e36e..4fb447aca 100644 --- a/src/presentation/dispatch/router.rs +++ b/src/presentation/dispatch/router.rs @@ -213,7 +213,7 @@ pub async fn route_command( context .container() .create_show_controller() - .execute(&environment)?; + .execute(&environment, context.output_format())?; Ok(()) } Commands::List => { diff --git a/src/presentation/views/commands/show/mod.rs b/src/presentation/views/commands/show/mod.rs index 5b5742c9d..1e00f42d6 100644 --- a/src/presentation/views/commands/show/mod.rs +++ b/src/presentation/views/commands/show/mod.rs @@ -6,14 +6,16 @@ //! //! This module follows the Strategy Pattern for rendering: //! - `TextView`: Renders human-readable text output with environment details +//! - `JsonView`: Renders machine-readable JSON output for automation //! //! # Structure //! //! - `views/`: View rendering implementations -//! - `mod.rs`: Main `TextView` with composition of helper views +//! - `text_view.rs`: Main `TextView` with composition of helper views +//! - `json_view.rs`: Main `JsonView` for JSON serialization //! - Helper views: basic, infrastructure, `tracker_services`, prometheus, grafana, `https_hint`, `next_step` pub mod views; // Re-export main types for convenience -pub use views::TextView; +pub use views::{JsonView, TextView}; diff --git a/src/presentation/views/commands/show/views/json_view.rs b/src/presentation/views/commands/show/views/json_view.rs new file mode 100644 index 000000000..63c6f6a91 --- /dev/null +++ b/src/presentation/views/commands/show/views/json_view.rs @@ -0,0 +1,195 @@ +//! JSON View for Environment Information (Show Command) +//! +//! This module provides JSON-based rendering for environment information. +//! It follows the Strategy Pattern, providing a machine-readable output format +//! for the same underlying data (`EnvironmentInfo` DTO). +//! +//! # Design +//! +//! The `JsonView` simply serializes the `EnvironmentInfo` DTO to JSON using `serde_json`. +//! No transformation is needed since the DTO structure is already designed for display +//! purposes and contains all necessary information in a well-structured format. + +use crate::application::command_handlers::show::info::EnvironmentInfo; + +/// View for rendering environment information as JSON +/// +/// This view provides machine-readable JSON output for automation workflows +/// and AI agents. It serializes the complete `EnvironmentInfo` DTO without +/// any transformations. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::EnvironmentInfo; +/// use torrust_tracker_deployer_lib::presentation::views::commands::show::JsonView; +/// use chrono::{TimeZone, Utc}; +/// +/// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); +/// let info = EnvironmentInfo::new( +/// "my-env".to_string(), +/// "Created".to_string(), +/// "LXD".to_string(), +/// created_at, +/// "created".to_string(), +/// ); +/// +/// let output = JsonView::render(&info); +/// // Verify it's valid JSON +/// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); +/// assert_eq!(parsed["name"], "my-env"); +/// assert_eq!(parsed["state"], "Created"); +/// ``` +pub struct JsonView; + +impl JsonView { + /// Render environment information as JSON + /// + /// Serializes the `EnvironmentInfo` DTO to pretty-printed JSON format. + /// The output structure mirrors the DTO structure exactly, making it + /// easy to parse programmatically. + /// + /// # Arguments + /// + /// * `info` - Environment information to render + /// + /// # Returns + /// + /// A JSON string containing the serialized environment information. + /// If serialization fails (which should never happen with valid data), + /// returns an error JSON object. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{ + /// EnvironmentInfo, InfrastructureInfo, + /// }; + /// use torrust_tracker_deployer_lib::presentation::views::commands::show::JsonView; + /// use std::net::{IpAddr, Ipv4Addr}; + /// use chrono::Utc; + /// + /// let info = EnvironmentInfo::new( + /// "my-env".to_string(), + /// "Provisioned".to_string(), + /// "LXD".to_string(), + /// Utc::now(), + /// "provisioned".to_string(), + /// ).with_infrastructure(InfrastructureInfo::new( + /// IpAddr::V4(Ipv4Addr::new(10, 140, 190, 14)), + /// 22, + /// "ubuntu".to_string(), + /// "/home/user/.ssh/key".to_string(), + /// )); + /// + /// let json = JsonView::render(&info); + /// // Verify it's valid JSON and has expected fields + /// let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + /// assert_eq!(parsed["name"], "my-env"); + /// assert!(parsed["infrastructure"].is_object()); + /// ``` + #[must_use] + pub fn render(info: &EnvironmentInfo) -> String { + serde_json::to_string_pretty(info) + .unwrap_or_else(|e| format!(r#"{{"error": "Failed to serialize: {e}"}}"#)) + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + + use chrono::{TimeZone, Utc}; + + use super::*; + use crate::application::command_handlers::show::info::InfrastructureInfo; + + #[test] + fn it_should_render_created_state_as_json() { + let created_at = Utc.with_ymd_and_hms(2026, 2, 16, 10, 0, 0).unwrap(); + let info = EnvironmentInfo::new( + "test-env".to_string(), + "Created".to_string(), + "LXD".to_string(), + created_at, + "created".to_string(), + ); + + let output = JsonView::render(&info); + + // Verify JSON structure + assert!( + output.contains(r#""name":"test-env""#) || output.contains(r#""name": "test-env""#) + ); + assert!( + output.contains(r#""state":"Created""#) || output.contains(r#""state": "Created""#) + ); + assert!(output.contains(r#""provider":"LXD""#) || output.contains(r#""provider": "LXD""#)); + assert!( + output.contains(r#""state_name":"created""#) + || output.contains(r#""state_name": "created""#) + ); + + // Verify optional fields are null + assert!( + output.contains(r#""infrastructure":null"#) + || output.contains(r#""infrastructure": null"#) + ); + assert!(output.contains(r#""services":null"#) || output.contains(r#""services": null"#)); + } + + #[test] + fn it_should_render_provisioned_state_with_infrastructure() { + let created_at = Utc.with_ymd_and_hms(2026, 2, 16, 10, 0, 0).unwrap(); + let info = EnvironmentInfo::new( + "test-env".to_string(), + "Provisioned".to_string(), + "LXD".to_string(), + created_at, + "provisioned".to_string(), + ) + .with_infrastructure(InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(10, 140, 190, 39)), + 22, + "torrust".to_string(), + "/home/user/.ssh/key".to_string(), + )); + + let output = JsonView::render(&info); + + // Verify infrastructure section exists + assert!(output.contains(r#""infrastructure":"#)); + assert!( + output.contains(r#""instance_ip":"10.140.190.39""#) + || output.contains(r#""instance_ip": "10.140.190.39""#) + ); + assert!(output.contains(r#""ssh_port":22"#) || output.contains(r#""ssh_port": 22"#)); + assert!( + output.contains(r#""ssh_user":"torrust""#) + || output.contains(r#""ssh_user": "torrust""#) + ); + } + + #[test] + fn it_should_render_valid_json() { + let created_at = Utc.with_ymd_and_hms(2026, 2, 16, 10, 0, 0).unwrap(); + let info = EnvironmentInfo::new( + "test-env".to_string(), + "Created".to_string(), + "LXD".to_string(), + created_at, + "created".to_string(), + ); + + let output = JsonView::render(&info); + + // Verify it's valid JSON by parsing it + let parsed: serde_json::Value = + serde_json::from_str(&output).expect("Output should be valid JSON"); + + // Verify key fields + assert_eq!(parsed["name"], "test-env"); + assert_eq!(parsed["state"], "Created"); + assert_eq!(parsed["provider"], "LXD"); + } +} diff --git a/src/presentation/views/commands/show/views/mod.rs b/src/presentation/views/commands/show/views/mod.rs index e7678fe73..845db3aa1 100644 --- a/src/presentation/views/commands/show/views/mod.rs +++ b/src/presentation/views/commands/show/views/mod.rs @@ -1,20 +1,13 @@ -//! Text View for Environment Information (Show Command) +//! Views for the Show Command //! -//! This module provides text-based rendering for environment information. -//! It follows the Strategy Pattern, providing one specific rendering strategy -//! (human-readable text) for environment details. -//! -//! # Module Structure -//! -//! The view is composed of specialized child views for each section: -//! - `basic`: Basic environment info (name, state, provider, created) -//! - `infrastructure`: Infrastructure details (IP, SSH credentials) -//! - `tracker_services`: Tracker service endpoints -//! - `prometheus`: Prometheus metrics service -//! - `grafana`: Grafana visualization service -//! - `https_hint`: HTTPS configuration hints (/etc/hosts) -//! - `next_step`: State-aware guidance +//! This module provides different rendering strategies for environment information. +//! Following the Strategy Pattern, each view (`TextView`, `JsonView`) implements +//! a different output format for the same underlying data (`EnvironmentInfo` DTO). + +mod json_view; +mod text_view; +// Helper modules for TextView (text-based rendering components) mod basic; mod grafana; mod https_hint; @@ -23,364 +16,5 @@ mod next_step; mod prometheus; mod tracker_services; -use basic::BasicInfoView; -use grafana::GrafanaView; -use https_hint::HttpsHintView; -use infrastructure::InfrastructureView; -use next_step::NextStepGuidanceView; -use prometheus::PrometheusView; -use tracker_services::TrackerServicesView; - -use crate::application::command_handlers::show::info::EnvironmentInfo; - -/// View for rendering environment information -/// -/// This view is responsible for formatting and rendering the environment -/// information that users see when running the `show` command. -/// -/// # Design -/// -/// Following MVC pattern with composition, this view: -/// - Receives data from the controller via the `EnvironmentInfo` DTO -/// - Delegates rendering to specialized child views -/// - Composes the final output from child view results -/// - Returns a string ready for output to stdout -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::EnvironmentInfo; -/// use torrust_tracker_deployer_lib::presentation::views::commands::show::TextView; -/// use chrono::{TimeZone, Utc}; -/// -/// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); -/// let info = EnvironmentInfo::new( -/// "my-env".to_string(), -/// "Created".to_string(), -/// "LXD".to_string(), -/// created_at, -/// "created".to_string(), -/// ); -/// -/// let output = TextView::render(&info); -/// assert!(output.contains("Environment: my-env")); -/// assert!(output.contains("State: Created")); -/// ``` -pub struct TextView; - -impl TextView { - /// Render environment information as a formatted string - /// - /// Takes environment info and produces a human-readable output suitable - /// for displaying to users via stdout. Uses composition to delegate - /// rendering to specialized child views. - /// - /// # Arguments - /// - /// * `info` - Environment information to render - /// - /// # Returns - /// - /// A formatted string containing: - /// - Basic information (name, state, provider) - /// - Infrastructure details (if available) - /// - Service information (if available, for Released/Running states) - /// - Next step guidance - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{ - /// EnvironmentInfo, InfrastructureInfo, - /// }; - /// use torrust_tracker_deployer_lib::presentation::views::commands::show::TextView; - /// use std::net::{IpAddr, Ipv4Addr}; - /// use chrono::Utc; - /// - /// let info = EnvironmentInfo::new( - /// "prod-env".to_string(), - /// "Provisioned".to_string(), - /// "LXD".to_string(), - /// Utc::now(), - /// "provisioned".to_string(), - /// ).with_infrastructure(InfrastructureInfo::new( - /// IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), - /// 22, - /// "torrust".to_string(), - /// "~/.ssh/id_rsa".to_string(), - /// )); - /// - /// let output = TextView::render(&info); - /// assert!(output.contains("10.140.190.171")); - /// assert!(output.contains("ssh -i")); - /// ``` - #[must_use] - pub fn render(info: &EnvironmentInfo) -> String { - let mut lines = Vec::new(); - - // Basic information (always present) - lines.extend(BasicInfoView::render( - &info.name, - &info.state, - &info.provider, - info.created_at, - )); - - // Infrastructure details (if available) - if let Some(ref infra) = info.infrastructure { - lines.extend(InfrastructureView::render(infra)); - } - - // Tracker service information (if available) - if let Some(ref services) = info.services { - lines.extend(TrackerServicesView::render(services)); - } - - // Prometheus service (if configured) - if let Some(ref prometheus) = info.prometheus { - lines.extend(PrometheusView::render(prometheus)); - } - - // Grafana service (if configured) - if let Some(ref grafana) = info.grafana { - lines.extend(GrafanaView::render(grafana)); - } - - // HTTPS hint with /etc/hosts (if TLS is configured) - if let Some(ref services) = info.services { - let instance_ip = info.infrastructure.as_ref().map(|i| i.instance_ip); - lines.extend(HttpsHintView::render(services, instance_ip)); - } - - // Next step guidance (always present) - lines.extend(NextStepGuidanceView::render(&info.state_name)); - - lines.join("\n") - } -} - -#[cfg(test)] -mod tests { - use std::net::{IpAddr, Ipv4Addr}; - - use chrono::{TimeZone, Utc}; - - use super::*; - use crate::application::command_handlers::show::info::{ - InfrastructureInfo, ServiceInfo, TlsDomainInfo, - }; - - /// Helper to create a fixed test timestamp - fn test_timestamp() -> chrono::DateTime { - Utc.with_ymd_and_hms(2025, 1, 7, 12, 30, 45).unwrap() - } - - #[test] - fn it_should_render_basic_environment_info() { - let info = EnvironmentInfo::new( - "test-env".to_string(), - "Created".to_string(), - "LXD".to_string(), - test_timestamp(), - "created".to_string(), - ); - - let output = TextView::render(&info); - - assert!(output.contains("Environment: test-env")); - assert!(output.contains("State: Created")); - assert!(output.contains("Provider: LXD")); - assert!(output.contains("Created: 2025-01-07 12:30:45 UTC")); - assert!(output.contains("Run 'provision' to create infrastructure.")); - } - - #[test] - fn it_should_render_infrastructure_details_when_available() { - let info = EnvironmentInfo::new( - "prod-env".to_string(), - "Provisioned".to_string(), - "LXD".to_string(), - test_timestamp(), - "provisioned".to_string(), - ) - .with_infrastructure(InfrastructureInfo::new( - IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), - 22, - "torrust".to_string(), - "~/.ssh/id_rsa".to_string(), - )); - - let output = TextView::render(&info); - - assert!(output.contains("Infrastructure:")); - assert!(output.contains("Instance IP: 10.140.190.171")); - assert!(output.contains("SSH Port: 22")); - assert!(output.contains("SSH User: torrust")); - assert!(output.contains("SSH Key: ~/.ssh/id_rsa")); - assert!(output.contains("Connection:")); - assert!(output.contains("ssh -i")); - } - - #[test] - fn it_should_render_service_info_when_available() { - let info = EnvironmentInfo::new( - "running-env".to_string(), - "Running".to_string(), - "LXD".to_string(), - test_timestamp(), - "running".to_string(), - ) - .with_services(ServiceInfo::new( - vec!["udp://10.0.0.1:6969/announce".to_string()], - vec![], // No HTTPS trackers - vec!["http://10.0.0.1:7070/announce".to_string()], // DevSkim: ignore DS137138 - vec![], // No localhost HTTP trackers - "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 - false, // API doesn't use HTTPS - false, // API not localhost-only - "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 - false, // Health check doesn't use HTTPS - false, // Health check not localhost-only - vec![], // No TLS domains - )); - - let output = TextView::render(&info); - - assert!(output.contains("Tracker Services:")); - assert!(output.contains("UDP Trackers:")); - assert!(output.contains("udp://10.0.0.1:6969/announce")); - assert!(output.contains("HTTP Trackers (direct):")); - assert!(output.contains("http://10.0.0.1:7070/announce")); // DevSkim: ignore DS137138 - assert!(output.contains("API Endpoint:")); - assert!(output.contains("http://10.0.0.1:1212/api")); // DevSkim: ignore DS137138 - assert!(output.contains("Health Check:")); - assert!(output.contains("http://10.0.0.1:1313/health_check")); // DevSkim: ignore DS137138 - } - - #[test] - fn it_should_render_complete_info_with_infrastructure_and_services() { - let info = EnvironmentInfo::new( - "full-env".to_string(), - "Running".to_string(), - "LXD".to_string(), - test_timestamp(), - "running".to_string(), - ) - .with_infrastructure(InfrastructureInfo::new( - IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), - 2222, - "admin".to_string(), - "/path/to/key".to_string(), - )) - .with_services(ServiceInfo::new( - vec!["udp://192.168.1.100:6969/announce".to_string()], - vec![], // No HTTPS trackers - vec![], // No direct trackers - vec![], // No localhost HTTP trackers - "http://192.168.1.100:1212/api".to_string(), // DevSkim: ignore DS137138 - false, - false, // API not localhost-only - "http://192.168.1.100:1313/health_check".to_string(), // DevSkim: ignore DS137138 - false, // Health check doesn't use HTTPS - false, // Health check not localhost-only - vec![], - )); - - let output = TextView::render(&info); - - // Should have all sections - assert!(output.contains("Environment: full-env")); - assert!(output.contains("Infrastructure:")); - assert!(output.contains("192.168.1.100")); - assert!(output.contains("Tracker Services:")); - assert!(output.contains("UDP Trackers:")); - // Should not have HTTP Trackers section when empty - assert!(!output.contains("HTTP Trackers")); - } - - #[test] - fn it_should_render_https_services_with_hosts_hint() { - let info = EnvironmentInfo::new( - "https-env".to_string(), - "Running".to_string(), - "LXD".to_string(), - test_timestamp(), - "running".to_string(), - ) - .with_infrastructure(InfrastructureInfo::new( - IpAddr::V4(Ipv4Addr::new(10, 140, 190, 214)), - 22, - "torrust".to_string(), - "~/.ssh/id_rsa".to_string(), - )) - .with_services(ServiceInfo::new( - vec!["udp://10.140.190.214:6969/announce".to_string()], - vec![ - "https://http1.tracker.local/announce".to_string(), - "https://http2.tracker.local/announce".to_string(), - ], - vec!["http://10.140.190.214:7072/announce".to_string()], // DevSkim: ignore DS137138 - vec![], // No localhost HTTP trackers - "https://api.tracker.local/api".to_string(), - true, // API uses HTTPS - false, // API not localhost-only - "http://10.140.190.214:1313/health_check".to_string(), // DevSkim: ignore DS137138 - false, // Health check doesn't use HTTPS - false, // Health check not localhost-only - vec![ - TlsDomainInfo::new("api.tracker.local".to_string(), 1212), - TlsDomainInfo::new("http1.tracker.local".to_string(), 7070), - TlsDomainInfo::new("http2.tracker.local".to_string(), 7071), - TlsDomainInfo::new("grafana.tracker.local".to_string(), 3000), - ], - )); - - let output = TextView::render(&info); - - // Check HTTPS trackers section - assert!(output.contains("HTTP Trackers (HTTPS via Caddy):")); - assert!(output.contains("https://http1.tracker.local/announce")); - assert!(output.contains("https://http2.tracker.local/announce")); - - // Check direct HTTP trackers section - assert!(output.contains("HTTP Trackers (direct):")); - assert!(output.contains("http://10.140.190.214:7072/announce")); // DevSkim: ignore DS137138 - - // Check API shows HTTPS - assert!(output.contains("API Endpoint (HTTPS via Caddy):")); - assert!(output.contains("https://api.tracker.local/api")); - - // Check /etc/hosts hint - assert!(output.contains("Note: HTTPS services require domain-based access")); - assert!(output.contains("/etc/hosts")); - assert!(output.contains("10.140.190.214")); - assert!(output.contains("api.tracker.local")); - assert!(output.contains("http1.tracker.local")); - assert!(output.contains("grafana.tracker.local")); - - // Check unexposed ports message - assert!(output.contains("Internal ports")); - assert!(output.contains("not directly accessible when TLS is enabled")); - } - - #[test] - fn it_should_include_port_in_ssh_command_when_non_standard() { - let info = EnvironmentInfo::new( - "custom-port-env".to_string(), - "Provisioned".to_string(), - "LXD".to_string(), - test_timestamp(), - "provisioned".to_string(), - ) - .with_infrastructure(InfrastructureInfo::new( - IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), - 2222, - "user".to_string(), - "/key".to_string(), - )); - - let output = TextView::render(&info); - - assert!(output.contains("-p 2222")); - } -} +pub use json_view::JsonView; +pub use text_view::TextView; diff --git a/src/presentation/views/commands/show/views/text_view.rs b/src/presentation/views/commands/show/views/text_view.rs new file mode 100644 index 000000000..594c41d0e --- /dev/null +++ b/src/presentation/views/commands/show/views/text_view.rs @@ -0,0 +1,378 @@ +//! Text View for Environment Information (Show Command) +//! +//! This module provides text-based rendering for environment information. +//! It follows the Strategy Pattern, providing one specific rendering strategy +//! (human-readable text) for environment details. +//! +//! # Module Structure +//! +//! The view is composed of specialized child views for each section: +//! - `basic`: Basic environment info (name, state, provider, created) +//! - `infrastructure`: Infrastructure details (IP, SSH credentials) +//! - `tracker_services`: Tracker service endpoints +//! - `prometheus`: Prometheus metrics service +//! - `grafana`: Grafana visualization service +//! - `https_hint`: HTTPS configuration hints (/etc/hosts) +//! - `next_step`: State-aware guidance + +use super::basic::BasicInfoView; +use super::grafana::GrafanaView; +use super::https_hint::HttpsHintView; +use super::infrastructure::InfrastructureView; +use super::next_step::NextStepGuidanceView; +use super::prometheus::PrometheusView; +use super::tracker_services::TrackerServicesView; + +use crate::application::command_handlers::show::info::EnvironmentInfo; + +/// View for rendering environment information +/// +/// This view is responsible for formatting and rendering the environment +/// information that users see when running the `show` command. +/// +/// # Design +/// +/// Following MVC pattern with composition, this view: +/// - Receives data from the controller via the `EnvironmentInfo` DTO +/// - Delegates rendering to specialized child views +/// - Composes the final output from child view results +/// - Returns a string ready for output to stdout +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::EnvironmentInfo; +/// use torrust_tracker_deployer_lib::presentation::views::commands::show::TextView; +/// use chrono::{TimeZone, Utc}; +/// +/// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); +/// let info = EnvironmentInfo::new( +/// "my-env".to_string(), +/// "Created".to_string(), +/// "LXD".to_string(), +/// created_at, +/// "created".to_string(), +/// ); +/// +/// let output = TextView::render(&info); +/// assert!(output.contains("Environment: my-env")); +/// assert!(output.contains("State: Created")); +/// ``` +pub struct TextView; + +impl TextView { + /// Render environment information as a formatted string + /// + /// Takes environment info and produces a human-readable output suitable + /// for displaying to users via stdout. Uses composition to delegate + /// rendering to specialized child views. + /// + /// # Arguments + /// + /// * `info` - Environment information to render + /// + /// # Returns + /// + /// A formatted string containing: + /// - Basic information (name, state, provider) + /// - Infrastructure details (if available) + /// - Service information (if available, for Released/Running states) + /// - Next step guidance + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{ + /// EnvironmentInfo, InfrastructureInfo, + /// }; + /// use torrust_tracker_deployer_lib::presentation::views::commands::show::TextView; + /// use std::net::{IpAddr, Ipv4Addr}; + /// use chrono::Utc; + /// + /// let info = EnvironmentInfo::new( + /// "prod-env".to_string(), + /// "Provisioned".to_string(), + /// "LXD".to_string(), + /// Utc::now(), + /// "provisioned".to_string(), + /// ).with_infrastructure(InfrastructureInfo::new( + /// IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), + /// 22, + /// "torrust".to_string(), + /// "~/.ssh/id_rsa".to_string(), + /// )); + /// + /// let output = TextView::render(&info); + /// assert!(output.contains("10.140.190.171")); + /// assert!(output.contains("ssh -i")); + /// ``` + #[must_use] + pub fn render(info: &EnvironmentInfo) -> String { + let mut lines = Vec::new(); + + // Basic information (always present) + lines.extend(BasicInfoView::render( + &info.name, + &info.state, + &info.provider, + info.created_at, + )); + + // Infrastructure details (if available) + if let Some(ref infra) = info.infrastructure { + lines.extend(InfrastructureView::render(infra)); + } + + // Tracker service information (if available) + if let Some(ref services) = info.services { + lines.extend(TrackerServicesView::render(services)); + } + + // Prometheus service (if configured) + if let Some(ref prometheus) = info.prometheus { + lines.extend(PrometheusView::render(prometheus)); + } + + // Grafana service (if configured) + if let Some(ref grafana) = info.grafana { + lines.extend(GrafanaView::render(grafana)); + } + + // HTTPS hint with /etc/hosts (if TLS is configured) + if let Some(ref services) = info.services { + let instance_ip = info.infrastructure.as_ref().map(|i| i.instance_ip); + lines.extend(HttpsHintView::render(services, instance_ip)); + } + + // Next step guidance (always present) + lines.extend(NextStepGuidanceView::render(&info.state_name)); + + lines.join("\n") + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + + use chrono::{TimeZone, Utc}; + + use super::*; + use crate::application::command_handlers::show::info::{ + InfrastructureInfo, ServiceInfo, TlsDomainInfo, + }; + + /// Helper to create a fixed test timestamp + fn test_timestamp() -> chrono::DateTime { + Utc.with_ymd_and_hms(2025, 1, 7, 12, 30, 45).unwrap() + } + + #[test] + fn it_should_render_basic_environment_info() { + let info = EnvironmentInfo::new( + "test-env".to_string(), + "Created".to_string(), + "LXD".to_string(), + test_timestamp(), + "created".to_string(), + ); + + let output = TextView::render(&info); + + assert!(output.contains("Environment: test-env")); + assert!(output.contains("State: Created")); + assert!(output.contains("Provider: LXD")); + assert!(output.contains("Created: 2025-01-07 12:30:45 UTC")); + assert!(output.contains("Run 'provision' to create infrastructure.")); + } + + #[test] + fn it_should_render_infrastructure_details_when_available() { + let info = EnvironmentInfo::new( + "prod-env".to_string(), + "Provisioned".to_string(), + "LXD".to_string(), + test_timestamp(), + "provisioned".to_string(), + ) + .with_infrastructure(InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), + 22, + "torrust".to_string(), + "~/.ssh/id_rsa".to_string(), + )); + + let output = TextView::render(&info); + + assert!(output.contains("Infrastructure:")); + assert!(output.contains("Instance IP: 10.140.190.171")); + assert!(output.contains("SSH Port: 22")); + assert!(output.contains("SSH User: torrust")); + assert!(output.contains("SSH Key: ~/.ssh/id_rsa")); + assert!(output.contains("Connection:")); + assert!(output.contains("ssh -i")); + } + + #[test] + fn it_should_render_service_info_when_available() { + let info = EnvironmentInfo::new( + "running-env".to_string(), + "Running".to_string(), + "LXD".to_string(), + test_timestamp(), + "running".to_string(), + ) + .with_services(ServiceInfo::new( + vec!["udp://10.0.0.1:6969/announce".to_string()], + vec![], // No HTTPS trackers + vec!["http://10.0.0.1:7070/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, // API doesn't use HTTPS + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![], // No TLS domains + )); + + let output = TextView::render(&info); + + assert!(output.contains("Tracker Services:")); + assert!(output.contains("UDP Trackers:")); + assert!(output.contains("udp://10.0.0.1:6969/announce")); + assert!(output.contains("HTTP Trackers (direct):")); + assert!(output.contains("http://10.0.0.1:7070/announce")); // DevSkim: ignore DS137138 + assert!(output.contains("API Endpoint:")); + assert!(output.contains("http://10.0.0.1:1212/api")); // DevSkim: ignore DS137138 + assert!(output.contains("Health Check:")); + assert!(output.contains("http://10.0.0.1:1313/health_check")); // DevSkim: ignore DS137138 + } + + #[test] + fn it_should_render_complete_info_with_infrastructure_and_services() { + let info = EnvironmentInfo::new( + "full-env".to_string(), + "Running".to_string(), + "LXD".to_string(), + test_timestamp(), + "running".to_string(), + ) + .with_infrastructure(InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), + 2222, + "admin".to_string(), + "/path/to/key".to_string(), + )) + .with_services(ServiceInfo::new( + vec!["udp://192.168.1.100:6969/announce".to_string()], + vec![], // No HTTPS trackers + vec![], // No direct trackers + vec![], // No localhost HTTP trackers + "http://192.168.1.100:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, // API not localhost-only + "http://192.168.1.100:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![], + )); + + let output = TextView::render(&info); + + // Should have all sections + assert!(output.contains("Environment: full-env")); + assert!(output.contains("Infrastructure:")); + assert!(output.contains("192.168.1.100")); + assert!(output.contains("Tracker Services:")); + assert!(output.contains("UDP Trackers:")); + // Should not have HTTP Trackers section when empty + assert!(!output.contains("HTTP Trackers")); + } + + #[test] + fn it_should_render_https_services_with_hosts_hint() { + let info = EnvironmentInfo::new( + "https-env".to_string(), + "Running".to_string(), + "LXD".to_string(), + test_timestamp(), + "running".to_string(), + ) + .with_infrastructure(InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(10, 140, 190, 214)), + 22, + "torrust".to_string(), + "~/.ssh/id_rsa".to_string(), + )) + .with_services(ServiceInfo::new( + vec!["udp://10.140.190.214:6969/announce".to_string()], + vec![ + "https://http1.tracker.local/announce".to_string(), + "https://http2.tracker.local/announce".to_string(), + ], + vec!["http://10.140.190.214:7072/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers + "https://api.tracker.local/api".to_string(), + true, // API uses HTTPS + false, // API not localhost-only + "http://10.140.190.214:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![ + TlsDomainInfo::new("api.tracker.local".to_string(), 1212), + TlsDomainInfo::new("http1.tracker.local".to_string(), 7070), + TlsDomainInfo::new("http2.tracker.local".to_string(), 7071), + TlsDomainInfo::new("grafana.tracker.local".to_string(), 3000), + ], + )); + + let output = TextView::render(&info); + + // Check HTTPS trackers section + assert!(output.contains("HTTP Trackers (HTTPS via Caddy):")); + assert!(output.contains("https://http1.tracker.local/announce")); + assert!(output.contains("https://http2.tracker.local/announce")); + + // Check direct HTTP trackers section + assert!(output.contains("HTTP Trackers (direct):")); + assert!(output.contains("http://10.140.190.214:7072/announce")); // DevSkim: ignore DS137138 + + // Check API shows HTTPS + assert!(output.contains("API Endpoint (HTTPS via Caddy):")); + assert!(output.contains("https://api.tracker.local/api")); + + // Check /etc/hosts hint + assert!(output.contains("Note: HTTPS services require domain-based access")); + assert!(output.contains("/etc/hosts")); + assert!(output.contains("10.140.190.214")); + assert!(output.contains("api.tracker.local")); + assert!(output.contains("http1.tracker.local")); + assert!(output.contains("grafana.tracker.local")); + + // Check unexposed ports message + assert!(output.contains("Internal ports")); + assert!(output.contains("not directly accessible when TLS is enabled")); + } + + #[test] + fn it_should_include_port_in_ssh_command_when_non_standard() { + let info = EnvironmentInfo::new( + "custom-port-env".to_string(), + "Provisioned".to_string(), + "LXD".to_string(), + test_timestamp(), + "provisioned".to_string(), + ) + .with_infrastructure(InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + 2222, + "user".to_string(), + "/key".to_string(), + )); + + let output = TextView::render(&info); + + assert!(output.contains("-p 2222")); + } +}