Skip to main content

torrust_tracker_deployer_lib/domain/
instance_name.rs

1//! Instance name validation and management for LXD VMs and containers
2//!
3//! This module provides the `InstanceName` type which ensures instance names
4//! follow the strict naming requirements imposed by LXD for security and
5//! compatibility reasons. The validated names are used for:
6//!
7//! - **LXD Virtual Machines**: Names for provisioned VMs in production/deployment environments
8//! - **Testing Containers**: Names for Docker containers used in end-to-end tests
9//!
10//! ## Naming Requirements
11//!
12//! - Length: 1-63 characters
13//! - Characters: ASCII letters, numbers, and dashes only
14//! - Cannot start with digit or dash
15//! - Cannot end with dash
16//!
17//! These restrictions ensure compatibility with DNS records, file systems,
18//! security profiles, and host names across different environments.
19
20use std::fmt;
21use std::str::FromStr;
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26/// Errors that can occur during instance name validation
27#[derive(Debug, Error, PartialEq)]
28pub enum InstanceNameError {
29    #[error("Instance name cannot be empty")]
30    Empty,
31
32    #[error("Instance name must be 63 characters or less, got {length} characters")]
33    TooLong { length: usize },
34
35    #[error("Instance name must not start with a digit or dash")]
36    InvalidFirstCharacter,
37
38    #[error("Instance name must not end with a dash")]
39    InvalidLastCharacter,
40
41    #[error("Instance name must contain only ASCII letters, numbers, and dashes")]
42    InvalidCharacters,
43}
44
45/// A validated instance name following LXD naming requirements.
46///
47/// This type ensures that names are valid for both LXD virtual machines used in
48/// production deployments and Docker containers used in end-to-end testing.
49///
50/// Valid instance names must fulfill the following requirements:
51/// - The name must be between 1 and 63 characters long
52/// - The name must contain only letters, numbers and dashes from the ASCII table
53/// - The name must not start with a digit or a dash
54/// - The name must not end with a dash
55///
56/// These requirements ensure that the instance name can be used in DNS records,
57/// on the file system, in various security profiles and as the host name.
58///
59/// # Use Cases
60///
61/// - **LXD Virtual Machines**: Naming VMs provisioned for deployment environments
62/// - **Testing Containers**: Naming Docker containers in E2E test scenarios
63///
64/// # Examples
65///
66/// ```rust
67/// use torrust_tracker_deployer_lib::domain::InstanceName;
68///
69/// // Valid instance names - accepts both &str and String
70/// let vm_instance = InstanceName::new("torrust-vm-prod")?;
71/// let test_container = InstanceName::new("test-container-01".to_string())?;
72/// let dynamic_name = InstanceName::new(format!("app-{}", "staging"))?;
73///
74/// // Invalid instance names
75/// assert!(InstanceName::new("").is_err());
76/// assert!(InstanceName::new("test-").is_err());
77/// assert!(InstanceName::new("-test").is_err());
78/// assert!(InstanceName::new("1test").is_err());
79/// # Ok::<(), Box<dyn std::error::Error>>(())
80/// ```
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(try_from = "String")]
83pub struct InstanceName(String);
84
85impl InstanceName {
86    /// Creates a new `InstanceName` from a string if it's valid.
87    ///
88    /// This method validates that the provided name meets the requirements for both
89    /// LXD virtual machines and testing containers.
90    ///
91    /// # Arguments
92    ///
93    /// * `name` - The instance name to validate (accepts `&str`, `String`, or anything that implements `Into<String>`)
94    ///
95    /// # Returns
96    ///
97    /// * `Ok(InstanceName)` - If the name is valid
98    /// * `Err(InstanceNameError)` - If the name violates LXD naming requirements
99    ///
100    /// # Errors
101    ///
102    /// This function will return an error if the name violates any LXD naming requirements:
103    /// * Empty name
104    /// * Name longer than 63 characters
105    /// * Name contains non-ASCII letters, numbers, or dashes
106    /// * Name starts with a digit or dash
107    /// * Name ends with a dash
108    ///
109    /// # Examples
110    ///
111    /// ```rust
112    /// use torrust_tracker_deployer_lib::domain::InstanceName;
113    ///
114    /// // Valid names for VMs and containers - accepts both &str and String
115    /// let vm_name = InstanceName::new("torrust-vm-prod")?;
116    /// let container_name = InstanceName::new("test-web-01".to_string())?;
117    /// let dynamic_name = InstanceName::new(format!("app-{}", "staging"))?;
118    ///
119    /// // Invalid names
120    /// assert!(InstanceName::new("").is_err());
121    /// assert!(InstanceName::new("test-").is_err());
122    /// assert!(InstanceName::new("-test").is_err());
123    /// assert!(InstanceName::new("1test").is_err());
124    /// # Ok::<(), Box<dyn std::error::Error>>(())
125    /// ```
126    pub fn new<S: Into<String>>(name: S) -> Result<Self, InstanceNameError> {
127        let name = name.into();
128        Self::validate(&name)?;
129        Ok(Self(name))
130    }
131
132    /// Returns the instance name as a string slice.
133    #[must_use]
134    pub fn as_str(&self) -> &str {
135        &self.0
136    }
137
138    /// Validates an instance name according to LXD requirements.
139    ///
140    /// # Arguments
141    ///
142    /// * `name` - The name to validate
143    ///
144    /// # Returns
145    ///
146    /// * `Ok(())` - If the name is valid
147    /// * `Err(InstanceNameError)` - If the name violates any requirement
148    fn validate(name: &str) -> Result<(), InstanceNameError> {
149        // Check length: must be between 1 and 63 characters
150        if name.is_empty() {
151            return Err(InstanceNameError::Empty);
152        }
153        if name.len() > 63 {
154            return Err(InstanceNameError::TooLong { length: name.len() });
155        }
156
157        // Check characters: only ASCII letters, numbers, and dashes
158        if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
159            return Err(InstanceNameError::InvalidCharacters);
160        }
161
162        // Check first character: must not be a digit or dash
163        if let Some(first_char) = name.chars().next() {
164            if first_char.is_ascii_digit() || first_char == '-' {
165                return Err(InstanceNameError::InvalidFirstCharacter);
166            }
167        }
168
169        // Check last character: must not be a dash
170        if name.ends_with('-') {
171            return Err(InstanceNameError::InvalidLastCharacter);
172        }
173
174        Ok(())
175    }
176}
177
178impl fmt::Display for InstanceName {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        write!(f, "{}", self.0)
181    }
182}
183
184impl FromStr for InstanceName {
185    type Err = InstanceNameError;
186
187    fn from_str(s: &str) -> Result<Self, InstanceNameError> {
188        Self::new(s.to_string())
189    }
190}
191
192impl AsRef<str> for InstanceName {
193    fn as_ref(&self) -> &str {
194        &self.0
195    }
196}
197
198impl TryFrom<String> for InstanceName {
199    type Error = InstanceNameError;
200
201    fn try_from(value: String) -> Result<Self, InstanceNameError> {
202        Self::new(value)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn it_should_create_valid_instance_name() {
212        let name = InstanceName::new("test-instance").unwrap();
213        assert_eq!(name.as_str(), "test-instance");
214    }
215
216    #[test]
217    fn it_should_accept_string_slice_and_owned_string() {
218        // Test with &str
219        let name1 = InstanceName::new("test-instance").unwrap();
220        assert_eq!(name1.as_str(), "test-instance");
221
222        // Test with String
223        let name2 = InstanceName::new("test-instance".to_string()).unwrap();
224        assert_eq!(name2.as_str(), "test-instance");
225
226        // Test with String from format!
227        let name3 = InstanceName::new(format!("app-{}", "prod")).unwrap();
228        assert_eq!(name3.as_str(), "app-prod");
229
230        // Test that both are equal
231        assert_eq!(name1, name2);
232    }
233
234    #[test]
235    fn it_should_create_instance_name_with_numbers() {
236        let name = InstanceName::new("test123").unwrap();
237        assert_eq!(name.as_str(), "test123");
238    }
239
240    #[test]
241    fn it_should_create_instance_name_with_dashes() {
242        let name = InstanceName::new("test-instance-name").unwrap();
243        assert_eq!(name.as_str(), "test-instance-name");
244    }
245
246    #[test]
247    fn it_should_create_single_character_name() {
248        let name = InstanceName::new("a").unwrap();
249        assert_eq!(name.as_str(), "a");
250    }
251
252    #[test]
253    fn it_should_create_63_character_name() {
254        let long_name = "a".repeat(63);
255        let name = InstanceName::new(long_name.clone()).unwrap();
256        assert_eq!(name.as_str(), long_name);
257    }
258
259    #[test]
260    fn it_should_reject_empty_name() {
261        let result = InstanceName::new("");
262        assert!(result.is_err());
263        assert_eq!(result.unwrap_err(), InstanceNameError::Empty);
264    }
265
266    #[test]
267    fn it_should_reject_name_longer_than_63_characters() {
268        let long_name = "a".repeat(64);
269        let result = InstanceName::new(long_name);
270        assert!(result.is_err());
271        assert_eq!(
272            result.unwrap_err(),
273            InstanceNameError::TooLong { length: 64 }
274        );
275    }
276
277    #[test]
278    fn it_should_reject_name_starting_with_digit() {
279        let result = InstanceName::new("1test");
280        assert!(result.is_err());
281        assert_eq!(
282            result.unwrap_err(),
283            InstanceNameError::InvalidFirstCharacter
284        );
285    }
286
287    #[test]
288    fn it_should_reject_name_starting_with_dash() {
289        let result = InstanceName::new("-test");
290        assert!(result.is_err());
291        assert_eq!(
292            result.unwrap_err(),
293            InstanceNameError::InvalidFirstCharacter
294        );
295    }
296
297    #[test]
298    fn it_should_reject_name_ending_with_dash() {
299        let result = InstanceName::new("test-");
300        assert!(result.is_err());
301        assert_eq!(result.unwrap_err(), InstanceNameError::InvalidLastCharacter);
302    }
303
304    #[test]
305    fn it_should_reject_name_with_invalid_characters() {
306        let invalid_chars = vec![
307            "test@instance",
308            "test.instance",
309            "test_instance",
310            "test instance",
311            "test#instance",
312            "test$instance",
313            "test%instance",
314            "test*instance",
315            "test+instance",
316            "test=instance",
317            "test[instance]",
318            "test{instance}",
319            "test|instance",
320            "test\\instance",
321            "test:instance",
322            "test;instance",
323            "test\"instance",
324            "test'instance",
325            "test<instance>",
326            "test,instance",
327            "test?instance",
328            "test/instance",
329        ];
330
331        for invalid_name in invalid_chars {
332            let result = InstanceName::new(invalid_name);
333            assert!(result.is_err());
334            assert_eq!(result.unwrap_err(), InstanceNameError::InvalidCharacters);
335        }
336    }
337
338    #[test]
339    fn it_should_reject_name_with_unicode_characters() {
340        let result = InstanceName::new("tést-instance");
341        assert!(result.is_err());
342        assert_eq!(result.unwrap_err(), InstanceNameError::InvalidCharacters);
343    }
344
345    #[test]
346    fn it_should_display_instance_name() {
347        let name = InstanceName::new("test-instance").unwrap();
348        assert_eq!(format!("{name}"), "test-instance");
349    }
350
351    #[test]
352    fn it_should_parse_valid_name_from_string() {
353        let name: InstanceName = "test-instance".parse().unwrap();
354        assert_eq!(name.as_str(), "test-instance");
355    }
356
357    #[test]
358    fn it_should_fail_parsing_invalid_name_from_string() {
359        let result: Result<InstanceName, _> = "test-".parse();
360        assert!(result.is_err());
361        assert_eq!(result.unwrap_err(), InstanceNameError::InvalidLastCharacter);
362    }
363
364    #[test]
365    fn it_should_implement_as_ref() {
366        let name = InstanceName::new("test-instance").unwrap();
367        let as_ref: &str = name.as_ref();
368        assert_eq!(as_ref, "test-instance");
369    }
370
371    #[test]
372    fn it_should_be_cloneable_and_comparable() {
373        let name1 = InstanceName::new("test-instance").unwrap();
374        let name2 = name1.clone();
375        assert_eq!(name1, name2);
376    }
377
378    #[test]
379    fn it_should_be_hashable() {
380        use std::collections::HashSet;
381
382        let mut set = HashSet::new();
383        let name1 = InstanceName::new("test-instance").unwrap();
384        let name2 = InstanceName::new("test-instance").unwrap();
385        let name3 = InstanceName::new("other-instance").unwrap();
386
387        set.insert(name1);
388        set.insert(name2); // Should not increase size due to equality
389        set.insert(name3);
390
391        assert_eq!(set.len(), 2);
392    }
393
394    #[test]
395    fn it_should_serialize_and_deserialize() {
396        let name = InstanceName::new("test-instance").unwrap();
397
398        // Serialize
399        let json = serde_json::to_string(&name).unwrap();
400        assert_eq!(json, "\"test-instance\"");
401
402        // Deserialize
403        let deserialized: InstanceName = serde_json::from_str(&json).unwrap();
404        assert_eq!(deserialized, name);
405    }
406
407    #[test]
408    fn it_should_fail_deserializing_invalid_name() {
409        let invalid_json = "\"test-\"";
410        let result: Result<InstanceName, _> = serde_json::from_str(invalid_json);
411        assert!(result.is_err());
412    }
413}