Skip to content

Commit 669c7e3

Browse files
committed
feat: [#241] add service endpoints to RuntimeOutputs and show command
1 parent b111a75 commit 669c7e3

10 files changed

Lines changed: 233 additions & 7 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ torrust-linting = { path = "packages/linting" }
6363
tracing = { version = "0.1", features = [ "attributes" ] }
6464
tracing-appender = "0.2"
6565
tracing-subscriber = { version = "0.3", features = [ "env-filter", "json", "fmt" ] }
66+
url = { version = "2.0", features = [ "serde" ] }
6667
uuid = { version = "1.0", features = [ "v4", "serde" ] }
6768

6869
[dev-dependencies]

src/application/command_handlers/run/handler.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::adapters::ansible::AnsibleClient;
1010
use crate::application::command_handlers::common::StepResult;
1111
use crate::application::steps::application::StartServicesStep;
1212
use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
13+
use crate::domain::environment::runtime_outputs::ServiceEndpoints;
1314
use crate::domain::environment::state::{RunFailureContext, RunStep};
1415
use crate::domain::environment::{Environment, Released, Running};
1516
use crate::domain::EnvironmentName;
@@ -148,6 +149,7 @@ impl RunCommandHandler {
148149
///
149150
/// This method orchestrates the complete run workflow:
150151
/// 1. Start Docker Compose services on the remote host
152+
/// 2. Build service endpoints for display
151153
///
152154
/// If an error occurs, it returns both the error and the step that was being
153155
/// executed, enabling accurate failure context generation.
@@ -169,7 +171,14 @@ impl RunCommandHandler {
169171
// Step 1: Start Docker Compose services
170172
self.start_services(environment, instance_ip)?;
171173

172-
let running = environment.clone().start_running();
174+
// Build service endpoints from tracker config and instance IP
175+
let service_endpoints =
176+
ServiceEndpoints::from_tracker_config(environment.tracker_config(), instance_ip);
177+
178+
// Transition to running state with service endpoints
179+
let running = environment
180+
.clone()
181+
.start_running_with_endpoints(service_endpoints);
173182

174183
Ok(running)
175184
}

src/application/command_handlers/show/handler.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,14 @@ impl ShowCommandHandler {
142142

143143
// Add service info for Released/Running states
144144
if Self::should_show_services(any_env.state_name()) {
145-
let tracker_config = any_env.tracker_config();
146-
let services = ServiceInfo::from_tracker_config(tracker_config, instance_ip);
145+
// Try to use stored service endpoints first, fall back to computing from config
146+
let services = if let Some(endpoints) = any_env.service_endpoints() {
147+
ServiceInfo::from_service_endpoints(endpoints)
148+
} else {
149+
// Backward compatibility: compute from tracker config
150+
let tracker_config = any_env.tracker_config();
151+
ServiceInfo::from_tracker_config(tracker_config, instance_ip)
152+
};
147153
info = info.with_services(services);
148154
}
149155
}

src/application/command_handlers/show/info.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,39 @@ impl ServiceInfo {
197197

198198
Self::new(udp_trackers, http_trackers, api_endpoint, health_check_url)
199199
}
200+
201+
/// Build `ServiceInfo` from stored `ServiceEndpoints`
202+
///
203+
/// This method extracts service URLs from the runtime outputs
204+
/// that were stored when services were started.
205+
#[must_use]
206+
pub fn from_service_endpoints(
207+
endpoints: &crate::domain::environment::runtime_outputs::ServiceEndpoints,
208+
) -> Self {
209+
let udp_trackers = endpoints
210+
.udp_trackers
211+
.iter()
212+
.map(ToString::to_string)
213+
.collect();
214+
215+
let http_trackers = endpoints
216+
.http_trackers
217+
.iter()
218+
.map(ToString::to_string)
219+
.collect();
220+
221+
let api_endpoint = endpoints
222+
.api_endpoint
223+
.as_ref()
224+
.map_or_else(String::new, ToString::to_string);
225+
226+
let health_check_url = endpoints
227+
.health_check_url
228+
.as_ref()
229+
.map_or_else(String::new, ToString::to_string);
230+
231+
Self::new(udp_trackers, http_trackers, api_endpoint, health_check_url)
232+
}
200233
}
201234

202235
#[cfg(test)]

src/domain/environment/context.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ impl EnvironmentContext {
204204
runtime_outputs: RuntimeOutputs {
205205
instance_ip: None,
206206
provision_method: None,
207+
service_endpoints: None,
207208
},
208209
}
209210
}
@@ -241,6 +242,7 @@ impl EnvironmentContext {
241242
runtime_outputs: RuntimeOutputs {
242243
instance_ip: None,
243244
provision_method: None,
245+
service_endpoints: None,
244246
},
245247
}
246248
}

src/domain/environment/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,7 @@ mod tests {
11271127
runtime_outputs: RuntimeOutputs {
11281128
instance_ip: None,
11291129
provision_method: None,
1130+
service_endpoints: None,
11301131
},
11311132
created_at: chrono::Utc::now(),
11321133
};

src/domain/environment/runtime_outputs.rs

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
//! **Runtime Outputs** are:
1414
//! - Generated during deployment operations
1515
//! - Mutable as operations progress
16-
//! - Examples: IP addresses, container IDs
16+
//! - Examples: IP addresses, container IDs, service URLs
1717
//!
1818
//! Add new fields here when: Operations produce new data about the deployed infrastructure.
1919
//!
@@ -22,10 +22,10 @@
2222
//! This struct is expected to grow with fields like:
2323
//! - `container_id: Option<String>` - Container/VM identifier
2424
//! - `resource_metrics: Option<ResourceMetrics>` - CPU, memory, disk usage
25-
//! - `service_endpoints: Option<Vec<ServiceEndpoint>>` - HTTP/TCP service URLs
2625
2726
use serde::{Deserialize, Serialize};
2827
use std::net::IpAddr;
28+
use url::Url;
2929

3030
/// How the infrastructure instance was provisioned
3131
///
@@ -57,6 +57,136 @@ impl std::fmt::Display for ProvisionMethod {
5757
}
5858
}
5959

