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:
Provider enum - Represents the available infrastructure providers
ProviderConfig enum - Tagged enum containing provider-specific configuration (domain type with validated fields)
LxdConfig struct - LXD-specific configuration (domain type)
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:
ProviderSection enum - Serde-tagged enum for JSON deserialization (raw String primitives)
LxdProviderSection struct - LXD config with raw String fields
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
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
Architectural Constraints
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
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
providermodule in the domain layer with:Providerenum - Represents the available infrastructure providersProviderConfigenum - Tagged enum containing provider-specific configuration (domain type with validated fields)LxdConfigstruct - LXD-specific configuration (domain type)HetznerConfigstruct - Hetzner-specific configuration (domain type, placeholder for Phase 2)Application Layer Types (
src/application/command_handlers/create/config/)Create config types for JSON parsing:
ProviderSectionenum - Serde-tagged enum for JSON deserialization (raw String primitives)LxdProviderSectionstruct - LXD config with raw String fieldsHetznerProviderSectionstruct - Hetzner config with raw String fields (placeholder)Conversion Pattern
The application layer types convert to domain types via validation:
Implementation Details
Domain Module Structure
Application Module Structure
Domain Types (Validated)
Application Layer Types (Raw Primitives)
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
Providerenum serialization/deserializationProvider::template_dir_name()returns correct valuesProviderConfigtagged enum parsing for LXDProviderConfigtagged enum parsing for HetznerProviderSection::to_provider_config()validationTest Examples
Acceptance Criteria
Providerenum exists withLxdandHetznervariantsProviderConfigenum uses Serde tagged enum (domain layer)ProviderSectionenum uses Serde tagged enum (application layer)LxdConfigcontainsprofile_name: ProfileName(validated)LxdProviderSectioncontainsprofile_name: String(raw)HetznerConfigcontains placeholder fields for Phase 2HetznerProviderSectioncontains raw string fieldsProviderSection::to_provider_config()validates and convertsDebug,Clone,Serialize,DeserializeArchitectural Constraints
ProfileName, notString)String)Notes
HetznerConfigfields are placeholders - exact fields will be refined in Phase 2Providerenum may gain atemplate_dir_name()method for path constructionTryFrom<ProviderSection> for ProviderConfigas an alternative APIRelated