Skip to content

Add Provider enum and ProviderConfig types to domain layer #206

@josecelano

Description

@josecelano

Task: Add Provider enum and ProviderConfig types

Epic: #205 - Add Hetzner Provider Support
Phase: 1 - Make LXD Explicit
Dependencies: None (foundational task)

Overview

Create the foundational type system for provider selection. This task establishes the types that all subsequent Phase 1 tasks will depend on.

Detailed Requirements

Domain Layer Types (src/domain/provider/)

Create a new provider module in the domain layer with:

  1. Provider enum - Represents the available infrastructure providers
  2. ProviderConfig enum - Tagged enum containing provider-specific configuration (domain type with validated fields)
  3. LxdConfig struct - LXD-specific configuration (domain type)
  4. HetznerConfig struct - Hetzner-specific configuration (domain type, placeholder for Phase 2)

Application Layer Types (src/application/command_handlers/create/config/)

Create config types for JSON parsing:

  1. ProviderSection enum - Serde-tagged enum for JSON deserialization (raw String primitives)
  2. LxdProviderSection struct - LXD config with raw String fields
  3. HetznerProviderSection struct - Hetzner config with raw String fields (placeholder)

Conversion Pattern

The application layer types convert to domain types via validation:

impl ProviderSection {
    pub fn to_provider_config(&self) -> Result<ProviderConfig, ProviderConfigError> {
        // Validates raw strings and converts to domain types
    }
}

Implementation Details

Domain Module Structure

src/domain/
├── mod.rs                    # Add: pub mod provider;
└── provider/
    ├── mod.rs                # Re-exports
    ├── provider.rs           # Provider enum
    └── config.rs             # ProviderConfig, LxdConfig, HetznerConfig

Application Module Structure

src/application/command_handlers/create/config/
├── mod.rs                    # Add: pub mod provider_section;
└── provider_section.rs       # ProviderSection, LxdProviderSection, HetznerProviderSection

Domain Types (Validated)

// src/domain/provider/provider.rs

//! Provider enum representing available infrastructure providers.

use serde::{Deserialize, Serialize};

/// Represents the available infrastructure providers.
///
/// Each provider has its own configuration requirements and
/// OpenTofu template directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
    /// LXD - Local development and testing provider
    Lxd,
    /// Hetzner Cloud - Production cloud provider
    Hetzner,
}

impl Provider {
    /// Returns the directory name used for OpenTofu templates.
    ///
    /// This is used to construct paths like `templates/tofu/{provider}/`.
    #[must_use]
    pub fn template_dir_name(&self) -> &'static str {
        match self {
            Self::Lxd => "lxd",
            Self::Hetzner => "hetzner",
        }
    }
}

impl std::fmt::Display for Provider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Lxd => write!(f, "lxd"),
            Self::Hetzner => write!(f, "hetzner"),
        }
    }
}
// src/domain/provider/config.rs

//! This module contains domain types for provider-specific configuration.
//! These types use validated domain types (like `ProfileName`) and represent
//! the semantic meaning of provider configuration.
//!
//! For config types used in JSON deserialization, see
//! `application::command_handlers::create::config::provider_section`.

use serde::{Deserialize, Serialize};

use super::Provider;
use crate::domain::user_inputs::ProfileName;

/// Provider-specific configuration with validated domain types.
///
/// This is a **domain type** that contains validated fields. It is created
/// by converting from `ProviderSection` (application layer) which handles
/// the raw JSON deserialization and validation.
///
/// # Note on Layer Placement
///
/// This is a **domain type** with validated fields. For JSON deserialization,
/// use `ProviderSection` in the application layer, then convert to this type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "lowercase")]
pub enum ProviderConfig {
    /// LXD provider configuration
    Lxd(LxdConfig),
    /// Hetzner Cloud provider configuration
    Hetzner(HetznerConfig),
}

impl ProviderConfig {
    /// Returns the provider type for this configuration.
    #[must_use]
    pub fn provider(&self) -> Provider {
        match self {
            Self::Lxd(_) => Provider::Lxd,
            Self::Hetzner(_) => Provider::Hetzner,
        }
    }

    /// Returns the LXD configuration if this is an LXD provider.
    ///
    /// # Panics
    ///
    /// Panics if called on a non-LXD provider configuration.
    #[must_use]
    pub fn as_lxd(&self) -> &LxdConfig {
        match self {
            Self::Lxd(config) => config,
            _ => panic!("Expected LXD provider configuration"),
        }
    }

    /// Returns the Hetzner configuration if this is a Hetzner provider.
    ///
    /// # Panics
    ///
    /// Panics if called on a non-Hetzner provider configuration.
    #[must_use]
    pub fn as_hetzner(&self) -> &HetznerConfig {
        match self {
            Self::Hetzner(config) => config,
            _ => panic!("Expected Hetzner provider configuration"),
        }
    }
}

/// LXD-specific configuration with validated domain types.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LxdConfig {
    /// The LXD profile name to use for the VM.
    /// This profile must exist in LXD and typically configures
    /// networking, storage, and resource limits.
    pub profile_name: ProfileName,
}

/// Hetzner Cloud-specific configuration.
///
/// This is a placeholder for Phase 2 implementation.
/// Fields will be added when implementing Hetzner support.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HetznerConfig {
    /// Hetzner API token for authentication.
    /// This should be kept secure and not committed to version control.
    pub api_token: String,

    /// Server type (e.g., "cx11", "cx21", "cpx11").
    pub server_type: String,

    /// Datacenter location (e.g., "nbg1", "fsn1", "hel1").
    pub location: String,

    /// OS image to use (e.g., "ubuntu-24.04").
    pub image: String,
}

Application Layer Types (Raw Primitives)

// src/application/command_handlers/create/config/provider_section.rs

