torrust_tracker_deployer_lib/testing/e2e/containers/
provisioned.rs1use std::net::{IpAddr, Ipv4Addr, SocketAddr};
62use std::time::Duration;
63use testcontainers::{core::WaitFor, runners::AsyncRunner, ContainerAsync, GenericImage, ImageExt};
64use tracing::info;
65
66use super::config_builder::ContainerConfigBuilder;
67#[cfg(test)]
68use super::errors::ContainerNetworkingError;
69use super::errors::{ContainerError, ContainerImageError, ContainerRuntimeError, Result};
70use super::executor::ContainerExecutor;
71use super::image_builder::ContainerImageBuilder;
72use super::timeout::ContainerTimeouts;
73
74const DEFAULT_IMAGE_NAME: &str = "torrust-provisioned-instance";
76
77const DEFAULT_IMAGE_TAG: &str = "latest";
79
80#[derive(Debug)]
86pub struct StoppedProvisionedContainer {
87 pub timeouts: ContainerTimeouts,
89}
90
91#[allow(clippy::derivable_impls)]
92impl Default for StoppedProvisionedContainer {
93 fn default() -> Self {
94 Self {
95 timeouts: ContainerTimeouts::default(),
96 }
97 }
98}
99
100impl StoppedProvisionedContainer {
101 #[must_use]
117 pub fn with_timeouts(timeouts: ContainerTimeouts) -> Self {
118 Self { timeouts }
119 }
120
121 #[must_use]
138 pub fn with_ssh_ready_timeout(ssh_ready_timeout: Duration) -> Self {
139 let timeouts = ContainerTimeouts {
140 ssh_ready: ssh_ready_timeout,
141 ..ContainerTimeouts::default()
142 };
143 Self { timeouts }
144 }
145
146 fn build_image(docker_build_timeout: Duration) -> Result<()> {
148 let builder = ContainerImageBuilder::new()
149 .with_name(DEFAULT_IMAGE_NAME)
150 .with_tag(DEFAULT_IMAGE_TAG)
151 .with_dockerfile(std::path::PathBuf::from(
152 "docker/provisioned-instance/Dockerfile",
153 ))
154 .with_context(std::path::PathBuf::from("docker/provisioned-instance"))
155 .with_build_timeout(docker_build_timeout);
156 builder.build().map_err(|e| {
157 Box::new(ContainerError::ContainerImage {
158 source: ContainerImageError::BuildFailed {
159 image_name: DEFAULT_IMAGE_NAME.to_string(),
160 image_tag: DEFAULT_IMAGE_TAG.to_string(),
161 reason: "Docker image build process failed".to_string(),
162 source: *e,
163 },
164 })
165 })?;
166 Ok(())
167 }
168
169 pub async fn start(
184 self,
185 container_name: Option<String>,
186 ssh_port: u16,
187 additional_ports: &[u16],
188 ) -> Result<RunningProvisionedContainer> {
189 Self::build_image(self.timeouts.docker_build)?;
191
192 info!(
193 ssh_port = %ssh_port,
194 additional_ports = ?additional_ports,
195 "Starting provisioned instance container with Docker-in-Docker support"
196 );
197
198 let mut config_builder =
201 ContainerConfigBuilder::new(format!("{DEFAULT_IMAGE_NAME}:{DEFAULT_IMAGE_TAG}"))
202 .with_exposed_port(ssh_port)
203 .with_wait_condition(WaitFor::message_on_stdout("dockerd entered RUNNING state"));
204
205 for port in additional_ports {
207 config_builder = config_builder.with_exposed_port(*port);
208 }
209
210 let image = config_builder.build().map_err(|source| {
211 Box::new(ContainerError::ContainerRuntime {
212 source: ContainerRuntimeError::InvalidConfiguration {
213 image_name: DEFAULT_IMAGE_NAME.to_string(),
214 image_tag: DEFAULT_IMAGE_TAG.to_string(),
215 reason: "Container configuration validation failed".to_string(),
216 source: *source,
217 },
218 })
219 })?;
220
221 let container = if let Some(name) = container_name {
224 info!(container_name = %name, "Starting container with custom name and privileged mode");
225 image
226 .with_privileged(true)
227 .with_container_name(name)
228 .start()
229 .await
230 } else {
231 image.with_privileged(true).start().await
232 }
233 .map_err(|source| {
234 Box::new(ContainerError::ContainerRuntime {
235 source: ContainerRuntimeError::StartupFailed {
236 image_name: DEFAULT_IMAGE_NAME.to_string(),
237 image_tag: DEFAULT_IMAGE_TAG.to_string(),
238 reason: "Container failed to start or reach expected state".to_string(),
239 source,
240 },
241 })
242 })?;
243
244 let mapped_ssh_port = container.get_host_port_ipv4(ssh_port).await.map_err(|e| {
246 Box::new(ContainerError::ContainerRuntime {
247 source: ContainerRuntimeError::StartupFailed {
248 image_name: DEFAULT_IMAGE_NAME.to_string(),
249 image_tag: DEFAULT_IMAGE_TAG.to_string(),
250 reason: format!("Failed to get mapped SSH port: {e}"),
251 source: e,
252 },
253 })
254 })?;
255
256 let mut mapped_additional_ports = Vec::new();
258 for port in additional_ports {
259 let mapped_port = container.get_host_port_ipv4(*port).await.map_err(|e| {
260 Box::new(ContainerError::ContainerRuntime {
261 source: ContainerRuntimeError::StartupFailed {
262 image_name: DEFAULT_IMAGE_NAME.to_string(),
263 image_tag: DEFAULT_IMAGE_TAG.to_string(),
264 reason: format!("Failed to get mapped port for {port}: {e}"),
265 source: e,
266 },
267 })
268 })?;
269 mapped_additional_ports.push(mapped_port);
270 }
271
272 info!(
273 container_id = %container.id(),
274 mapped_ssh_port,
275 mapped_additional_ports = ?mapped_additional_ports,
276 "Container started successfully with bridge networking"
277 );
278
279 Ok(RunningProvisionedContainer::new(
280 container,
281 mapped_ssh_port,
282 mapped_additional_ports,
283 ))
284 }
285}
286
287pub struct RunningProvisionedContainer {
289 container: ContainerAsync<GenericImage>,
290 ssh_port: u16,
291 additional_mapped_ports: Vec<u16>,
292}
293
294impl ContainerExecutor for RunningProvisionedContainer {
295 async fn exec(
296 &self,
297 command: testcontainers::core::ExecCommand,
298 ) -> std::result::Result<(), testcontainers::TestcontainersError> {
299 self.container.exec(command).await.map(|_| ())
300 }
301}
302
303impl RunningProvisionedContainer {
304 pub(crate) fn new(
305 container: ContainerAsync<GenericImage>,
306 ssh_port: u16,
307 additional_mapped_ports: Vec<u16>,
308 ) -> Self {
309 Self {
310 container,
311 ssh_port,
312 additional_mapped_ports,
313 }
314 }
315
316 #[must_use]
318 pub fn ssh_socket_addr(&self) -> SocketAddr {
319 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), self.ssh_port)
320 }
321
322 #[must_use]
325 pub fn additional_mapped_ports(&self) -> &[u16] {
326 &self.additional_mapped_ports
327 }
328
329 #[must_use]
331 pub fn container_id(&self) -> &str {
332 self.container.id()
333 }
334
335 pub fn stop(self) -> StoppedProvisionedContainer {
337 info!(container_id = %self.container.id(), "Stopping container");
338 StoppedProvisionedContainer::default()
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use std::error::Error;
347
348 #[test]
349 fn it_should_create_default_stopped_container() {
350 let container = StoppedProvisionedContainer::default();
351 assert!(std::ptr::eq(
352 std::ptr::addr_of!(container),
353 std::ptr::addr_of!(container)
354 )); }
356
357 #[test]
358 fn it_should_have_proper_error_display_messages() {
359 let error = ContainerError::ContainerImage {
360 source: ContainerImageError::BuildFailed {
361 image_name: "test-image".to_string(),
362 image_tag: "test-tag".to_string(),
363 reason: "Docker build compilation failed".to_string(),
364 source: crate::testing::e2e::containers::image_builder::ContainerBuildError::ContainerBuildFailed {
365 image_name: "test-image".to_string(),
366 tag: "test-tag".to_string(),
367 dockerfile_path: "/path/to/Dockerfile".to_string(),
368 context_path: "/build/context".to_string(),
369 build_duration_secs: 60,
370 stderr: "test error message".to_string(),
371 },
372 },
373 };
374 assert!(error.to_string().contains("Container image problem"));
375 assert!(error.to_string().contains("Failed to build Docker image"));
376 assert!(error.to_string().contains("test-image:test-tag"));
377 assert!(error
378 .to_string()
379 .contains("Docker build compilation failed"));
380 }
381
382 #[test]
383 fn it_should_preserve_error_chain_for_docker_command_execution() {
384 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "docker not found");
385 let image_error = ContainerImageError::DockerCommandFailed {
386 image_name: "test-image".to_string(),
387 image_tag: "test-tag".to_string(),
388 reason: "Docker daemon not available".to_string(),
389 source: io_error,
390 };
391 let error = ContainerError::ContainerImage {
392 source: image_error,
393 };
394
395 assert!(error
396 .to_string()
397 .contains("Docker command execution failed"));
398 assert!(error.to_string().contains("test-image:test-tag"));
399 assert!(error.to_string().contains("Docker daemon not available"));
400 assert!(error.source().is_some());
401 }
402
403 #[test]
404 fn it_should_convert_docker_build_error_to_provisioned_container_error() {
405 use crate::testing::e2e::containers::image_builder::ContainerBuildError;
406
407 let docker_build_error = ContainerBuildError::ContainerBuildFailed {
408 image_name: "test-image".to_string(),
409 tag: "v1.0".to_string(),
410 dockerfile_path: "/path/to/Dockerfile".to_string(),
411 context_path: "/build/context".to_string(),
412 build_duration_secs: 60,
413 stderr: "build failed".to_string(),
414 };
415
416 let image_error = ContainerImageError::BuildFailed {
417 image_name: "test-image".to_string(),
418 image_tag: "v1.0".to_string(),
419 reason: "Docker image build process failed".to_string(),
420 source: docker_build_error,
421 };
422 let provisioned_error = ContainerError::ContainerImage {
423 source: image_error,
424 };
425
426 assert!(provisioned_error
427 .to_string()
428 .contains("Container image problem"));
429 assert!(std::error::Error::source(&provisioned_error).is_some());
430 }
431
432 #[test]
433 fn it_should_group_networking_errors_logically() {
434 let testcontainers_error = testcontainers::TestcontainersError::other("port conflict");
436 let networking_error = ContainerNetworkingError::PortMappingFailed {
437 container_id: "container123".to_string(),
438 internal_port: 22,
439 reason: "Port already in use".to_string(),
440 source: testcontainers_error,
441 };
442 let provisioned_error = ContainerError::ContainerNetworking {
443 source: networking_error,
444 };
445
446 assert!(provisioned_error
447 .to_string()
448 .contains("Container networking problem"));
449 assert!(provisioned_error
450 .to_string()
451 .contains("Failed to get mapped port 22"));
452 assert!(provisioned_error
453 .to_string()
454 .contains("Port already in use"));
455 }
456
457 #[test]
458 fn it_should_group_runtime_errors_logically() {
459 let testcontainers_error = testcontainers::TestcontainersError::other("resource limit");
460 let runtime_error = ContainerRuntimeError::StartupFailed {
461 image_name: "test-image".to_string(),
462 image_tag: "latest".to_string(),
463 reason: "Insufficient memory".to_string(),
464 source: testcontainers_error,
465 };
466 let provisioned_error = ContainerError::ContainerRuntime {
467 source: runtime_error,
468 };
469
470 assert!(provisioned_error
471 .to_string()
472 .contains("Container runtime problem"));
473 assert!(provisioned_error
474 .to_string()
475 .contains("Container failed to start"));
476 assert!(provisioned_error
477 .to_string()
478 .contains("Insufficient memory"));
479 }
480
481 #[test]
482 fn it_should_allow_matching_on_logical_error_categories() {
483 let image_error = ContainerImageError::BuildFailed {
484 image_name: "test".to_string(),
485 image_tag: "latest".to_string(),
486 reason: "Build failed".to_string(),
487 source: crate::testing::e2e::containers::image_builder::ContainerBuildError::ImageNameRequired,
488 };
489 let error = ContainerError::ContainerImage {
490 source: image_error,
491 };
492
493 match error {
495 ContainerError::ContainerImage { .. } => {
496 }
498 ContainerError::ContainerRuntime { .. } => {
499 panic!("Should not match runtime category");
500 }
501 ContainerError::ContainerNetworking { .. } => {
502 panic!("Should not match networking category");
503 }
504 ContainerError::SshSetup { .. } => {
505 panic!("Should not match SSH category");
506 }
507 }
508 }
509
510 }