60+
/// Service endpoints for deployed tracker services
61+
///
62+
/// This struct stores the URLs for all deployed tracker services. These URLs
63+
/// are computed from the tracker configuration and instance IP after the
64+
/// `run` command successfully starts the services.
65+
///
66+
/// # Purpose
67+
///
68+
/// Having service endpoints as first-class data allows:
69+
/// - Displaying service URLs without recomputation
70+
/// - Sharing URLs with external tools/integrations
71+
/// - Validating service availability against stored endpoints
72+
///
73+
/// # Examples
74+
///
75+
/// ```rust
76+
/// use torrust_tracker_deployer_lib::domain::environment::runtime_outputs::ServiceEndpoints;
77+
/// use url::Url;
78+
///
79+
/// let endpoints = ServiceEndpoints {
80+
/// udp_trackers: vec![
81+
/// Url::parse("udp://10.0.0.1:6969/announce").unwrap(),
82+
/// ],
83+
/// http_trackers: vec![
84+
/// Url::parse("http://10.0.0.1:7070/announce").unwrap(),
85+
/// ],
86+
/// api_endpoint: Some(Url::parse("http://10.0.0.1:1212/api").unwrap()),
87+
/// health_check_url: Some(Url::parse("http://10.0.0.1:1313/health_check").unwrap()),
88+
/// };
89+
/// ```
90+
#[derive(Debug, Clone, Serialize, Deserialize)]
91+
pub struct ServiceEndpoints {
92+
/// UDP tracker announce URLs (e.g., `udp://10.0.0.1:6969/announce`)
93+
#[serde(default)]
94+
pub udp_trackers: Vec<Url>,
95+
96+
/// HTTP tracker announce URLs (e.g., `http://10.0.0.1:7070/announce`)
97+
#[serde(default)]
98+
pub http_trackers: Vec<Url>,
99+
100+
/// HTTP API endpoint URL (e.g., `http://10.0.0.1:1212/api`)
101+
pub api_endpoint: Option<Url>,
102+
103+
/// Health check API URL (e.g., `http://10.0.0.1:1313/health_check`)
104+
pub health_check_url: Option<Url>,
105+
}
106+
107+
impl ServiceEndpoints {
108+
/// Create new `ServiceEndpoints` from the provided URLs
109+
#[must_use]
110+
pub fn new(
111+
udp_trackers: Vec<Url>,
112+
http_trackers: Vec<Url>,
113+
api_endpoint: Option<Url>,
114+
health_check_url: Option<Url>,
115+
) -> Self {
116+
Self {
117+
udp_trackers,
118+
http_trackers,
119+
api_endpoint,
120+
health_check_url,
121+
}
122+
}
123+
124+
/// Build `ServiceEndpoints` from tracker configuration and instance IP
125+
///
126+
/// Constructs service URLs by combining the configured bind addresses
127+
/// with the actual instance IP address.
128+
///
129+
/// # Examples
130+
///
131+
/// ```rust
132+
/// use torrust_tracker_deployer_lib::domain::environment::runtime_outputs::ServiceEndpoints;
133+
/// use torrust_tracker_deployer_lib::domain::tracker::TrackerConfig;
134+
/// use std::net::{IpAddr, Ipv4Addr};
135+
///
136+
/// let tracker_config = TrackerConfig::default();
137+
/// let instance_ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
138+
///
139+
/// let endpoints = ServiceEndpoints::from_tracker_config(&tracker_config, instance_ip);
140+
/// ```
141+
#[must_use]
142+
pub fn from_tracker_config(
143+
tracker_config: &crate::domain::tracker::TrackerConfig,
144+
instance_ip: IpAddr,
145+
) -> Self {
146+
let udp_trackers = tracker_config
147+
.udp_trackers
148+
.iter()
149+
.filter_map(|udp| {
150+
Url::parse(&format!(
151+
"udp://{}:{}/announce",
152+
instance_ip,
153+
udp.bind_address.port()
154+
))
155+
.ok()
156+
})
157+
.collect();
158+
159+
let http_trackers = tracker_config
160+
.http_trackers
161+
.iter()
162+
.filter_map(|http| {
163+
Url::parse(&format!(
164+
"http://{}:{}/announce", // DevSkim: ignore DS137138
165+
instance_ip,
166+
http.bind_address.port()
167+
))
168+
.ok()
169+
})
170+
.collect();
171+
172+
let api_endpoint = Url::parse(&format!(
173+
"http://{}:{}/api", // DevSkim: ignore DS137138
174+
instance_ip,
175+
tracker_config.http_api.bind_address.port()
176+
))
177+
.ok();
178+
179+
let health_check_url = Url::parse(&format!(
180+
"http://{}:{}/health_check", // DevSkim: ignore DS137138
181+
instance_ip,
182+
tracker_config.health_check_api.bind_address.port()
183+
))
184+
.ok();
185+
186+
Self::new(udp_trackers, http_trackers, api_endpoint, health_check_url)
187+
}
188+
}
189+
60190
/// Runtime outputs generated during deployment operations
61191
///
62192
/// This struct contains fields that are generated during deployment operations
@@ -68,7 +198,6 @@ impl std::fmt::Display for ProvisionMethod {
68198
/// This struct is expected to grow as deployment operations become more complex:
69199
/// - `container_id: Option<String>` - Container/VM identifier
70200
/// - `resource_metrics: Option<ResourceMetrics>` - CPU, memory, disk usage
71-
/// - `service_endpoints: Option<Vec<ServiceEndpoint>>` - HTTP/TCP service URLs
72201
///
73202
/// # Examples
74203
///
@@ -79,6 +208,7 @@ impl std::fmt::Display for ProvisionMethod {
79208
/// let runtime_outputs = RuntimeOutputs {
80209
/// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))),
81210
/// provision_method: Some(ProvisionMethod::Provisioned),
211+
/// service_endpoints: None,
82212
/// };
83213
/// ```
84214
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -100,4 +230,14 @@ pub struct RuntimeOutputs {
100230
/// - `Some(Registered)`: Instance was connected via `register` command
101231
#[serde(default)]
102232
pub provision_method: Option<ProvisionMethod>,
233+
234+
/// Service endpoints populated after services are started
235+
///
236+
/// This field stores the URLs for all deployed tracker services. It is
237+
/// populated by the `run` command after services start successfully.
238+
///
239+
/// - `None`: Services not yet started or legacy state
240+
/// - `Some(endpoints)`: URLs for all running services
241+
#[serde(default)]
242+
pub service_endpoints: Option<ServiceEndpoints>,
103243
}

src/domain/environment/state/mod.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
use serde::{Deserialize, Serialize};
4444
use thiserror::Error;
4545

46-
use crate::domain::environment::runtime_outputs::ProvisionMethod;
46+
use crate::domain::environment::runtime_outputs::{ProvisionMethod, ServiceEndpoints};
4747

4848
// State modules
4949
mod common;
@@ -495,6 +495,20 @@ impl AnyEnvironmentState {
495495
self.context().runtime_outputs.provision_method
496496
}
497497

498+
/// Get the service endpoints if available, regardless of current state
499+
///
500+
/// This method provides access to the service endpoints without needing to
501+
/// pattern match on the specific state variant.
502+
///
503+
/// # Returns
504+
///
505+
/// - `Some(&ServiceEndpoints)` if services have been started and URLs are available
506+
/// - `None` if services haven't been started yet or URLs weren't recorded
507+
#[must_use]
508+
pub fn service_endpoints(&self) -> Option<&ServiceEndpoints> {
509+
self.context().runtime_outputs.service_endpoints.as_ref()
510+
}
511+
498512
/// Check if this environment was registered from existing infrastructure
499513
///
500514
/// Registered environments have infrastructure that was created externally

src/domain/environment/state/released.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
1010
use serde::{Deserialize, Serialize};
1111

12+
use crate::domain::environment::runtime_outputs::ServiceEndpoints;
1213
use crate::domain::environment::state::{AnyEnvironmentState, Running, StateTypeError};
1314
use crate::domain::environment::Environment;
1415

@@ -23,9 +24,27 @@ pub struct Released;
2324

2425
// State transition implementations
2526
impl Environment<Released> {
27+
/// Transitions from Released to Running state with service endpoints
28+
///
29+
/// This method indicates that the application has started running and stores
30+
/// the service endpoints for later display.
31+
///
32+
/// # Arguments
33+
///
34+
/// * `service_endpoints` - The URLs for all running services
35+
#[must_use]
36+
pub fn start_running_with_endpoints(
37+
mut self,
38+
service_endpoints: ServiceEndpoints,
39+
) -> Environment<Running> {
40+
self.context.runtime_outputs.service_endpoints = Some(service_endpoints);
41+
self.with_state(Running)
42+
}
43+
2644
/// Transitions from Released to Running state
2745
///
2846
/// This method indicates that the application has started running.
47+
/// Consider using `start_running_with_endpoints` to also store service URLs.
2948
#[must_use]
3049
pub fn start_running(self) -> Environment<Running> {
3150
self.with_state(Running)

src/domain/environment/testing.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ impl EnvironmentTestBuilder {
169169
runtime_outputs: crate::domain::environment::RuntimeOutputs {
170170
instance_ip: None,
171171
provision_method: None,
172+
service_endpoints: None,
172173
},
173174
};
174175

0 commit comments

Comments
 (0)