Skip to content

Commit 8cf7d81

Browse files
committed
feat: enhance E2E testing with dynamic port mapping and SSH infrastructure
- Add dynamic port mapping for Docker containers with AnsiblePort type - Enhance SSH infrastructure to support custom ports (SshConnection, SshClient) - Improve Docker validation with proper error handling instead of warnings - Simplify Docker/Docker Compose installation playbooks using Ubuntu repositories - Update Ansible inventory templates to support custom SSH ports - Add comprehensive E2E testing for both provision and configuration workflows - Fix YAML linting issues in Docker Compose templates - Update project words list for new technical terms This enables reliable E2E testing with proper port isolation and reduces network complexity in CI environments. All tests now pass in ~60 seconds vs previous 8+ minute hangs.
1 parent afa62b6 commit 8cf7d81

File tree

22 files changed

+564
-685
lines changed

22 files changed

+564
-685
lines changed

.yamllint-ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ rules:
1010
allowed-values: ["true", "false", "yes", "no", "on", "off"] # Allow cloud-init and GitHub Actions common values
1111

1212
# Ignore cloud-init files for comment spacing since #cloud-config is a special directive
13+
# Ignore build directory with generated files
1314
ignore: |
1415
**/cloud-init.yml
16+
build/**

project-words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ containerd
99
cpus
1010
dearmor
1111
debootstrap
12+
distutils
1213
Dockerfiles
1314
dtolnay
1415
ehthumbs

src/application/commands/provision.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ impl ProvisionCommand {
150150
Arc::clone(&self.ansible_template_renderer),
151151
self.ssh_credentials.clone(),
152152
instance_ip,
153+
22, // Default SSH port for VMs
153154
)
154155
.execute()
155156
.await?;

src/application/steps/rendering/ansible_templates.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ use crate::infrastructure::adapters::ssh::credentials::SshCredentials;
2828
use crate::infrastructure::ansible::template::renderer::ConfigurationTemplateError;
2929
use crate::infrastructure::ansible::AnsibleTemplateRenderer;
3030
use crate::infrastructure::template::wrappers::ansible::inventory::{
31-
AnsibleHost, InventoryContext, InventoryContextError, SshPrivateKeyFile, SshPrivateKeyFileError,
31+
AnsibleHost, AnsiblePort, AnsiblePortError, InventoryContext, InventoryContextError,
32+
SshPrivateKeyFile, SshPrivateKeyFileError,
3233
};
3334

3435
/// Errors that can occur during Ansible template rendering step execution
@@ -38,6 +39,10 @@ pub enum RenderAnsibleTemplatesError {
3839
#[error("SSH key path parsing failed: {0}")]
3940
SshKeyPathError(#[from] SshPrivateKeyFileError),
4041

42+
/// SSH port parsing failed
43+
#[error("SSH port parsing failed: {0}")]
44+
SshPortError(#[from] AnsiblePortError),
45+
4146
/// Inventory context creation failed
4247
#[error("Inventory context creation failed: {0}")]
4348
InventoryContextError(#[from] InventoryContextError),
@@ -52,6 +57,7 @@ pub struct RenderAnsibleTemplatesStep {
5257
ansible_template_renderer: Arc<AnsibleTemplateRenderer>,
5358
ssh_credentials: SshCredentials,
5459
instance_ip: IpAddr,
60+
ssh_port: u16,
5561
}
5662

5763
impl RenderAnsibleTemplatesStep {
@@ -60,11 +66,13 @@ impl RenderAnsibleTemplatesStep {
6066
ansible_template_renderer: Arc<AnsibleTemplateRenderer>,
6167
ssh_credentials: SshCredentials,
6268
instance_ip: IpAddr,
69+
ssh_port: u16,
6370
) -> Self {
6471
Self {
6572
ansible_template_renderer,
6673
ssh_credentials,
6774
instance_ip,
75+
ssh_port,
6876
}
6977
}
7078

@@ -112,10 +120,12 @@ impl RenderAnsibleTemplatesStep {
112120
fn create_inventory_context(&self) -> Result<InventoryContext, RenderAnsibleTemplatesError> {
113121
let host = AnsibleHost::from(self.instance_ip);
114122
let ssh_key = SshPrivateKeyFile::new(&self.ssh_credentials.ssh_priv_key_path)?;
123+
let ssh_port = AnsiblePort::new(self.ssh_port)?;
115124

116125
InventoryContext::builder()
117126
.with_host(host)
118127
.with_ssh_priv_key_path(ssh_key)
128+
.with_ssh_port(ssh_port)
119129
.build()
120130
.map_err(RenderAnsibleTemplatesError::from)
121131
}

src/bin/e2e_config_tests.rs

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@
2525
2626
use anyhow::{Context, Result};
2727
use clap::Parser;
28-
use std::net::IpAddr;
2928
use std::sync::Arc;
3029
use std::time::Instant;
3130
use tokio::runtime::Runtime;
3231
use tracing::{error, info};
3332

34-
use torrust_tracker_deploy::application::commands::{ConfigureCommand, TestCommand};
33+
use torrust_tracker_deploy::application::commands::ConfigureCommand;
3534
use torrust_tracker_deploy::config::{Config, InstanceName, SshCredentials};
3635
use torrust_tracker_deploy::container::Services;
3736
use torrust_tracker_deploy::e2e::environment::TestEnvironment;
@@ -289,34 +288,25 @@ async fn run_deployment_validation(
289288
"Running deployment validation on container"
290289
);
291290

292-
// NOTE: Similar to configuration, validation needs container-specific setup.
293-
// The TestCommand expects standard SSH credentials but needs to connect to
294-
// 127.0.0.1:mapped_port instead of the provisioned instance IP.
295-
//
296-
// For now, we'll demonstrate the validation workflow structure:
297-
291+
// Now we can use the proper SSH infrastructure with custom port support
298292
let credentials_result = create_container_ssh_credentials();
299293
match credentials_result {
300294
Ok(ssh_credentials) => {
301-
let instance_ip: IpAddr = ssh_host
302-
.parse()
303-
.context("Failed to parse SSH host as IP address")?;
304-
let test_command = TestCommand::new(ssh_credentials, instance_ip);
295+
// Create SSH connection with the container's dynamic port
296+
let host_ip = ssh_host.parse().context("Failed to parse SSH host as IP")?;
305297

306-
match test_command.execute().await.map_err(anyhow::Error::from) {
298+
match validate_container_deployment_with_port(&ssh_credentials, host_ip, ssh_port).await
299+
{
307300
Ok(()) => {
308301
info!(status = "success", "All deployment validations passed");
309302
}
310303
Err(e) => {
311-
// Expected failure due to SSH connection issues - log and return error
312304
info!(
313-
status = "expected_failure",
305+
status = "failed",
314306
error = %e,
315-
note = "TestCommand failed as expected - needs container-specific SSH setup"
307+
"Container deployment validation failed"
316308
);
317-
return Err(e.context(
318-
"Validation failed (expected - needs container-specific SSH setup)",
319-
));
309+
return Err(e.context("Container deployment validation failed"));
320310
}
321311
}
322312
}
@@ -326,8 +316,8 @@ async fn run_deployment_validation(
326316
}
327317

328318
info!(
329-
status = "structural_complete",
330-
"Validation workflow structure implemented"
319+
status = "success",
320+
"Validation workflow completed successfully"
331321
);
332322

333323
Ok(())
@@ -373,3 +363,35 @@ fn create_container_ssh_credentials() -> Result<SshCredentials> {
373363

374364
Ok(ssh_credentials)
375365
}
366+
367+
/// Validate container deployment using SSH infrastructure with custom port
368+
async fn validate_container_deployment_with_port(
369+
ssh_credentials: &SshCredentials,
370+
host_ip: std::net::IpAddr,
371+
ssh_port: u16,
372+
) -> Result<()> {
373+
use torrust_tracker_deploy::infrastructure::remote_actions::{
374+
DockerComposeValidator, DockerValidator, RemoteAction,
375+
};
376+
377+
// Create SSH connection with the container's dynamic port using the new port support
378+
let ssh_connection = ssh_credentials
379+
.clone()
380+
.with_host_and_port(host_ip, ssh_port);
381+
382+
// Validate Docker installation
383+
let docker_validator = DockerValidator::new(ssh_connection.clone());
384+
docker_validator
385+
.execute(&host_ip)
386+
.await
387+
.context("Docker validation failed")?;
388+
389+
// Validate Docker Compose installation
390+
let compose_validator = DockerComposeValidator::new(ssh_connection);
391+
compose_validator
392+
.execute(&host_ip)
393+
.await
394+
.context("Docker Compose validation failed")?;
395+
396+
Ok(())
397+
}

src/e2e/provisioned_container.rs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use std::time::Duration;
3939
use testcontainers::{
4040
core::{IntoContainerPort, WaitFor},
4141
runners::SyncRunner,
42-
Container, GenericImage, ImageExt,
42+
Container, GenericImage,
4343
};
4444
use tracing::info;
4545

@@ -91,18 +91,17 @@ impl StoppedProvisionedContainer {
9191

9292
info!("Starting provisioned instance container");
9393

94-
// Create and start the container with fixed port mapping (22:22)
94+
// Create and start the container with automatic port mapping
9595
let image = GenericImage::new("torrust-provisioned-instance", "latest")
9696
.with_exposed_port(22.tcp())
9797
.with_wait_for(WaitFor::message_on_stdout("sshd entered RUNNING state"));
9898

99-
let container = image
100-
.with_mapped_port(22, 22.tcp())
101-
.start()
102-
.context("Failed to start container")?;
99+
let container = image.start().context("Failed to start container")?;
103100

104-
// Use fixed port 22 since we're mapping 22:22
105-
let ssh_port = 22_u16;
101+
// Get the actual mapped port from testcontainers
102+
let ssh_port = container
103+
.get_host_port_ipv4(22.tcp())
104+
.context("Failed to get mapped SSH port")?;
106105

107106
info!(
108107
container_id = %container.id(),

src/e2e/tasks/provision_docker_infrastructure.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,15 @@ pub async fn provision_docker_infrastructure(
5757

5858
// Step 1: Render Ansible templates with container connection details
5959
info!("Rendering Ansible templates for container");
60-
RenderAnsibleTemplatesStep::new(ansible_template_renderer, ssh_credentials, container_ip)
61-
.execute()
62-
.await
63-
.context("Failed to render Ansible templates for container")?;
60+
RenderAnsibleTemplatesStep::new(
61+
ansible_template_renderer,
62+
ssh_credentials,
63+
container_ip,
64+
ssh_port,
65+
)
66+
.execute()
67+
.await
68+
.context("Failed to render Ansible templates for container")?;
6469

6570
// Note: SSH connectivity check is skipped for Docker containers since
6671
// the container setup process already ensures SSH is ready and accessible

src/infrastructure/adapters/ssh/client.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ impl SshClient {
6161
"StrictHostKeyChecking=no".to_string(),
6262
"-o".to_string(),
6363
"UserKnownHostsFile=/dev/null".to_string(),
64+
"-p".to_string(),
65+
self.ssh_connection.ssh_port().to_string(),
6466
];
6567

6668
// Add additional SSH options

src/infrastructure/adapters/ssh/connection.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ pub struct SshConnection {
3232
/// This is the IP address of the remote instance that the SSH client
3333
/// will connect to.
3434
pub host_ip: IpAddr,
35+
36+
/// Port number for SSH connections.
37+
///
38+
/// Defaults to 22 (standard SSH port) but can be customized for
39+
/// containerized environments or non-standard SSH configurations.
40+
pub port: u16,
3541
}
3642

3743
impl SshConnection {
@@ -49,13 +55,60 @@ impl SshConnection {
4955
/// let connection = SshConnection::new(
5056
/// credentials,
5157
/// IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
58+
/// 22,
59+
/// );
60+
/// ```
61+
#[must_use]
62+
pub fn new(credentials: SshCredentials, host_ip: IpAddr, port: u16) -> Self {
63+
Self::new_with_port(credentials, host_ip, port)
64+
}
65+
66+
/// Creates a new SSH connection configuration with the default port (22).
67+
///
68+
/// This is a convenience method for when you want to use the standard SSH port.
69+
///
70+
/// ```rust
71+
/// # use std::net::{IpAddr, Ipv4Addr};
72+
/// # use std::path::PathBuf;
73+
/// # use torrust_tracker_deploy::infrastructure::adapters::ssh::{SshCredentials, SshConnection};
74+
/// let credentials = SshCredentials::new(
75+
/// PathBuf::from("/home/user/.ssh/deploy_key"),
76+
/// PathBuf::from("/home/user/.ssh/deploy_key.pub"),
77+
/// "ubuntu".to_string(),
78+
/// );
79+
/// let connection = SshConnection::with_default_port(
80+
/// credentials,
81+
/// IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
5282
/// );
5383
/// ```
5484
#[must_use]
55-
pub fn new(credentials: SshCredentials, host_ip: IpAddr) -> Self {
85+
pub fn with_default_port(credentials: SshCredentials, host_ip: IpAddr) -> Self {
86+
Self::new(credentials, host_ip, 22)
87+
}
88+
89+
/// Creates a new SSH connection configuration with a custom port.
90+
///
91+
/// ```rust
92+
/// # use std::net::{IpAddr, Ipv4Addr};
93+
/// # use std::path::PathBuf;
94+
/// # use torrust_tracker_deploy::infrastructure::adapters::ssh::{SshCredentials, SshConnection};
95+
/// let credentials = SshCredentials::new(
96+
/// PathBuf::from("/home/user/.ssh/deploy_key"),
97+
/// PathBuf::from("/home/user/.ssh/deploy_key.pub"),
98+
/// "ubuntu".to_string(),
99+
/// );
100+
/// let connection = SshConnection::new_with_port(
101+
/// credentials,
102+
/// IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
103+
/// 2222,
104+
/// );
105+
/// ```
106+
#[must_use]
107+
pub fn new_with_port(credentials: SshCredentials, host_ip: IpAddr, port: u16) -> Self {
56108
Self {
57109
credentials,
58110
host_ip,
111+
port,
59112
}
60113
}
61114

@@ -76,4 +129,10 @@ impl SshConnection {
76129
pub fn ssh_username(&self) -> &str {
77130
&self.credentials.ssh_username
78131
}
132+
133+
/// Access the SSH port.
134+
#[must_use]
135+
pub fn ssh_port(&self) -> u16 {
136+
self.port
137+
}
79138
}

src/infrastructure/adapters/ssh/credentials.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,12 @@ impl SshCredentials {
7676
/// with a specific host IP address.
7777
#[must_use]
7878
pub fn with_host(self, host_ip: IpAddr) -> SshConnection {
79-
SshConnection {
80-
credentials: self,
81-
host_ip,
82-
}
79+
SshConnection::with_default_port(self, host_ip)
80+
}
81+
82+
/// Create an SSH connection with a custom port
83+
#[must_use]
84+
pub fn with_host_and_port(self, host_ip: IpAddr, port: u16) -> SshConnection {
85+
SshConnection::new(self, host_ip, port)
8386
}
8487
}

0 commit comments

Comments
 (0)