diff --git a/.github/workflows/docker-security-scan.yml b/.github/workflows/docker-security-scan.yml index 4eaf5b684..4d7540e11 100644 --- a/.github/workflows/docker-security-scan.yml +++ b/.github/workflows/docker-security-scan.yml @@ -6,16 +6,18 @@ on: paths: - "docker/**" - "templates/docker-compose/**" + - "src/**" - ".github/workflows/docker-security-scan.yml" pull_request: paths: - "docker/**" - "templates/docker-compose/**" + - "src/**" - ".github/workflows/docker-security-scan.yml" # Scheduled scans are important because new CVEs appear - # even if the code or images didn’t change + # even if the code or images didn't change schedule: - cron: "0 6 * * *" # Daily at 6 AM UTC @@ -60,7 +62,7 @@ jobs: ${{ matrix.image.context }} # Human-readable output in logs - # This NEVER fails the job; it’s only for visibility + # This NEVER fails the job; it's only for visibility - name: Display vulnerabilities (table format) uses: aquasecurity/trivy-action@0.35.0 with: @@ -93,8 +95,98 @@ jobs: path: trivy-${{ matrix.image.name }}.sarif retention-days: 30 + extract-images: + name: Extract Third-Party Docker Images from Source + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + # JSON array of Docker image references for use in scan matrix + # Example: ["torrust/tracker:develop","mysql:8.4","prom/prometheus:v3.5.0","grafana/grafana:12.3.1","caddy:2.10"] + images: ${{ steps.extract.outputs.images }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Build deployer CLI + run: cargo build --release + + # Creates a minimal environment config with all optional services + # enabled so that all third-party Docker images appear in the output. + # - MySQL: enables mysql image in docker_images output + # - Prometheus: enables prometheus image in docker_images output + # - Grafana: enables grafana image in docker_images output + # Uses fixture SSH keys (already committed to the repository). + - name: Create environment config for image extraction + run: | + cat > /tmp/ci-images-env.json <> "$GITHUB_OUTPUT" + scan-third-party-images: name: Scan Third-Party Docker Images + needs: extract-images runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -103,14 +195,9 @@ jobs: strategy: fail-fast: false matrix: - # These must match docker-compose templates - # in templates/docker-compose/docker-compose.yml.tera - image: - - torrust/tracker:develop - - mysql:8.0 - - grafana/grafana:11.4.0 - - prom/prometheus:v3.0.1 - - caddy:2.10 + # Dynamic image list extracted from the deployer CLI at build time. + # Images come from domain config constants — no manual maintenance needed. + image: ${{ fromJson(needs.extract-images.outputs.images) }} steps: - name: Display vulnerabilities (table format) @@ -154,7 +241,7 @@ jobs: - scan-project-images - scan-third-party-images - # Always run so we don’t lose security visibility + # Always run so we don't lose security visibility if: always() permissions: @@ -168,7 +255,6 @@ jobs: # Upload each SARIF file with CodeQL Action using unique categories. # The category parameter enables proper alert tracking per image. - # Must use CodeQL Action (not gh API) - API doesn't support category field. # # VIEWING RESULTS: # - For pull requests: /security/code-scanning?query=pr:NUMBER+is:open @@ -192,42 +278,41 @@ jobs: category: docker-project-ssh-server continue-on-error: true - - name: Upload third-party mysql SARIF - if: always() - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: sarif-third-party-mysql-8.0-${{ github.run_id }}/trivy.sarif - category: docker-third-party-mysql-8.0 - continue-on-error: true - - - name: Upload third-party tracker SARIF + # Dynamic upload of all third-party image SARIF results. + # Iterates over every sarif-third-party-* artifact directory so + # no manual step additions are needed when images change version. + # The category is derived from the artifact directory name so + # GitHub Code Scanning properly tracks alerts per image. + - name: Upload all third-party SARIF results if: always() - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: sarif-third-party-torrust-tracker-develop-${{ github.run_id }}/trivy.sarif - category: docker-third-party-torrust-tracker-develop - continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + for sarif_dir in sarif-third-party-*; do + if [[ ! -d "$sarif_dir" ]]; then + continue + fi + sarif_file="$sarif_dir/trivy.sarif" + if [[ ! -f "$sarif_file" ]]; then + echo "No SARIF file in $sarif_dir, skipping" + continue + fi - - name: Upload third-party grafana SARIF - if: always() - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: sarif-third-party-grafana-grafana-11.4.0-${{ github.run_id }}/trivy.sarif - category: docker-third-party-grafana-grafana-11.4.0 - continue-on-error: true + # Derive unique Code Scanning category from the artifact directory name. + # Example: sarif-third-party-mysql-8.4-12345 -> docker-third-party-mysql-8.4 + artifact_name="${sarif_dir%-${{ github.run_id }}}" + category="docker-${artifact_name#sarif-}" - - name: Upload third-party prometheus SARIF - if: always() - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: sarif-third-party-prom-prometheus-v3.0.1-${{ github.run_id }}/trivy.sarif - category: docker-third-party-prom-prometheus-v3.0.1 - continue-on-error: true + echo "Uploading $sarif_file with category: $category" - - name: Upload third-party caddy SARIF - if: always() - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: sarif-third-party-caddy-2.10-${{ github.run_id }}/trivy.sarif - category: docker-third-party-caddy-2.10 - continue-on-error: true + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/code-scanning/sarifs" \ + -f "commit_sha=${{ github.sha }}" \ + -f "ref=${{ github.ref }}" \ + -f "sarif=$(gzip -c "$sarif_file" | base64 -w 0)" \ + -f "category=$category" \ + || echo "Warning: Upload failed for $sarif_file (category: $category)" + done diff --git a/project-words.txt b/project-words.txt index 9bf02f3d3..5ca5b6076 100644 --- a/project-words.txt +++ b/project-words.txt @@ -426,6 +426,7 @@ rustup rwxrwx sandboxed sarif +sarifs scannability schemafile schemars diff --git a/src/application/command_handlers/show/handler.rs b/src/application/command_handlers/show/handler.rs index 60c71d8cb..c6f48a988 100644 --- a/src/application/command_handlers/show/handler.rs +++ b/src/application/command_handlers/show/handler.rs @@ -28,9 +28,15 @@ use std::sync::Arc; use tracing::instrument; use super::errors::ShowCommandHandlerError; -use super::info::{EnvironmentInfo, GrafanaInfo, InfrastructureInfo, PrometheusInfo, ServiceInfo}; +use super::info::{ + DockerImagesInfo, EnvironmentInfo, GrafanaInfo, InfrastructureInfo, PrometheusInfo, ServiceInfo, +}; use crate::domain::environment::repository::EnvironmentRepository; use crate::domain::environment::state::AnyEnvironmentState; +use crate::domain::grafana::GrafanaConfig; +use crate::domain::mysql::MysqlServiceConfig; +use crate::domain::prometheus::PrometheusConfig; +use crate::domain::tracker::config::TrackerConfig; use crate::domain::EnvironmentName; /// Default SSH port when not specified @@ -121,7 +127,24 @@ impl ShowCommandHandler { let created_at = any_env.created_at(); let state_name = any_env.state_name().to_string(); - let mut info = EnvironmentInfo::new(name, state, provider, created_at, state_name); + let tracker_config = any_env.tracker_config(); + let docker_images = DockerImagesInfo::new( + TrackerConfig::docker_image().full_reference(), + if tracker_config.uses_mysql() { + Some(MysqlServiceConfig::docker_image().full_reference()) + } else { + None + }, + any_env + .prometheus_config() + .map(|_| PrometheusConfig::docker_image().full_reference()), + any_env + .grafana_config() + .map(|_| GrafanaConfig::docker_image().full_reference()), + ); + + let mut info = + EnvironmentInfo::new(name, state, provider, created_at, docker_images, state_name); // Add infrastructure info if instance IP is available if let Some(instance_ip) = any_env.instance_ip() { @@ -144,7 +167,6 @@ impl ShowCommandHandler { if Self::should_show_services(any_env.state_name()) { // Always compute from tracker config to show proper service information // including TLS domains, localhost hints, and HTTPS status - let tracker_config = any_env.tracker_config(); let grafana_config = any_env.grafana_config(); let services = ServiceInfo::from_tracker_config(tracker_config, instance_ip, grafana_config); diff --git a/src/application/command_handlers/show/info/docker_images.rs b/src/application/command_handlers/show/info/docker_images.rs new file mode 100644 index 000000000..cfe0cc81e --- /dev/null +++ b/src/application/command_handlers/show/info/docker_images.rs @@ -0,0 +1,38 @@ +use serde::Serialize; + +/// Docker image information for the deployment stack +/// +/// Contains the Docker image references for all services in the deployment. +/// Optional services (`MySQL`, Prometheus, Grafana) are `None` if not configured. +#[derive(Debug, Clone, Serialize)] +pub struct DockerImagesInfo { + /// Tracker Docker image reference (e.g. `torrust/tracker:develop`) + pub tracker: String, + + /// `MySQL` Docker image reference (e.g. `mysql:8.4`), present when `MySQL` is configured + pub mysql: Option, + + /// Prometheus Docker image reference (e.g. `prom/prometheus:v3.5.0`), present when configured + pub prometheus: Option, + + /// Grafana Docker image reference (e.g. `grafana/grafana:12.3.1`), present when configured + pub grafana: Option, +} + +impl DockerImagesInfo { + /// Create a new `DockerImagesInfo` with the tracker image and optional service images + #[must_use] + pub fn new( + tracker: String, + mysql: Option, + prometheus: Option, + grafana: Option, + ) -> Self { + Self { + tracker, + mysql, + prometheus, + grafana, + } + } +} diff --git a/src/application/command_handlers/show/info/mod.rs b/src/application/command_handlers/show/info/mod.rs index 96219ac22..d88a9be3a 100644 --- a/src/application/command_handlers/show/info/mod.rs +++ b/src/application/command_handlers/show/info/mod.rs @@ -7,10 +7,12 @@ //! # Module Structure //! //! Each service in the deployment stack has its own submodule: +//! - `docker_images`: Docker image references for all services //! - `tracker`: Tracker service information (UDP/HTTP trackers, API, health check) //! - `prometheus`: Prometheus metrics service information //! - `grafana`: Grafana visualization service information +mod docker_images; mod grafana; mod prometheus; mod tracker; @@ -20,6 +22,7 @@ use std::net::IpAddr; use chrono::{DateTime, Utc}; use serde::Serialize; +pub use self::docker_images::DockerImagesInfo; pub use self::grafana::GrafanaInfo; pub use self::prometheus::PrometheusInfo; pub use self::tracker::{LocalhostServiceInfo, ServiceInfo, TlsDomainInfo}; @@ -55,6 +58,9 @@ pub struct EnvironmentInfo { /// Grafana visualization service information, available for Released/Running states pub grafana: Option, + /// Docker image references for all services in the deployment stack + pub docker_images: DockerImagesInfo, + /// Internal state name (e.g., "created", "provisioned") for guidance generation pub state_name: String, } @@ -67,6 +73,7 @@ impl EnvironmentInfo { state: String, provider: String, created_at: DateTime, + docker_images: DockerImagesInfo, state_name: String, ) -> Self { Self { @@ -78,6 +85,7 @@ impl EnvironmentInfo { services: None, prometheus: None, grafana: None, + docker_images, state_name, } } @@ -166,6 +174,10 @@ mod tests { use super::*; + fn test_docker_images() -> DockerImagesInfo { + DockerImagesInfo::new("torrust/tracker:develop".to_string(), None, None, None) + } + #[test] fn it_should_create_environment_info() { let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); @@ -174,6 +186,7 @@ mod tests { "Created".to_string(), "LXD".to_string(), created_at, + test_docker_images(), "Run 'provision' to create infrastructure.".to_string(), ); @@ -191,6 +204,7 @@ mod tests { "Provisioned".to_string(), "LXD".to_string(), created_at, + test_docker_images(), "Run 'configure' to set up the system.".to_string(), ) .with_infrastructure(InfrastructureInfo::new( diff --git a/src/application/command_handlers/show/mod.rs b/src/application/command_handlers/show/mod.rs index ae202d46c..983b4d98b 100644 --- a/src/application/command_handlers/show/mod.rs +++ b/src/application/command_handlers/show/mod.rs @@ -45,6 +45,7 @@ mod tests; // Re-export main types for convenience pub use errors::ShowCommandHandlerError; pub use handler::ShowCommandHandler; +pub use info::DockerImagesInfo; pub use info::EnvironmentInfo; pub use info::GrafanaInfo; pub use info::InfrastructureInfo; diff --git a/src/domain/grafana/config.rs b/src/domain/grafana/config.rs index 0043a36f8..6ab49dbe9 100644 --- a/src/domain/grafana/config.rs +++ b/src/domain/grafana/config.rs @@ -5,9 +5,16 @@ use serde::{Deserialize, Serialize}; use crate::domain::topology::{ EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, }; +use crate::shared::docker_image::DockerImage; use crate::shared::domain_name::DomainName; use crate::shared::secrets::Password; +/// Docker image repository for the Grafana container +pub const GRAFANA_DOCKER_IMAGE_REPOSITORY: &str = "grafana/grafana"; + +/// Docker image tag for the Grafana container +pub const GRAFANA_DOCKER_IMAGE_TAG: &str = "12.3.1"; + /// Grafana metrics visualization configuration /// /// Configures Grafana service for displaying tracker metrics. @@ -106,6 +113,23 @@ impl GrafanaConfig { pub fn use_tls_proxy(&self) -> bool { self.use_tls_proxy } + + /// Returns the Docker image used for the Grafana service. + /// + /// This is a pinned constant — not user-configurable. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::grafana::GrafanaConfig; + /// + /// let image = GrafanaConfig::docker_image(); + /// assert_eq!(image.full_reference(), "grafana/grafana:12.3.1"); + /// ``` + #[must_use] + pub fn docker_image() -> DockerImage { + DockerImage::new(GRAFANA_DOCKER_IMAGE_REPOSITORY, GRAFANA_DOCKER_IMAGE_TAG) + } } impl Default for GrafanaConfig { diff --git a/src/domain/mysql/config.rs b/src/domain/mysql/config.rs index a9e5ebece..a24e0097c 100644 --- a/src/domain/mysql/config.rs +++ b/src/domain/mysql/config.rs @@ -28,6 +28,13 @@ use serde::{Deserialize, Serialize}; use crate::domain::topology::{ EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, }; +use crate::shared::docker_image::DockerImage; + +/// Docker image repository for the `MySQL` container +pub const MYSQL_DOCKER_IMAGE_REPOSITORY: &str = "mysql"; + +/// Docker image tag for the `MySQL` container +pub const MYSQL_DOCKER_IMAGE_TAG: &str = "8.4"; /// `MySQL` database service configuration for Docker Compose topology /// @@ -69,6 +76,23 @@ impl MysqlServiceConfig { pub const fn new() -> Self { Self {} } + + /// Returns the Docker image used for the `MySQL` service. + /// + /// This is a pinned constant — not user-configurable. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::mysql::MysqlServiceConfig; + /// + /// let image = MysqlServiceConfig::docker_image(); + /// assert_eq!(image.full_reference(), "mysql:8.4"); + /// ``` + #[must_use] + pub fn docker_image() -> DockerImage { + DockerImage::new(MYSQL_DOCKER_IMAGE_REPOSITORY, MYSQL_DOCKER_IMAGE_TAG) + } } impl PortDerivation for MysqlServiceConfig { diff --git a/src/domain/prometheus/config.rs b/src/domain/prometheus/config.rs index ff6ce35c2..452eeda1e 100644 --- a/src/domain/prometheus/config.rs +++ b/src/domain/prometheus/config.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::domain::topology::{ EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, }; +use crate::shared::docker_image::DockerImage; /// Default scrape interval in seconds /// @@ -16,6 +17,12 @@ use crate::domain::topology::{ /// monitoring frequency with resource usage. const DEFAULT_SCRAPE_INTERVAL_SECS: u32 = 15; +/// Docker image repository for the Prometheus container +pub const PROMETHEUS_DOCKER_IMAGE_REPOSITORY: &str = "prom/prometheus"; + +/// Docker image tag for the Prometheus container +pub const PROMETHEUS_DOCKER_IMAGE_TAG: &str = "v3.5.0"; + /// Prometheus metrics collection configuration /// /// Configures how Prometheus scrapes metrics from the tracker. @@ -77,6 +84,26 @@ impl PrometheusConfig { pub fn scrape_interval_in_secs(&self) -> u32 { self.scrape_interval_in_secs.get() } + + /// Returns the Docker image used for the Prometheus service. + /// + /// This is a pinned constant — not user-configurable. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig; + /// + /// let image = PrometheusConfig::docker_image(); + /// assert_eq!(image.full_reference(), "prom/prometheus:v3.5.0"); + /// ``` + #[must_use] + pub fn docker_image() -> DockerImage { + DockerImage::new( + PROMETHEUS_DOCKER_IMAGE_REPOSITORY, + PROMETHEUS_DOCKER_IMAGE_TAG, + ) + } } impl Default for PrometheusConfig { diff --git a/src/domain/tracker/config/core/database/mod.rs b/src/domain/tracker/config/core/database/mod.rs index 4b6d14b44..3c336fe01 100644 --- a/src/domain/tracker/config/core/database/mod.rs +++ b/src/domain/tracker/config/core/database/mod.rs @@ -13,6 +13,9 @@ use serde::{Deserialize, Serialize}; +use crate::domain::mysql::MysqlServiceConfig; +use crate::shared::docker_image::DockerImage; + mod mysql; mod sqlite; @@ -93,6 +96,33 @@ impl DatabaseConfig { Self::Mysql(config) => config.database_name(), } } + + /// Returns the Docker image for the database service container, if applicable. + /// + /// - `Mysql` variant returns the `MySQL` docker image + /// - `Sqlite` returns `None` — `SQLite` runs in-process; no container is needed + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tracker::{DatabaseConfig, SqliteConfig, MysqlConfig}; + /// use torrust_tracker_deployer_lib::shared::Password; + /// + /// let sqlite = DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()); + /// assert!(sqlite.docker_image().is_none()); + /// + /// let mysql = DatabaseConfig::Mysql( + /// MysqlConfig::new("localhost", 3306, "tracker", "user", "pass".to_string().into(), "root_pass".to_string().into()).unwrap() + /// ); + /// assert_eq!(mysql.docker_image().unwrap().full_reference(), "mysql:8.4"); + /// ``` + #[must_use] + pub fn docker_image(&self) -> Option { + match self { + Self::Mysql(_) => Some(MysqlServiceConfig::docker_image()), + Self::Sqlite(_) => None, + } + } } #[cfg(test)] diff --git a/src/domain/tracker/config/mod.rs b/src/domain/tracker/config/mod.rs index be1efcd83..405d478fc 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -13,8 +13,15 @@ use super::{BindingAddress, Protocol}; use crate::domain::topology::{ EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service, }; +use crate::shared::docker_image::DockerImage; use crate::shared::DomainName; +/// Docker image repository for the Torrust Tracker container +pub const TRACKER_DOCKER_IMAGE_REPOSITORY: &str = "torrust/tracker"; + +/// Docker image tag for the Torrust Tracker container +pub const TRACKER_DOCKER_IMAGE_TAG: &str = "develop"; + mod core; mod health_check_api; mod http; @@ -330,6 +337,23 @@ impl TrackerConfig { matches!(self.core.database(), DatabaseConfig::Mysql(_)) } + /// Returns the Docker image used for the tracker service. + /// + /// This is a pinned constant — not user-configurable. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tracker::TrackerConfig; + /// + /// let image = TrackerConfig::docker_image(); + /// assert_eq!(image.full_reference(), "torrust/tracker:develop"); + /// ``` + #[must_use] + pub fn docker_image() -> DockerImage { + DockerImage::new(TRACKER_DOCKER_IMAGE_REPOSITORY, TRACKER_DOCKER_IMAGE_TAG) + } + /// Checks for socket address conflicts /// /// Validates that no two services using the same protocol attempt to bind diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs index 62eca609b..19e7d1657 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs @@ -18,6 +18,9 @@ use super::service_topology::ServiceTopology; /// Uses `ServiceTopology` to share the common topology structure with other services. #[derive(Serialize, Debug, Clone)] pub struct GrafanaServiceContext { + /// Docker image reference (e.g. `grafana/grafana:12.3.1`) + pub image: String, + /// Service topology (ports and networks) /// /// Flattened for template compatibility - serializes ports/networks at top level. @@ -60,6 +63,7 @@ impl GrafanaServiceContext { format!("{scheme}://{}", domain.as_str()) }); Self { + image: GrafanaConfig::docker_image().full_reference(), topology: ServiceTopology::new(ports, networks), server_root_url, } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs index 3dd6b80ea..f090d7425 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs @@ -46,6 +46,9 @@ use super::service_topology::ServiceTopology; /// ``` #[derive(Debug, Clone, Serialize, PartialEq)] pub struct MysqlServiceContext { + /// Docker image reference (e.g. `mysql:8.4`) + pub image: String, + /// Service topology (ports and networks) /// /// Flattened for template compatibility - serializes ports/networks at top level. @@ -73,6 +76,7 @@ impl MysqlServiceContext { let networks = config.derive_networks(enabled_services); Self { + image: DomainMysqlConfig::docker_image().full_reference(), topology: ServiceTopology::new(ports, networks), } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs index 84a8d5377..ce84c8398 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs @@ -18,6 +18,9 @@ use super::service_topology::ServiceTopology; /// Uses `ServiceTopology` to share the common topology structure with other services. #[derive(Serialize, Debug, Clone)] pub struct PrometheusServiceContext { + /// Docker image reference (e.g. `prom/prometheus:v3.5.0`) + pub image: String, + /// Service topology (ports and networks) /// /// Flattened for template compatibility - serializes ports/networks at top level. @@ -47,6 +50,7 @@ impl PrometheusServiceContext { .map(PortDefinition::from) .collect(); Self { + image: PrometheusConfig::docker_image().full_reference(), topology: ServiceTopology::new(ports, networks), } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs index c638c3e11..c6f6c4833 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs @@ -18,6 +18,9 @@ use super::service_topology::ServiceTopology; /// Uses `ServiceTopology` to share the common topology structure with other services. #[derive(Serialize, Debug, Clone)] pub struct TrackerServiceContext { + /// Docker image reference (e.g. `torrust/tracker:develop`) + pub image: String, + /// Service topology (ports and networks) /// /// Flattened for template compatibility - serializes ports/networks at top level. @@ -45,6 +48,7 @@ impl TrackerServiceContext { .collect(); Self { + image: TrackerConfig::docker_image().full_reference(), topology: ServiceTopology::new(ports, networks), } } diff --git a/src/presentation/cli/views/commands/show/view_data/mod.rs b/src/presentation/cli/views/commands/show/view_data/mod.rs index 53ca21b5d..b1492d6d0 100644 --- a/src/presentation/cli/views/commands/show/view_data/mod.rs +++ b/src/presentation/cli/views/commands/show/view_data/mod.rs @@ -1,6 +1,6 @@ pub mod show_details; pub use show_details::{ - EnvironmentInfo, GrafanaInfo, InfrastructureInfo, LocalhostServiceInfo, PrometheusInfo, - ServiceInfo, TlsDomainInfo, + DockerImagesInfo, EnvironmentInfo, GrafanaInfo, InfrastructureInfo, LocalhostServiceInfo, + PrometheusInfo, ServiceInfo, TlsDomainInfo, }; diff --git a/src/presentation/cli/views/commands/show/view_data/show_details.rs b/src/presentation/cli/views/commands/show/view_data/show_details.rs index a30be7bf7..800f274a4 100644 --- a/src/presentation/cli/views/commands/show/view_data/show_details.rs +++ b/src/presentation/cli/views/commands/show/view_data/show_details.rs @@ -4,6 +4,7 @@ //! The presentation layer references this module rather than importing directly //! from the application layer. +pub use crate::application::command_handlers::show::info::DockerImagesInfo; pub use crate::application::command_handlers::show::info::EnvironmentInfo; pub use crate::application::command_handlers::show::info::GrafanaInfo; pub use crate::application::command_handlers::show::info::InfrastructureInfo; diff --git a/src/presentation/cli/views/commands/show/views/json_view.rs b/src/presentation/cli/views/commands/show/views/json_view.rs index 1fb582818..6eef2ffec 100644 --- a/src/presentation/cli/views/commands/show/views/json_view.rs +++ b/src/presentation/cli/views/commands/show/views/json_view.rs @@ -23,16 +23,18 @@ use crate::presentation::cli::views::{Render, ViewRenderError}; /// /// ```rust /// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; -/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::EnvironmentInfo; +/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{DockerImagesInfo, EnvironmentInfo}; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::show::JsonView; /// use chrono::{TimeZone, Utc}; /// /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); +/// let docker_images = DockerImagesInfo::new("torrust/tracker:develop".to_string(), None, None, None); /// let info = EnvironmentInfo::new( /// "my-env".to_string(), /// "Created".to_string(), /// "LXD".to_string(), /// created_at, +/// docker_images, /// "created".to_string(), /// ); /// @@ -57,9 +59,14 @@ mod tests { use chrono::{TimeZone, Utc}; use super::*; + use crate::presentation::cli::views::commands::show::view_data::DockerImagesInfo; use crate::presentation::cli::views::commands::show::view_data::InfrastructureInfo; use crate::presentation::cli::views::Render; + fn test_docker_images() -> DockerImagesInfo { + DockerImagesInfo::new("torrust/tracker:develop".to_string(), None, None, None) + } + #[test] fn it_should_render_created_state_as_json() { let created_at = Utc.with_ymd_and_hms(2026, 2, 16, 10, 0, 0).unwrap(); @@ -68,6 +75,7 @@ mod tests { "Created".to_string(), "LXD".to_string(), created_at, + test_docker_images(), "created".to_string(), ); @@ -102,6 +110,7 @@ mod tests { "Provisioned".to_string(), "LXD".to_string(), created_at, + test_docker_images(), "provisioned".to_string(), ) .with_infrastructure(InfrastructureInfo::new( @@ -134,6 +143,7 @@ mod tests { "Created".to_string(), "LXD".to_string(), created_at, + test_docker_images(), "created".to_string(), ); diff --git a/src/presentation/cli/views/commands/show/views/text_view.rs b/src/presentation/cli/views/commands/show/views/text_view.rs index a617a94db..eeebedf51 100644 --- a/src/presentation/cli/views/commands/show/views/text_view.rs +++ b/src/presentation/cli/views/commands/show/views/text_view.rs @@ -23,7 +23,9 @@ use super::next_step::NextStepGuidanceView; use super::prometheus::PrometheusView; use super::tracker_services::TrackerServicesView; -use crate::presentation::cli::views::commands::show::view_data::EnvironmentInfo; +use crate::presentation::cli::views::commands::show::view_data::{ + DockerImagesInfo, EnvironmentInfo, +}; use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering environment information @@ -43,16 +45,18 @@ use crate::presentation::cli::views::{Render, ViewRenderError}; /// /// ```rust /// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; -/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::EnvironmentInfo; +/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{DockerImagesInfo, EnvironmentInfo}; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::show::TextView; /// use chrono::{TimeZone, Utc}; /// /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); +/// let docker_images = DockerImagesInfo::new("torrust/tracker:develop".to_string(), None, None, None); /// let info = EnvironmentInfo::new( /// "my-env".to_string(), /// "Created".to_string(), /// "LXD".to_string(), /// created_at, +/// docker_images, /// "created".to_string(), /// ); /// @@ -94,6 +98,9 @@ impl Render for TextView { lines.extend(GrafanaView::render(grafana)); } + // Docker images (always present) + lines.extend(Self::render_docker_images(&info.docker_images)); + // HTTPS hint with /etc/hosts (if TLS is configured) if let Some(ref services) = info.services { let instance_ip = info.infrastructure.as_ref().map(|i| i.instance_ip); @@ -107,6 +114,25 @@ impl Render for TextView { } } +impl TextView { + fn render_docker_images(docker_images: &DockerImagesInfo) -> Vec { + let mut lines = Vec::new(); + lines.push(String::new()); + lines.push("Docker Images:".to_string()); + lines.push(format!(" Tracker: {}", docker_images.tracker)); + if let Some(ref mysql) = docker_images.mysql { + lines.push(format!(" MySQL: {mysql}")); + } + if let Some(ref prometheus) = docker_images.prometheus { + lines.push(format!(" Prometheus: {prometheus}")); + } + if let Some(ref grafana) = docker_images.grafana { + lines.push(format!(" Grafana: {grafana}")); + } + lines + } +} + #[cfg(test)] mod tests { use std::net::{IpAddr, Ipv4Addr}; @@ -115,7 +141,7 @@ mod tests { use super::*; use crate::presentation::cli::views::commands::show::view_data::{ - InfrastructureInfo, ServiceInfo, TlsDomainInfo, + DockerImagesInfo, InfrastructureInfo, ServiceInfo, TlsDomainInfo, }; /// Helper to create a fixed test timestamp @@ -123,6 +149,10 @@ mod tests { Utc.with_ymd_and_hms(2025, 1, 7, 12, 30, 45).unwrap() } + fn test_docker_images() -> DockerImagesInfo { + DockerImagesInfo::new("torrust/tracker:develop".to_string(), None, None, None) + } + #[test] fn it_should_render_basic_environment_info() { let info = EnvironmentInfo::new( @@ -130,6 +160,7 @@ mod tests { "Created".to_string(), "LXD".to_string(), test_timestamp(), + test_docker_images(), "created".to_string(), ); @@ -149,6 +180,7 @@ mod tests { "Provisioned".to_string(), "LXD".to_string(), test_timestamp(), + test_docker_images(), "provisioned".to_string(), ) .with_infrastructure(InfrastructureInfo::new( @@ -176,6 +208,7 @@ mod tests { "Running".to_string(), "LXD".to_string(), test_timestamp(), + test_docker_images(), "running".to_string(), ) .with_services(ServiceInfo::new( @@ -212,6 +245,7 @@ mod tests { "Running".to_string(), "LXD".to_string(), test_timestamp(), + test_docker_images(), "running".to_string(), ) .with_infrastructure(InfrastructureInfo::new( @@ -253,6 +287,7 @@ mod tests { "Running".to_string(), "LXD".to_string(), test_timestamp(), + test_docker_images(), "running".to_string(), ) .with_infrastructure(InfrastructureInfo::new( @@ -318,6 +353,7 @@ mod tests { "Provisioned".to_string(), "LXD".to_string(), test_timestamp(), + test_docker_images(), "provisioned".to_string(), ) .with_infrastructure(InfrastructureInfo::new( diff --git a/src/shared/docker_image.rs b/src/shared/docker_image.rs new file mode 100644 index 000000000..59e5fc388 --- /dev/null +++ b/src/shared/docker_image.rs @@ -0,0 +1,145 @@ +//! Docker image reference value object +//! +//! This module provides a strongly-typed Docker image reference that combines +//! a repository name with a tag. It is used to represent Docker images in +//! service configurations and templates. +//! +//! # Design Decision +//! +//! Docker image versions are **not user-configurable** — they are pinned as +//! constants in the code to ensure compatibility between the deployer and the +//! images it uses. Exposing them through domain configs (rather than hardcoding +//! in templates) gives us: +//! +//! - A **single source of truth** for each image version +//! - The ability to **inspect images via the `show` command** +//! - Automatic propagation of version changes to both templates and CI scanning +//! +//! # Examples +//! +//! ```rust +//! use torrust_tracker_deployer_lib::shared::docker_image::DockerImage; +//! +//! let image = DockerImage::new("torrust/tracker", "develop"); +//! assert_eq!(image.full_reference(), "torrust/tracker:develop"); +//! assert_eq!(image.repository(), "torrust/tracker"); +//! assert_eq!(image.tag(), "develop"); +//! ``` + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Docker image reference with repository and tag +/// +/// Represents an image reference of the form `repository:tag`, +/// e.g. `torrust/tracker:develop` or `mysql:8.4`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DockerImage { + repository: String, + tag: String, +} + +impl DockerImage { + /// Creates a new Docker image reference + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::shared::docker_image::DockerImage; + /// + /// let image = DockerImage::new("torrust/tracker", "develop"); + /// assert_eq!(image.repository(), "torrust/tracker"); + /// assert_eq!(image.tag(), "develop"); + /// ``` + #[must_use] + pub fn new(repository: impl Into, tag: impl Into) -> Self { + Self { + repository: repository.into(), + tag: tag.into(), + } + } + + /// Returns the repository name (e.g. `"torrust/tracker"`) + #[must_use] + pub fn repository(&self) -> &str { + &self.repository + } + + /// Returns the image tag (e.g. `"develop"` or `"8.4"`) + #[must_use] + pub fn tag(&self) -> &str { + &self.tag + } + + /// Returns the full image reference as `repository:tag` + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::shared::docker_image::DockerImage; + /// + /// let image = DockerImage::new("mysql", "8.4"); + /// assert_eq!(image.full_reference(), "mysql:8.4"); + /// ``` + #[must_use] + pub fn full_reference(&self) -> String { + format!("{}:{}", self.repository, self.tag) + } +} + +impl fmt::Display for DockerImage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.repository, self.tag) + } +} + +impl From<(&str, &str)> for DockerImage { + fn from((repository, tag): (&str, &str)) -> Self { + Self::new(repository, tag) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_docker_image_with_repository_and_tag() { + let image = DockerImage::new("torrust/tracker", "develop"); + + assert_eq!(image.repository(), "torrust/tracker"); + assert_eq!(image.tag(), "develop"); + } + + #[test] + fn it_should_return_full_reference_as_repository_colon_tag() { + let image = DockerImage::new("torrust/tracker", "develop"); + + assert_eq!(image.full_reference(), "torrust/tracker:develop"); + } + + #[test] + fn it_should_display_as_full_reference() { + let image = DockerImage::new("mysql", "8.4"); + + assert_eq!(format!("{image}"), "mysql:8.4"); + } + + #[test] + fn it_should_create_from_str_tuple() { + let image = DockerImage::from(("prom/prometheus", "v3.5.0")); + + assert_eq!(image.full_reference(), "prom/prometheus:v3.5.0"); + } + + #[test] + fn it_should_implement_equality() { + let a = DockerImage::new("grafana/grafana", "12.3.1"); + let b = DockerImage::new("grafana/grafana", "12.3.1"); + let c = DockerImage::new("grafana/grafana", "11.4.0"); + + assert_eq!(a, b); + assert_ne!(a, c); + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 337115faa..2e0855576 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -6,6 +6,7 @@ pub mod clock; pub mod command; +pub mod docker_image; pub mod domain_name; pub mod email; pub mod error; diff --git a/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index 526ef42c5..309e42b97 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -90,7 +90,7 @@ services: # Tracking issue: https://github.com/torrust/torrust-tracker-deployer/issues/TBD # Rationale: The develop tag is mutable and introduces deployment non-reproducibility. # Pinning to a stable release ensures predictable deployments and easier rollback. - image: torrust/tracker:develop + image: {{ tracker.image }} container_name: tracker {%- if mysql %} depends_on: @@ -126,7 +126,7 @@ services: prometheus: <<: *defaults - image: prom/prometheus:v3.5.0 + image: {{ prometheus.image }} container_name: prometheus {%- if prometheus.networks | length > 0 %} networks: @@ -158,7 +158,7 @@ services: grafana: <<: *defaults - image: grafana/grafana:12.3.1 + image: {{ grafana.image }} container_name: grafana {%- if grafana.networks | length > 0 %} networks: @@ -196,7 +196,7 @@ services: mysql: <<: *defaults - image: mysql:8.4 + image: {{ mysql.image }} container_name: mysql environment: - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}