//! Config types for provider-specific JSON deserialization.
//!
//! These types use raw `String` fields and are converted to domain types
//! (`ProviderConfig`, `LxdConfig`, etc.) after validation.

use serde::{Deserialize, Serialize};

use crate::domain::provider::{HetznerConfig, LxdConfig, ProviderConfig};
use crate::domain::user_inputs::ProfileName;

/// Provider configuration section for JSON deserialization.
///
/// Uses Serde's internally tagged enum to parse JSON like:
/// ```json
/// {
///   "provider": "lxd",
///   "profile_name": "torrust-tracker"
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "lowercase")]
pub enum ProviderSection {
    /// LXD provider configuration
    Lxd(LxdProviderSection),
    /// Hetzner Cloud provider configuration
    Hetzner(HetznerProviderSection),
}

impl ProviderSection {
    /// Converts to domain `ProviderConfig` with validation.
    ///
    /// # Errors
    ///
    /// Returns an error if validation fails (e.g., invalid profile name format).
    pub fn to_provider_config(&self) -> Result<ProviderConfig, ProviderSectionError> {
        match self {
            Self::Lxd(lxd) => {
                let profile_name = ProfileName::new(&lxd.profile_name)
                    .map_err(|e| ProviderSectionError::InvalidProfileName(e.to_string()))?;
                Ok(ProviderConfig::Lxd(LxdConfig { profile_name }))
            }
            Self::Hetzner(hetzner) => {
                // Basic validation - more comprehensive validation in Phase 2
                if hetzner.api_token.is_empty() {
                    return Err(ProviderSectionError::EmptyApiToken);
                }
                Ok(ProviderConfig::Hetzner(HetznerConfig {
                    api_token: hetzner.api_token.clone(),
                    server_type: hetzner.server_type.clone(),
                    location: hetzner.location.clone(),
                    image: hetzner.image.clone(),
                }))
            }
        }
    }
}

/// LXD provider section with raw string fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LxdProviderSection {
    /// Raw profile name string (validated when converting to domain type).
    pub profile_name: String,
}

/// Hetzner provider section with raw string fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HetznerProviderSection {
    /// Hetzner API token.
    pub api_token: String,

    /// Server type (e.g., "cx11").
    pub server_type: String,

    /// Datacenter location (e.g., "nbg1").
    pub location: String,

    /// OS image (e.g., "ubuntu-24.04").
    pub image: String,
}

/// Errors that can occur when converting `ProviderSection` to `ProviderConfig`.
#[derive(Debug, thiserror::Error)]
pub enum ProviderSectionError {
    /// Invalid LXD profile name format.
    #[error("Invalid profile name: {0}")]
    InvalidProfileName(String),

    /// Hetzner API token is empty.
    #[error("Hetzner API token cannot be empty")]
    EmptyApiToken,
}

JSON Configuration Examples

LXD Provider (Current Use Case)

{
  "provider": {
    "provider": "lxd",
    "profile_name": "torrust-tracker"
  }
}

Hetzner Provider (Phase 2)

{
  "provider": {
    "provider": "hetzner",
    "api_token": "your-hetzner-api-token",
    "server_type": "cx11",
    "location": "nbg1",
    "image": "ubuntu-24.04"
  }
}

Testing Requirements

Unit Tests

  • Provider enum serialization/deserialization
  • Provider::template_dir_name() returns correct values
  • ProviderConfig tagged enum parsing for LXD
  • ProviderConfig tagged enum parsing for Hetzner
  • ProviderSection::to_provider_config() validation
  • Error handling for invalid profile names
  • Error handling for empty API token

Test Examples

#[test]
fn provider_serializes_to_lowercase() {
    assert_eq!(serde_json::to_string(&Provider::Lxd).unwrap(), "\"lxd\"");
    assert_eq!(serde_json::to_string(&Provider::Hetzner).unwrap(), "\"hetzner\"");
}

#[test]
fn provider_config_parses_lxd() {
    let json = r#"{"provider": "lxd", "profile_name": "default"}"#;
    let config: ProviderSection = serde_json::from_str(json).unwrap();
    assert!(matches!(config, ProviderSection::Lxd(_)));
}

#[test]
fn lxd_provider_section_converts_to_domain() {
    let section = ProviderSection::Lxd(LxdProviderSection {
        profile_name: "torrust-tracker".to_string(),
    });
    let config = section.to_provider_config().unwrap();
    assert!(matches!(config, ProviderConfig::Lxd(_)));
}

Acceptance Criteria

  • Provider enum exists with Lxd and Hetzner variants
  • ProviderConfig enum uses Serde tagged enum (domain layer)
  • ProviderSection enum uses Serde tagged enum (application layer)
  • LxdConfig contains profile_name: ProfileName (validated)
  • LxdProviderSection contains profile_name: String (raw)
  • HetznerConfig contains placeholder fields for Phase 2
  • HetznerProviderSection contains raw string fields
  • ProviderSection::to_provider_config() validates and converts
  • All types implement Debug, Clone, Serialize, Deserialize
  • Unit tests cover serialization and parsing
  • Code follows project conventions (see docs/contributing/)

Architectural Constraints

  • Domain types use validated types (ProfileName, not String)
  • Application types use raw primitives (String)
  • Conversion from application to domain includes validation
  • Follow DDD layer placement guidelines (see docs/contributing/ddd-layer-placement.md)
  • Error handling follows project conventions (see docs/contributing/error-handling.md)
  • Both application config types and domain types should be serializable/deserializable with Serde

Notes

  • HetznerConfig fields are placeholders - exact fields will be refined in Phase 2
  • The Provider enum may gain a template_dir_name() method for path construction
  • Consider adding TryFrom<ProviderSection> for ProviderConfig as an alternative API

Related

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions