Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions docs/user-guide/commands/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,146 @@ Service URLs:
Tip: Run 'torrust-tracker-deployer show my-tls-env' for full details
```

## JSON Output

The `run` command supports JSON output for automation workflows using the `--output-format json` or `-o json` flag.

### Command Syntax

```bash
torrust-tracker-deployer run <ENVIRONMENT> --output-format json
# Or use the short form:
torrust-tracker-deployer run <ENVIRONMENT> -o json
```

### JSON Output Structure

```json
{
"environment_name": "my-environment",
"state": "Running",
"services": {
"udp_trackers": ["udp://udp.tracker.local:6969/announce"],
"https_http_trackers": [],
"direct_http_trackers": ["http://10.140.190.133:7070/announce"],
"localhost_http_trackers": [],
"api_endpoint": "http://10.140.190.133:1212/api",
"api_uses_https": false,
"api_is_localhost_only": false,
"health_check_url": "http://10.140.190.133:1313/health_check",
"health_check_uses_https": false,
"health_check_is_localhost_only": false,
"tls_domains": []
},
"grafana": {
"url": "http://10.140.190.133:3000/",
"uses_https": false
}
}
```

### JSON Output with HTTPS/TLS

When TLS is configured, the output includes HTTPS URLs and domain information:

```json
{
"environment_name": "my-tls-env",
"state": "Running",
"services": {
"udp_trackers": ["udp://udp.tracker.example.com:6969/announce"],
"https_http_trackers": ["https://tracker.example.com:7070/announce"],
"direct_http_trackers": [],
"localhost_http_trackers": [],
"api_endpoint": "https://tracker.example.com:1212/api",
"api_uses_https": true,
"api_is_localhost_only": false,
"health_check_url": "https://tracker.example.com:1313/health_check",
"health_check_uses_https": true,
"health_check_is_localhost_only": false,
"tls_domains": ["tracker.example.com"]
},
"grafana": {
"url": "https://tracker.example.com:3000/",
"uses_https": true
}
}
```

### Automation Use Cases

#### Extract API Endpoint

```bash
# Get API endpoint for automated testing
API_ENDPOINT=$(torrust-tracker-deployer run my-env -o json | jq -r '.services.api_endpoint')
curl "$API_ENDPOINT/health_check"
```

#### Check if HTTPS is Required

```bash
# Determine if API uses HTTPS
USES_HTTPS=$(torrust-tracker-deployer run my-env -o json | jq -r '.services.api_uses_https')
if [ "$USES_HTTPS" = "true" ]; then
echo "HTTPS is enabled"
else
echo "Using HTTP only"
fi
```

#### Extract All HTTP Tracker URLs

```bash
# Get all HTTP/HTTPS tracker announce URLs for testing
torrust-tracker-deployer run my-env -o json | \
jq -r '.services | (.direct_http_trackers + .https_http_trackers)[]'
```

#### Parse TLS Domains for DNS Configuration

```bash
# Extract domains that need DNS configuration
DOMAINS=$(torrust-tracker-deployer run my-env -o json | jq -r '.services.tls_domains[]')
for domain in $DOMAINS; do
echo "Configure DNS A record: $domain → $(jq -r '.services.api_endpoint' <<< "$JSON" | cut -d: -f2 | tr -d '/')"
done
```

#### Monitor Service Status in CI/CD

```bash
# Save output for later analysis
torrust-tracker-deployer run production-env -o json > run-output.json

# Parse for verification
jq '.services.api_endpoint' run-output.json
jq '.services.udp_trackers[]' run-output.json
jq '.grafana.url' run-output.json
```

### JSON Output Fields

| Field | Type | Description |
| ----------------------------------------- | -------- | ------------------------------------------------- |
| `environment_name` | string | Name of the environment |
| `state` | string | Always "Running" after successful run |
| `services.udp_trackers` | string[] | UDP tracker announce URLs |
| `services.https_http_trackers` | string[] | HTTPS HTTP tracker announce URLs (TLS configured) |
| `services.direct_http_trackers` | string[] | HTTP tracker announce URLs (no TLS) |
| `services.localhost_http_trackers` | string[] | Localhost-only HTTP trackers (for testing) |
| `services.api_endpoint` | string | Tracker API base URL |
| `services.api_uses_https` | boolean | True if API uses HTTPS |
| `services.api_is_localhost_only` | boolean | True if API only bound to localhost |
| `services.health_check_url` | string | Health check endpoint URL |
| `services.health_check_uses_https` | boolean | True if health check uses HTTPS |
| `services.health_check_is_localhost_only` | boolean | True if health check only on localhost |
| `services.tls_domains` | string[] | Domains requiring DNS configuration for TLS |
| `grafana.url` | string | Grafana dashboard URL (if enabled) |
| `grafana.uses_https` | boolean | True if Grafana uses HTTPS |

**Note**: `grafana` will be `null` if monitoring is not enabled in the environment configuration.

## Example Usage

### Basic Run
Expand Down
72 changes: 42 additions & 30 deletions src/presentation/controllers/run/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ use crate::application::command_handlers::show::info::{GrafanaInfo, ServiceInfo}
use crate::domain::environment::name::EnvironmentName;
use crate::domain::environment::repository::EnvironmentRepository;
use crate::domain::environment::state::AnyEnvironmentState;
use crate::presentation::views::commands::shared::service_urls::{
CompactServiceUrlsView, DnsHintView,
};
use crate::presentation::input::cli::OutputFormat;
use crate::presentation::views::commands::run::{JsonView, TextView};
use crate::presentation::views::progress::ProgressReporter;
use crate::presentation::views::UserOutput;
use crate::shared::clock::Clock;
Expand Down Expand Up @@ -101,6 +100,7 @@ impl RunCommandController {
/// # Arguments
///
/// * `environment_name` - The name of the environment to run services in
/// * `output_format` - Output format (Text or Json)
///
/// # Errors
///
Expand All @@ -114,12 +114,16 @@ impl RunCommandController {
/// Returns `Ok(())` on success, or a `RunSubcommandError` if any step fails.
#[allow(clippy::result_large_err)]
#[allow(clippy::unused_async)] // Part of uniform async presentation layer interface
pub async fn execute(&mut self, environment_name: &str) -> Result<(), RunSubcommandError> {
pub async fn execute(
&mut self,
environment_name: &str,
output_format: OutputFormat,
) -> Result<(), RunSubcommandError> {
let env_name = self.validate_environment_name(environment_name)?;

self.run_services(&env_name)?;

self.complete_workflow(environment_name)?;
self.complete_workflow(environment_name, output_format)?;

Ok(())
}
Expand Down Expand Up @@ -185,7 +189,11 @@ impl RunCommandController {
/// Follows the same pattern as the show command for loading environment
/// and extracting service information.
#[allow(clippy::result_large_err)]
fn complete_workflow(&mut self, name: &str) -> Result<(), RunSubcommandError> {
fn complete_workflow(
&mut self,
name: &str,
output_format: OutputFormat,
) -> Result<(), RunSubcommandError> {
// Load environment to get service information
let env_name = EnvironmentName::new(name.to_string()).map_err(|source| {
RunSubcommandError::InvalidEnvironmentName {
Expand All @@ -201,7 +209,7 @@ impl RunCommandController {
.complete(&format!("Run command completed for '{name}'"))?;

// Display service URLs and hints
self.display_service_urls(&any_env)?;
self.display_service_urls(&any_env, output_format)?;

Ok(())
}
Expand Down Expand Up @@ -231,15 +239,22 @@ impl RunCommandController {

/// Display service URLs and DNS hints
///
/// Renders service URLs using the shared views:
/// - `CompactServiceUrlsView` - Only shows publicly accessible services
/// - `DnsHintView` - Shows DNS configuration hint for HTTPS services
/// Uses the Strategy Pattern to render output in the requested format:
/// - Text format: Uses `TextView` with `CompactServiceUrlsView` and `DnsHintView`
/// - JSON format: Uses `JsonView` for machine-readable output
///
/// Also displays a tip about using the `show` command for full details.
/// # Architecture
///
/// Following the MVC pattern with functional composition:
/// - Model: `ServiceInfo` and `GrafanaInfo` (application layer DTOs)
/// - View: `TextView::render()` or `JsonView::render()` (formatting)
/// - Controller (this method): Orchestrates the pipeline
/// - Output: `ProgressReporter::result()` (routing to stdout)
#[allow(clippy::result_large_err)]
fn display_service_urls(
&mut self,
any_env: &AnyEnvironmentState,
output_format: OutputFormat,
) -> Result<(), RunSubcommandError> {
if let Some(instance_ip) = any_env.instance_ip() {
let tracker_config = any_env.tracker_config();
Expand All @@ -251,22 +266,18 @@ impl RunCommandController {
let grafana =
grafana_config.map(|config| GrafanaInfo::from_config(config, instance_ip));

// Render service URLs (only public services)
let service_urls_output = CompactServiceUrlsView::render(&services, grafana.as_ref());
if !service_urls_output.is_empty() {
self.progress.result(&format!("\n{service_urls_output}"))?;
}

// Show DNS hint if HTTPS services are configured
if let Some(dns_hint) = DnsHintView::render(&services) {
self.progress.result(&format!("\n{dns_hint}"))?;
}

// Show tip about show command
self.progress.result(&format!(
"\nTip: Run 'torrust-tracker-deployer show {}' for full details\n",
any_env.name()
))?;
// Render using appropriate view based on output format (Strategy Pattern)
let output = match output_format {
OutputFormat::Text => {
TextView::render(any_env.name().as_str(), &services, grafana.as_ref())
}
OutputFormat::Json => {
JsonView::render(any_env.name().as_str(), &services, grafana.as_ref())
}
};

// Pipeline: ServiceInfo + GrafanaInfo → render → output to stdout
self.progress.result(&output)?;
}

Ok(())
Expand All @@ -278,6 +289,7 @@ mod tests {
use super::*;
use crate::infrastructure::persistence::repository_factory::RepositoryFactory;
use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT;
use crate::presentation::input::cli::OutputFormat;
use crate::presentation::views::testing::TestUserOutput;
use crate::presentation::views::VerbosityLevel;
use crate::shared::SystemClock;
Expand Down Expand Up @@ -310,7 +322,7 @@ mod tests {

// Test with invalid environment name (contains underscore)
let result = RunCommandController::new(repository, clock, user_output.clone())
.execute("invalid_name")
.execute("invalid_name", OutputFormat::Text)
.await;

assert!(result.is_err());
Expand All @@ -329,7 +341,7 @@ mod tests {
let (user_output, repository, clock) = create_test_dependencies(&temp_dir);

let result = RunCommandController::new(repository, clock, user_output.clone())
.execute("")
.execute("", OutputFormat::Text)
.await;

assert!(result.is_err());
Expand All @@ -349,7 +361,7 @@ mod tests {

// Valid environment name but doesn't exist
let result = RunCommandController::new(repository, clock, user_output.clone())
.execute("test-env")
.execute("test-env", OutputFormat::Text)
.await;

assert!(result.is_err());
Expand Down
11 changes: 6 additions & 5 deletions src/presentation/controllers/run/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::domain::environment::repository::EnvironmentRepository;
use crate::infrastructure::persistence::repository_factory::RepositoryFactory;
use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT;
use crate::presentation::controllers::run::handler::RunCommandController;
use crate::presentation::input::cli::OutputFormat;
use crate::presentation::views::testing::TestUserOutput;
use crate::presentation::views::{UserOutput, VerbosityLevel};
use crate::shared::clock::Clock;
Expand Down Expand Up @@ -46,7 +47,7 @@ mod environment_name_validation {
let (user_output, repository, clock) = create_test_dependencies(&temp_dir);

let result = RunCommandController::new(repository, clock, user_output)
.execute("invalid_name")
.execute("invalid_name", OutputFormat::Text)
.await;

assert!(matches!(
Expand All @@ -61,7 +62,7 @@ mod environment_name_validation {
let (user_output, repository, clock) = create_test_dependencies(&temp_dir);

let result = RunCommandController::new(repository, clock, user_output)
.execute("")
.execute("", OutputFormat::Text)
.await;

assert!(matches!(
Expand All @@ -76,7 +77,7 @@ mod environment_name_validation {
let (user_output, repository, clock) = create_test_dependencies(&temp_dir);

let result = RunCommandController::new(repository, clock, user_output)
.execute("-invalid")
.execute("-invalid", OutputFormat::Text)
.await;

assert!(matches!(
Expand All @@ -97,7 +98,7 @@ mod real_workflow {

// Valid environment name but environment doesn't exist
let result = RunCommandController::new(repository, clock, user_output)
.execute("production")
.execute("production", OutputFormat::Text)
.await;

assert!(
Expand All @@ -115,7 +116,7 @@ mod real_workflow {
let (user_output, repository, clock) = create_test_dependencies(&temp_dir);

let result = RunCommandController::new(repository, clock, user_output)
.execute("my-test-env")
.execute("my-test-env", OutputFormat::Text)
.await;

assert!(
Expand Down
3 changes: 2 additions & 1 deletion src/presentation/dispatch/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,11 @@ pub async fn route_command(
Ok(())
}
Commands::Run { environment } => {
let output_format = context.output_format();
context
.container()
.create_run_controller()
.execute(&environment)
.execute(&environment, output_format)
.await?;
Ok(())
}
Expand Down
1 change: 1 addition & 0 deletions src/presentation/views/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
pub mod create;
pub mod list;
pub mod provision;
pub mod run;
pub mod shared;
pub mod show;
Loading
Loading