torrust_tracker_deployer_lib/domain/environment/user_inputs.rs
1//! User Inputs Module
2//!
3//! This module contains the `UserInputs` struct which holds all user-provided
4//! configuration when creating an environment.
5//!
6//! ## Purpose
7//!
8//! User inputs represent the immutable configuration choices made by the user
9//! when creating an environment. These fields never change throughout the
10//! environment's lifecycle.
11//!
12//! ## Semantic Category
13//!
14//! **User Inputs** are:
15//! - Provided by the user when creating an environment
16//! - Immutable throughout environment lifecycle
17//! - Examples: name, SSH credentials, port numbers
18//!
19//! Add new fields here when: User needs to configure something at environment creation time.
20
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::adapters::ssh::SshCredentials;
25use crate::domain::backup::BackupConfig;
26use crate::domain::environment::EnvironmentName;
27use crate::domain::grafana::GrafanaConfig;
28use crate::domain::https::HttpsConfig;
29use crate::domain::prometheus::PrometheusConfig;
30use crate::domain::provider::{Provider, ProviderConfig};
31use crate::domain::tracker::TrackerConfig;
32use crate::domain::InstanceName;
33
34/// Errors for user inputs validation
35///
36/// These errors represent cross-service invariant violations that can only be
37/// detected when considering multiple service configurations together.
38#[derive(Debug, Clone, PartialEq, Error)]
39pub enum UserInputsError {
40 /// Grafana requires Prometheus to be configured as its data source
41 ///
42 /// Use `.help()` for detailed troubleshooting steps.
43 #[error(
44 "Grafana requires Prometheus to be configured as its data source
45Tip: Add a 'prometheus' section or remove the 'grafana' section"
46 )]
47 GrafanaRequiresPrometheus,
48
49 /// HTTPS section is defined but no service has TLS configured
50 ///
51 /// Use `.help()` for detailed troubleshooting steps.
52 #[error(
53 "HTTPS section is defined but no service has TLS configured
54Tip: Set 'use_tls_proxy: true' on at least one service, or remove the 'https' section"
55 )]
56 HttpsSectionWithoutTlsServices,
57
58 /// At least one service has TLS configured but HTTPS section is missing
59 ///
60 /// Use `.help()` for detailed troubleshooting steps.
61 #[error(
62 "At least one service has TLS configured but HTTPS section is missing
63Tip: Add an 'https' section with 'admin_email' for Let's Encrypt certificate management"
64 )]
65 TlsServicesWithoutHttpsSection,
66}
67
68impl UserInputsError {
69 /// Provides actionable help text for fixing the error
70 #[must_use]
71 pub fn help(&self) -> &'static str {
72 match self {
73 Self::GrafanaRequiresPrometheus => {
74 "Add a 'prometheus' section to your configuration, or remove the 'grafana' section. \
75 Grafana needs Prometheus as its metrics data source."
76 }
77 Self::HttpsSectionWithoutTlsServices => {
78 "Either remove the 'https' section, or set 'use_tls_proxy: true' on at least one \
79 service (http_api, http_trackers, or health_check_api)."
80 }
81 Self::TlsServicesWithoutHttpsSection => {
82 "Add an 'https' section with 'admin_email' for Let's Encrypt certificate management. \
83 Services with 'use_tls_proxy: true' require Caddy for TLS termination."
84 }
85 }
86 }
87}
88
89/// User-provided configuration when creating an environment
90///
91/// This struct contains all fields that are provided by the user when creating
92/// an environment. These fields are immutable throughout the environment lifecycle
93/// and represent the user's configuration choices.
94///
95/// # Cross-Service Invariants
96///
97/// The following invariants are validated at construction time:
98/// - **Grafana requires Prometheus**: If Grafana is enabled, Prometheus must also be enabled
99/// - **HTTPS requires TLS services**: If HTTPS section is present, at least one service must have TLS
100/// - **TLS requires HTTPS**: If any service has TLS, HTTPS section must be present
101///
102/// # Examples
103///
104/// ```rust
105/// use torrust_tracker_deployer_lib::domain::{InstanceName, EnvironmentName, ProfileName};
106/// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig};
107/// use torrust_tracker_deployer_lib::domain::environment::user_inputs::UserInputs;
108/// use torrust_tracker_deployer_lib::domain::tracker::TrackerConfig;
109/// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig;
110/// use torrust_tracker_deployer_lib::domain::grafana::GrafanaConfig;
111/// use torrust_tracker_deployer_lib::shared::Username;
112/// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
113/// use std::path::PathBuf;
114///
115/// let provider_config = ProviderConfig::Lxd(LxdConfig {
116/// profile_name: ProfileName::new("torrust-profile-production".to_string())?,
117/// });
118/// let ssh_credentials = SshCredentials::new(
119/// PathBuf::from("keys/prod_rsa"),
120/// PathBuf::from("keys/prod_rsa.pub"),
121/// Username::new("torrust".to_string())?,
122/// );
123/// let env_name = EnvironmentName::new("production".to_string())?;
124///
125/// // Create with defaults (includes Prometheus and Grafana)
126/// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?;
127///
128/// assert_eq!(user_inputs.name().as_str(), "production");
129/// assert!(user_inputs.prometheus().is_some());
130/// assert!(user_inputs.grafana().is_some());
131/// # Ok::<(), Box<dyn std::error::Error>>(())
132/// ```
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct UserInputs {
135 /// The validated environment name
136 name: EnvironmentName,
137
138 /// The instance name for this environment (auto-generated from name)
139 instance_name: InstanceName,
140
141 /// Provider-specific configuration (e.g., LXD profile, Hetzner settings)
142 provider_config: ProviderConfig,
143
144 /// SSH credentials for connecting to instances in this environment
145 ssh_credentials: SshCredentials,
146
147 /// SSH port for connecting to instances in this environment
148 ssh_port: u16,
149
150 /// Tracker deployment configuration
151 tracker: TrackerConfig,
152
153 /// Prometheus metrics collection configuration (optional)
154 ///
155 /// When present, Prometheus service is enabled in the deployment.
156 /// When absent (`None`), Prometheus service is disabled.
157 /// Default: `Some(PrometheusConfig::default())` in generated templates.
158 prometheus: Option<PrometheusConfig>,
159
160 /// Grafana visualization and dashboard configuration (optional)
161 ///
162 /// When present, Grafana service is enabled in the deployment.
163 /// When absent (`None`), Grafana service is disabled.
164 /// Requires Prometheus to be enabled - dependency validated at construction time.
165 /// Default: `Some(GrafanaConfig::default())` in generated templates.
166 grafana: Option<GrafanaConfig>,
167
168 /// HTTPS/TLS configuration for Caddy reverse proxy (optional)
169 ///
170 /// When present, Caddy service is deployed as a TLS termination proxy.
171 /// When absent (`None`), services are exposed directly over HTTP.
172 /// Requires at least one service to have TLS configuration.
173 https: Option<HttpsConfig>,
174
175 /// Backup configuration (optional)
176 ///
177 /// When present, backup service is enabled with scheduled backups.
178 /// When absent (`None`), backup service is disabled.
179 /// Default: `None` in generated templates.
180 backup: Option<BackupConfig>,
181}
182
183impl UserInputs {
184 /// Creates a new `UserInputs` with auto-generated instance name and default services
185 ///
186 /// Creates a `UserInputs` with default tracker configuration, Prometheus, and Grafana
187 /// enabled. This is the standard setup for most deployments.
188 ///
189 /// # Arguments
190 ///
191 /// * `name` - The validated environment name
192 /// * `provider_config` - Provider-specific configuration
193 /// * `ssh_credentials` - SSH credentials for connecting to instances
194 /// * `ssh_port` - SSH port for connecting to instances
195 ///
196 /// # Returns
197 ///
198 /// A new `UserInputs` with:
199 /// - Auto-generated instance name: `torrust-tracker-vm-{env_name}`
200 /// - Default tracker configuration
201 /// - Prometheus and Grafana enabled (satisfies cross-service invariants)
202 ///
203 /// # Errors
204 ///
205 /// This constructor with defaults cannot fail because the default configuration
206 /// (Prometheus + Grafana, no HTTPS) always satisfies cross-service invariants.
207 ///
208 /// # Examples
209 ///
210 /// ```rust
211 /// use torrust_tracker_deployer_lib::domain::environment::{EnvironmentName, UserInputs};
212 /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, Provider};
213 /// use torrust_tracker_deployer_lib::domain::ProfileName;
214 /// use torrust_tracker_deployer_lib::shared::Username;
215 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
216 /// use std::path::PathBuf;
217 ///
218 /// let env_name = EnvironmentName::new("production".to_string())?;
219 /// let ssh_username = Username::new("torrust".to_string())?;
220 /// let ssh_credentials = SshCredentials::new(
221 /// PathBuf::from("keys/prod_rsa"),
222 /// PathBuf::from("keys/prod_rsa.pub"),
223 /// ssh_username,
224 /// );
225 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
226 /// profile_name: ProfileName::new("torrust-profile-production".to_string())?,
227 /// });
228 ///
229 /// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?;
230 ///
231 /// assert_eq!(user_inputs.instance_name().as_str(), "torrust-tracker-vm-production");
232 /// assert_eq!(user_inputs.provider(), Provider::Lxd);
233 ///
234 /// # Ok::<(), Box<dyn std::error::Error>>(())
235 /// ```
236 pub fn new(
237 name: &EnvironmentName,
238 provider_config: ProviderConfig,
239 ssh_credentials: SshCredentials,
240 ssh_port: u16,
241 ) -> Result<Self, UserInputsError> {
242 // Default configuration: Prometheus + Grafana, no HTTPS, no backup
243 // This always passes validation (Grafana has Prometheus, no TLS configured)
244 Self::with_tracker(
245 name,
246 provider_config,
247 ssh_credentials,
248 ssh_port,
249 TrackerConfig::default(),
250 Some(PrometheusConfig::default()),
251 Some(GrafanaConfig::default()),
252 None,
253 None,
254 )
255 }
256
257 /// Creates a new `UserInputs` with custom tracker and service configuration
258 ///
259 /// This constructor allows full control over all service configurations.
260 /// Cross-service invariants are validated at construction time.
261 ///
262 /// # Arguments
263 ///
264 /// * `name` - The validated environment name
265 /// * `provider_config` - Provider-specific configuration
266 /// * `ssh_credentials` - SSH credentials for connecting to instances
267 /// * `ssh_port` - SSH port for connecting to instances
268 /// * `tracker` - Tracker deployment configuration
269 /// * `prometheus` - Optional Prometheus configuration
270 /// * `grafana` - Optional Grafana configuration (requires Prometheus)
271 /// * `https` - Optional HTTPS/TLS configuration (requires TLS services)
272 /// * `backup` - Optional backup configuration
273 ///
274 /// # Errors
275 ///
276 /// - `GrafanaRequiresPrometheus` if Grafana is configured without Prometheus
277 /// - `HttpsSectionWithoutTlsServices` if HTTPS section exists but no service uses TLS
278 /// - `TlsServicesWithoutHttpsSection` if a service uses TLS but HTTPS section is missing
279 #[allow(clippy::too_many_arguments)]
280 pub fn with_tracker(
281 name: &EnvironmentName,
282 provider_config: ProviderConfig,
283 ssh_credentials: SshCredentials,
284 ssh_port: u16,
285 tracker: TrackerConfig,
286 prometheus: Option<PrometheusConfig>,
287 grafana: Option<GrafanaConfig>,
288 https: Option<HttpsConfig>,
289 backup: Option<BackupConfig>,
290 ) -> Result<Self, UserInputsError> {
291 // Cross-service invariant: Grafana requires Prometheus as data source
292 if grafana.is_some() && prometheus.is_none() {
293 return Err(UserInputsError::GrafanaRequiresPrometheus);
294 }
295
296 // Cross-service invariant: HTTPS section requires at least one TLS service
297 let has_tls = tracker.has_any_tls_configured();
298 if https.is_some() && !has_tls {
299 return Err(UserInputsError::HttpsSectionWithoutTlsServices);
300 }
301
302 // Inverse: TLS services require HTTPS section
303 if has_tls && https.is_none() {
304 return Err(UserInputsError::TlsServicesWithoutHttpsSection);
305 }
306
307 let instance_name = Self::generate_instance_name(name);
308
309 Ok(Self {
310 name: name.clone(),
311 instance_name,
312 provider_config,
313 ssh_credentials,
314 ssh_port,
315 tracker,
316 prometheus,
317 grafana,
318 https,
319 backup,
320 })
321 }
322
323 // ========================================================================
324 // Getter Methods
325 // ========================================================================
326
327 /// Returns the environment name
328 #[must_use]
329 pub fn name(&self) -> &EnvironmentName {
330 &self.name
331 }
332
333 /// Returns the instance name
334 #[must_use]
335 pub fn instance_name(&self) -> &InstanceName {
336 &self.instance_name
337 }
338
339 /// Returns the SSH credentials
340 #[must_use]
341 pub fn ssh_credentials(&self) -> &SshCredentials {
342 &self.ssh_credentials
343 }
344
345 /// Returns the SSH port
346 #[must_use]
347 pub fn ssh_port(&self) -> u16 {
348 self.ssh_port
349 }
350
351 /// Returns the tracker configuration
352 #[must_use]
353 pub fn tracker(&self) -> &TrackerConfig {
354 &self.tracker
355 }
356
357 /// Returns the Prometheus configuration if enabled
358 #[must_use]
359 pub fn prometheus(&self) -> Option<&PrometheusConfig> {
360 self.prometheus.as_ref()
361 }
362
363 /// Returns the Grafana configuration if enabled
364 #[must_use]
365 pub fn grafana(&self) -> Option<&GrafanaConfig> {
366 self.grafana.as_ref()
367 }
368
369 /// Returns the HTTPS configuration if enabled
370 #[must_use]
371 pub fn https(&self) -> Option<&HttpsConfig> {
372 self.https.as_ref()
373 }
374
375 /// Returns the backup configuration if enabled
376 #[must_use]
377 pub fn backup(&self) -> Option<&BackupConfig> {
378 self.backup.as_ref()
379 }
380
381 // ========================================================================
382 // Provider Accessor Methods
383 // ========================================================================
384
385 /// Returns the provider type for this environment
386 ///
387 /// # Examples
388 ///
389 /// ```rust
390 /// use torrust_tracker_deployer_lib::domain::environment::{EnvironmentName, UserInputs};
391 /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, Provider};
392 /// use torrust_tracker_deployer_lib::domain::ProfileName;
393 /// use torrust_tracker_deployer_lib::shared::Username;
394 /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
395 /// use std::path::PathBuf;
396 ///
397 /// let env_name = EnvironmentName::new("test".to_string())?;
398 /// let ssh_credentials = SshCredentials::new(
399 /// PathBuf::from("keys/test_rsa"),
400 /// PathBuf::from("keys/test_rsa.pub"),
401 /// Username::new("torrust".to_string())?,
402 /// );
403 /// let provider_config = ProviderConfig::Lxd(LxdConfig {
404 /// profile_name: ProfileName::new("test-profile".to_string())?,
405 /// });
406 ///
407 /// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?;
408 /// assert_eq!(user_inputs.provider(), Provider::Lxd);
409 ///
410 /// # Ok::<(), Box<dyn std::error::Error>>(())
411 /// ```
412 #[must_use]
413 pub fn provider(&self) -> Provider {
414 self.provider_config.provider()
415 }
416
417 /// Returns a reference to the provider configuration
418 ///
419 /// Use this to access provider-specific fields. For example:
420 /// ```rust,ignore
421 /// if let Some(lxd_config) = user_inputs.provider_config().as_lxd() {
422 /// println!("LXD profile: {}", lxd_config.profile_name.as_str());
423 /// }
424 /// ```
425 #[must_use]
426 pub fn provider_config(&self) -> &ProviderConfig {
427 &self.provider_config
428 }
429
430 // ========================================================================
431 // Private Helper Methods
432 // ========================================================================
433
434 /// Generates an instance name from the environment name
435 ///
436 /// Format: `torrust-tracker-vm-{env_name}`
437 ///
438 /// # Panics
439 ///
440 /// This function does not panic. The generated instance name is guaranteed
441 /// to be valid for any valid environment name.
442 fn generate_instance_name(env_name: &EnvironmentName) -> InstanceName {
443 let instance_name_str = format!("torrust-tracker-vm-{}", env_name.as_str());
444 InstanceName::new(instance_name_str)
445 .expect("Generated instance name should always be valid")
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use std::path::PathBuf;
452
453 use super::*;
454 use crate::domain::provider::LxdConfig;
455 use crate::domain::tracker::{
456 DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, SqliteConfig, TrackerCoreConfig,
457 UdpTrackerConfig,
458 };
459 use crate::domain::ProfileName;
460 use crate::shared::{ApiToken, DomainName, Username};
461
462 fn create_test_ssh_credentials() -> SshCredentials {
463 SshCredentials::new(
464 PathBuf::from("keys/test_rsa"),
465 PathBuf::from("keys/test_rsa.pub"),
466 Username::new("testuser".to_string()).unwrap(),
467 )
468 }
469
470 fn create_lxd_provider_config(profile_name: &str) -> ProviderConfig {
471 ProviderConfig::Lxd(LxdConfig {
472 profile_name: ProfileName::new(profile_name.to_string()).unwrap(),
473 })
474 }
475
476 fn create_test_env_name() -> EnvironmentName {
477 EnvironmentName::new("test-env".to_string()).unwrap()
478 }
479
480 fn create_tracker_config_with_tls() -> TrackerConfig {
481 TrackerConfig::new(
482 TrackerCoreConfig::new(
483 DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()),
484 false,
485 ),
486 vec![UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap()],
487 vec![],
488 HttpApiConfig::new(
489 "0.0.0.0:1212".parse().unwrap(),
490 "token".to_string().into(),
491 Some(DomainName::new("api.example.com").unwrap()),
492 true, // TLS enabled
493 )
494 .unwrap(),
495 HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(),
496 )
497 .unwrap()
498 }
499
500 fn create_tracker_config_without_tls() -> TrackerConfig {
501 TrackerConfig::default()
502 }
503
504 #[test]
505 fn it_should_create_user_inputs_with_lxd_provider() {
506 let env_name = create_test_env_name();
507 let provider_config = create_lxd_provider_config("test-profile");
508 let ssh_credentials = create_test_ssh_credentials();
509
510 let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap();
511
512 assert_eq!(user_inputs.name().as_str(), "test-env");
513 assert_eq!(
514 user_inputs.instance_name().as_str(),
515 "torrust-tracker-vm-test-env"
516 );
517 assert_eq!(user_inputs.provider(), Provider::Lxd);
518 assert_eq!(user_inputs.provider_config().provider_name(), "lxd");
519 assert_eq!(user_inputs.ssh_port(), 22);
520 }
521
522 #[test]
523 fn it_should_return_provider_config_for_lxd() {
524 let env_name = create_test_env_name();
525 let provider_config = create_lxd_provider_config("my-custom-profile");
526 let ssh_credentials = create_test_ssh_credentials();
527
528 let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap();
529
530 let lxd_config = user_inputs.provider_config().as_lxd().unwrap();
531 assert_eq!(lxd_config.profile_name.as_str(), "my-custom-profile");
532 }
533
534 #[test]
535 fn it_should_return_provider_config_for_hetzner() {
536 use crate::domain::provider::HetznerConfig;
537
538 let env_name = create_test_env_name();
539 let provider_config = ProviderConfig::Hetzner(HetznerConfig {
540 api_token: ApiToken::from("test-token"),
541 server_type: "cx22".to_string(),
542 location: "nbg1".to_string(),
543 image: "ubuntu-24.04".to_string(),
544 });
545 let ssh_credentials = create_test_ssh_credentials();
546
547 let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap();
548
549 assert_eq!(user_inputs.provider(), Provider::Hetzner);
550 assert!(user_inputs.provider_config().as_lxd().is_none());
551
552 let hetzner_config = user_inputs.provider_config().as_hetzner().unwrap();
553 assert_eq!(hetzner_config.api_token.expose_secret(), "test-token");
554 assert_eq!(hetzner_config.server_type, "cx22");
555 assert_eq!(hetzner_config.location, "nbg1");
556 assert_eq!(hetzner_config.image, "ubuntu-24.04");
557 }
558
559 #[test]
560 fn it_should_auto_generate_instance_name_from_environment_name() {
561 let env_name = EnvironmentName::new("production".to_string()).unwrap();
562 let provider_config = create_lxd_provider_config("prod-profile");
563 let ssh_credentials = create_test_ssh_credentials();
564
565 let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap();
566
567 assert_eq!(
568 user_inputs.instance_name().as_str(),
569 "torrust-tracker-vm-production"
570 );
571 }
572
573 // ========================================================================
574 // Cross-Service Invariant Tests
575 // ========================================================================
576
577 #[test]
578 fn it_should_reject_grafana_without_prometheus() {
579 let env_name = create_test_env_name();
580 let provider_config = create_lxd_provider_config("test-profile");
581 let ssh_credentials = create_test_ssh_credentials();
582
583 let result = UserInputs::with_tracker(
584 &env_name,
585 provider_config,
586 ssh_credentials,
587 22,
588 create_tracker_config_without_tls(),
589 None, // No Prometheus
590 Some(GrafanaConfig::default()), // Grafana enabled
591 None,
592 None, // No backup
593 );
594
595 assert!(
596 matches!(result, Err(UserInputsError::GrafanaRequiresPrometheus)),
597 "Expected GrafanaRequiresPrometheus error, got {result:?}"
598 );
599 }
600
601 #[test]
602 fn it_should_accept_grafana_with_prometheus() {
603 let env_name = create_test_env_name();
604 let provider_config = create_lxd_provider_config("test-profile");
605 let ssh_credentials = create_test_ssh_credentials();
606
607 let result = UserInputs::with_tracker(
608 &env_name,
609 provider_config,
610 ssh_credentials,
611 22,
612 create_tracker_config_without_tls(),
613 Some(PrometheusConfig::default()), // Prometheus enabled
614 Some(GrafanaConfig::default()), // Grafana enabled
615 None,
616 None, // No backup
617 );
618
619 assert!(result.is_ok());
620 }
621
622 #[test]
623 fn it_should_reject_https_section_without_tls_services() {
624 let env_name = create_test_env_name();
625 let provider_config = create_lxd_provider_config("test-profile");
626 let ssh_credentials = create_test_ssh_credentials();
627
628 let result = UserInputs::with_tracker(
629 &env_name,
630 provider_config,
631 ssh_credentials,
632 22,
633 create_tracker_config_without_tls(), // No TLS on any service
634 Some(PrometheusConfig::default()),
635 Some(GrafanaConfig::default()),
636 Some(HttpsConfig::new("admin@example.com", false).expect("valid email")), // HTTPS section present
637 None, // No backup
638 );
639
640 assert!(
641 matches!(result, Err(UserInputsError::HttpsSectionWithoutTlsServices)),
642 "Expected HttpsSectionWithoutTlsServices error, got {result:?}"
643 );
644 }
645
646 #[test]
647 fn it_should_reject_tls_services_without_https_section() {
648 let env_name = create_test_env_name();
649 let provider_config = create_lxd_provider_config("test-profile");
650 let ssh_credentials = create_test_ssh_credentials();
651
652 let result = UserInputs::with_tracker(
653 &env_name,
654 provider_config,
655 ssh_credentials,
656 22,
657 create_tracker_config_with_tls(), // Has TLS on HTTP API
658 Some(PrometheusConfig::default()),
659 Some(GrafanaConfig::default()),
660 None, // No HTTPS section
661 None, // No backup
662 );
663
664 assert!(
665 matches!(result, Err(UserInputsError::TlsServicesWithoutHttpsSection)),
666 "Expected TlsServicesWithoutHttpsSection error, got {result:?}"
667 );
668 }
669
670 #[test]
671 fn it_should_accept_tls_services_with_https_section() {
672 let env_name = create_test_env_name();
673 let provider_config = create_lxd_provider_config("test-profile");
674 let ssh_credentials = create_test_ssh_credentials();
675
676 let result = UserInputs::with_tracker(
677 &env_name,
678 provider_config,
679 ssh_credentials,
680 22,
681 create_tracker_config_with_tls(),
682 Some(PrometheusConfig::default()),
683 Some(GrafanaConfig::default()),
684 Some(HttpsConfig::new("admin@example.com", false).expect("valid email")),
685 None, // No backup
686 );
687
688 assert!(result.is_ok());
689 }
690
691 #[test]
692 fn it_should_accept_no_tls_and_no_https() {
693 let env_name = create_test_env_name();
694 let provider_config = create_lxd_provider_config("test-profile");
695 let ssh_credentials = create_test_ssh_credentials();
696
697 let result = UserInputs::with_tracker(
698 &env_name,
699 provider_config,
700 ssh_credentials,
701 22,
702 create_tracker_config_without_tls(),
703 Some(PrometheusConfig::default()),
704 Some(GrafanaConfig::default()),
705 None, // No HTTPS
706 None, // No backup
707 );
708
709 assert!(result.is_ok());
710 }
711
712 #[test]
713 fn it_should_provide_helpful_error_messages() {
714 assert!(UserInputsError::GrafanaRequiresPrometheus
715 .to_string()
716 .contains("Grafana requires Prometheus"));
717 assert!(UserInputsError::GrafanaRequiresPrometheus
718 .to_string()
719 .contains("Tip:"));
720 assert!(UserInputsError::GrafanaRequiresPrometheus
721 .help()
722 .contains("prometheus"));
723
724 assert!(UserInputsError::HttpsSectionWithoutTlsServices
725 .to_string()
726 .contains("Tip:"));
727 assert!(UserInputsError::HttpsSectionWithoutTlsServices
728 .help()
729 .contains("use_tls_proxy"));
730
731 assert!(UserInputsError::TlsServicesWithoutHttpsSection
732 .to_string()
733 .contains("Tip:"));
734 assert!(UserInputsError::TlsServicesWithoutHttpsSection
735 .help()
736 .contains("https"));
737 }
738}