Skip to content

Commit d8673f5

Browse files
committed
feat: add instance_ip to Environment context (Proposal #0)
Add instance_ip as optional field in Environment struct to simplify command API by eliminating tuple return types. Changes: - Add instance_ip: Option<IpAddr> field to Environment<S> struct - Add instance_ip() getter and with_instance_ip() builder method - Update ProvisionCommand to return Environment<Provisioned> instead of tuple - Store IP in environment context using .with_instance_ip(ip) - Update all call sites to extract IP from environment when needed - Preserve instance_ip through state transitions and type erasure - Automatic serde serialization/deserialization support Benefits: - Simplified API: Result<Environment<T>, Error> vs Result<(Environment<T>, IpAddr), Error> - Better cohesion: IP is execution context, belongs with environment - Extensible: Easy to add more optional outputs without API changes - Consistent: Follows pattern of data_dir, build_dir in Environment Implementation of Proposal #0 from command-code-quality-improvements.md All tests pass, all linters pass.
1 parent 81cd3cc commit d8673f5

File tree

6 files changed

+121
-15
lines changed

6 files changed

+121
-15
lines changed

src/application/commands/provision.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ impl ProvisionCommand {
192192
pub async fn execute(
193193
&self,
194194
environment: Environment<Created>,
195-
) -> Result<(Environment<Provisioned>, IpAddr), ProvisionCommandError> {
195+
) -> Result<Environment<Provisioned>, ProvisionCommandError> {
196196
info!(
197197
command = "provision",
198198
environment = %environment.name(),
@@ -212,17 +212,20 @@ impl ProvisionCommand {
212212
// This allows us to know exactly which step failed if an error occurs
213213
match self.execute_provisioning_with_tracking(&environment).await {
214214
Ok((provisioned, instance_ip)) => {
215+
// Store instance IP in the environment context
216+
let provisioned = provisioned.with_instance_ip(instance_ip);
217+
215218
// Persist final state
216219
self.persist_provisioned_state(&provisioned);
217220

218221
info!(
219222
command = "provision",
220223
environment = %provisioned.name(),
221-
instance_ip = %instance_ip,
224+
instance_ip = ?provisioned.instance_ip(),
222225
"Infrastructure provisioning completed successfully"
223226
);
224227

225-
Ok((provisioned, instance_ip))
228+
Ok(provisioned)
226229
}
227230
Err((e, current_step)) => {
228231
// Transition to error state with structured context

src/bin/e2e_provision_tests.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,11 @@ async fn run_provisioning_test(env: &TestContext) -> Result<IpAddr> {
198198
"Starting infrastructure provisioning E2E test"
199199
);
200200

201-
let (_provisioned_env, instance_ip) = run_provision_command(env).await?;
201+
let provisioned_env = run_provision_command(env).await?;
202+
203+
let instance_ip = provisioned_env
204+
.instance_ip()
205+
.expect("Instance IP must be set after successful provisioning");
202206

203207
info!(
204208
status = "success",

src/bin/e2e_tests_full.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,12 @@ async fn run_full_deployment_test(env: &TestContext) -> Result<IpAddr> {
218218
);
219219

220220
// Provision infrastructure - returns typed Environment<Provisioned>
221-
let (provisioned_env, instance_ip) = run_provision_command(env).await?;
221+
let provisioned_env = run_provision_command(env).await?;
222+
223+
// Extract instance IP from the provisioned environment
224+
let instance_ip = provisioned_env
225+
.instance_ip()
226+
.expect("Instance IP must be set after successful provisioning");
222227

223228
// Configure infrastructure - requires Environment<Provisioned>, returns Environment<Configured>
224229
// This demonstrates compile-time type safety: cannot call configure without provisioning first

src/domain/environment/mod.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ pub use state::{
6868
use crate::domain::{InstanceName, ProfileName};
6969
use crate::shared::{ssh::SshCredentials, Username};
7070
use serde::{Deserialize, Serialize};
71+
use std::net::IpAddr;
7172
use std::path::PathBuf;
7273

7374
/// Directory name for trace files within an environment's data directory
@@ -125,6 +126,13 @@ pub struct Environment<S = Created> {
125126
/// Data directory for this environment (auto-generated)
126127
data_dir: PathBuf,
127128

129+
/// Instance IP address (populated after provisioning)
130+
///
131+
/// This field stores the IP address of the provisioned instance and is
132+
/// `None` until the environment has been successfully provisioned.
133+
/// Once set, it's carried through all subsequent state transitions.
134+
instance_ip: Option<IpAddr>,
135+
128136
/// Current state of the environment in the deployment lifecycle
129137
state: S,
130138
}
@@ -194,6 +202,7 @@ impl Environment {
194202
ssh_credentials,
195203
build_dir,
196204
data_dir,
205+
instance_ip: None,
197206
state: Created,
198207
}
199208
}
@@ -238,6 +247,7 @@ impl<S> Environment<S> {
238247
ssh_credentials: self.ssh_credentials,
239248
build_dir: self.build_dir,
240249
data_dir: self.data_dir,
250+
instance_ip: self.instance_ip,
241251
state: new_state,
242252
}
243253
}
@@ -318,6 +328,92 @@ impl<S> Environment<S> {
318328
&self.data_dir
319329
}
320330

331+
/// Returns the instance IP address if available
332+
///
333+
/// The instance IP is populated after successful provisioning and is
334+
/// `None` for environments that haven't been provisioned yet.
335+
///
336+
/// # Returns
337+
///
338+
/// - `Some(IpAddr)` if the environment has been provisioned
339+
/// - `None` if the environment hasn't been provisioned yet
340+
///
341+
/// # Examples
342+
///
343+
/// ```rust
344+
/// use torrust_tracker_deploy::domain::{Environment, EnvironmentName};
345+
/// use torrust_tracker_deploy::shared::{Username, ssh::SshCredentials};
346+
/// use std::path::PathBuf;
347+
/// use std::net::{IpAddr, Ipv4Addr};
348+
///
349+
/// let env_name = EnvironmentName::new("test".to_string())?;
350+
/// let ssh_username = Username::new("torrust".to_string())?;
351+
/// let ssh_credentials = SshCredentials::new(
352+
/// PathBuf::from("keys/test_rsa"),
353+
/// PathBuf::from("keys/test_rsa.pub"),
354+
/// ssh_username,
355+
/// );
356+
/// let environment = Environment::new(env_name, ssh_credentials);
357+
///
358+
/// // Before provisioning
359+
/// assert_eq!(environment.instance_ip(), None);
360+
///
361+
/// // After provisioning (simulated)
362+
/// let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100));
363+
/// let environment = environment.with_instance_ip(ip);
364+
/// assert_eq!(environment.instance_ip(), Some(ip));
365+
///
366+
/// # Ok::<(), Box<dyn std::error::Error>>(())
367+
/// ```
368+
#[must_use]
369+
pub fn instance_ip(&self) -> Option<IpAddr> {
370+
self.instance_ip
371+
}
372+
373+
/// Sets the instance IP address for this environment
374+
///
375+
/// This method is typically called by the `ProvisionCommand` after successfully
376+
/// provisioning the infrastructure and obtaining the instance's IP address.
377+
///
378+
/// # Arguments
379+
///
380+
/// * `ip` - The IP address of the provisioned instance
381+
///
382+
/// # Returns
383+
///
384+
/// A new Environment instance with the IP address set
385+
///
386+
/// # Examples
387+
///
388+
/// ```rust
389+
/// use torrust_tracker_deploy::domain::{Environment, EnvironmentName};
390+
/// use torrust_tracker_deploy::shared::{Username, ssh::SshCredentials};
391+
/// use std::path::PathBuf;
392+
/// use std::net::{IpAddr, Ipv4Addr};
393+
///
394+
/// let env_name = EnvironmentName::new("production".to_string())?;
395+
/// let ssh_username = Username::new("torrust".to_string())?;
396+
/// let ssh_credentials = SshCredentials::new(
397+
/// PathBuf::from("keys/prod_rsa"),
398+
/// PathBuf::from("keys/prod_rsa.pub"),
399+
/// ssh_username,
400+
/// );
401+
/// let environment = Environment::new(env_name, ssh_credentials);
402+
///
403+
/// // Set IP after provisioning
404+
/// let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 42));
405+
/// let environment = environment.with_instance_ip(ip);
406+
///
407+
/// assert_eq!(environment.instance_ip(), Some(ip));
408+
///
409+
/// # Ok::<(), Box<dyn std::error::Error>>(())
410+
/// ```
411+
#[must_use]
412+
pub fn with_instance_ip(mut self, ip: IpAddr) -> Self {
413+
self.instance_ip = Some(ip);
414+
self
415+
}
416+
321417
/// Returns the templates directory for this environment
322418
///
323419
/// The templates directory is located at `data/{env_name}/templates/`
@@ -643,6 +739,7 @@ mod tests {
643739
ssh_credentials,
644740
data_dir: data_dir.clone(),
645741
build_dir: build_dir.clone(),
742+
instance_ip: None,
646743
state: Created,
647744
};
648745

src/domain/environment/testing.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ impl EnvironmentTestBuilder {
136136
ssh_credentials,
137137
data_dir: data_dir.clone(),
138138
build_dir: build_dir.clone(),
139+
instance_ip: None,
139140
state: Created,
140141
};
141142

src/e2e/tasks/virtual_machine/run_provision_command.rs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
//! the foundation infrastructure for all subsequent testing operations.
2424
2525
use anyhow::{Context, Result};
26-
use std::net::IpAddr;
2726
use std::sync::Arc;
2827
use tracing::info;
2928

@@ -32,8 +31,8 @@ use crate::e2e::context::TestContext;
3231

3332
/// Provision infrastructure using `OpenTofu` and prepare for configuration
3433
///
35-
/// Returns both the provisioned environment (for type-safe command chaining) and
36-
/// the IP address (for validation tasks).
34+
/// Returns the provisioned environment with the instance IP stored in its context.
35+
/// Callers can extract the IP address using `environment.instance_ip()`.
3736
///
3837
/// # Errors
3938
///
@@ -43,10 +42,7 @@ use crate::e2e::context::TestContext;
4342
/// - IP address cannot be obtained from `OpenTofu` outputs
4443
pub async fn run_provision_command(
4544
test_context: &TestContext,
46-
) -> Result<(
47-
crate::domain::Environment<crate::domain::environment::Provisioned>,
48-
IpAddr,
49-
)> {
45+
) -> Result<crate::domain::Environment<crate::domain::environment::Provisioned>> {
5046
info!("Provisioning test infrastructure");
5147

5248
// Create repository for this environment
@@ -63,7 +59,7 @@ pub async fn run_provision_command(
6359
);
6460

6561
// Execute provisioning with environment in Created state
66-
let (provisioned_env, instance_ip) = provision_command
62+
let provisioned_env = provision_command
6763
.execute(test_context.environment.clone())
6864
.await
6965
.map_err(anyhow::Error::from)
@@ -72,9 +68,9 @@ pub async fn run_provision_command(
7268
info!(
7369
status = "complete",
7470
environment = %provisioned_env.name(),
75-
instance_ip = %instance_ip,
71+
instance_ip = ?provisioned_env.instance_ip(),
7672
"Instance provisioned successfully"
7773
);
7874

79-
Ok((provisioned_env, instance_ip))
75+
Ok(provisioned_env)
8076
}

0 commit comments

Comments
 (0)