lmrc_config_validator/
lib.rs

1//! Configuration validation library for LMRC Stack projects
2//!
3//! This library provides types and validation for LMRC Stack project configurations.
4//! It defines the schema for project configuration files and validates them.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use thiserror::Error;
9use validator::Validate;
10
11pub mod providers;
12
13/// Main configuration structure for LMRC Stack projects
14#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
15pub struct LmrcConfig {
16    /// Project metadata
17    #[validate(nested)]
18    pub project: ProjectConfig,
19
20    /// Infrastructure provider selection
21    #[validate(nested)]
22    pub providers: ProviderConfig,
23
24    /// Application configuration
25    #[validate(nested)]
26    pub apps: AppsConfig,
27
28    /// Infrastructure-specific configurations
29    #[validate(nested)]
30    pub infrastructure: InfrastructureConfig,
31}
32
33/// Project metadata
34#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
35pub struct ProjectConfig {
36    /// Project name (alphanumeric with hyphens/underscores)
37    #[validate(length(min = 1, max = 100))]
38    pub name: String,
39
40    /// Project description
41    #[validate(length(min = 1, max = 500))]
42    pub description: String,
43}
44
45/// Provider selection configuration
46#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
47pub struct ProviderConfig {
48    /// Server provider (currently only "hetzner")
49    pub server: String,
50
51    /// Kubernetes provider (currently "k3s" or "kubernetes")
52    pub kubernetes: String,
53
54    /// Database provider (currently only "postgres")
55    pub database: String,
56
57    /// DNS provider (currently only "cloudflare")
58    pub dns: String,
59
60    /// Git provider (currently only "gitlab")
61    pub git: String,
62}
63
64/// Applications configuration
65#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
66pub struct AppsConfig {
67    /// List of application names in the workspace
68    #[validate(length(min = 1))]
69    pub applications: Vec<ApplicationEntry>,
70}
71
72/// Application type - determines which template to use
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "kebab-case")]
75pub enum AppType {
76    /// Gateway with authentication (session-based)
77    Gateway,
78    /// API service without authentication
79    Api,
80    /// Database migrator
81    Migrator,
82    /// Basic application (simple Rust binary)
83    Basic,
84}
85
86impl AppType {
87    /// Get all available app types
88    pub fn all() -> Vec<Self> {
89        vec![
90            AppType::Gateway,
91            AppType::Api,
92            AppType::Migrator,
93            AppType::Basic,
94        ]
95    }
96
97    /// Get display name for the app type
98    pub fn display_name(&self) -> &'static str {
99        match self {
100            AppType::Gateway => "Gateway (with authentication)",
101            AppType::Api => "API Service (no auth)",
102            AppType::Migrator => "Database Migrator",
103            AppType::Basic => "Basic Application",
104        }
105    }
106
107    /// Get description for the app type
108    pub fn description(&self) -> &'static str {
109        match self {
110            AppType::Gateway => "HTTP API gateway with session-based authentication, uses SeaORM + PostgreSQL",
111            AppType::Api => "HTTP API service without authentication (for internal services behind gateway)",
112            AppType::Migrator => "SeaORM migration CLI for database schema management",
113            AppType::Basic => "Simple Rust binary application with no HTTP framework",
114        }
115    }
116}
117
118/// Single application entry
119#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
120pub struct ApplicationEntry {
121    /// Application name
122    #[validate(length(min = 1, max = 50))]
123    pub name: String,
124
125    /// Application type - determines which template to use
126    #[serde(default)]
127    pub app_type: Option<AppType>,
128
129    /// Docker configuration for this application
130    #[serde(default)]
131    #[validate(nested)]
132    pub docker: Option<DockerConfig>,
133
134    /// Kubernetes deployment configuration
135    #[serde(default)]
136    #[validate(nested)]
137    pub deployment: Option<DeploymentConfig>,
138}
139
140/// Docker build configuration for an application
141#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
142pub struct DockerConfig {
143    /// Dockerfile path relative to app directory
144    #[serde(default = "default_dockerfile")]
145    pub dockerfile: String,
146
147    /// Build context path relative to workspace root
148    #[serde(default = "default_context")]
149    pub context: String,
150
151    /// Image tags (e.g., ["latest", "v1.0.0"])
152    #[serde(default = "default_tags")]
153    pub tags: Vec<String>,
154}
155
156fn default_dockerfile() -> String {
157    "Dockerfile".to_string()
158}
159
160fn default_context() -> String {
161    ".".to_string()
162}
163
164fn default_tags() -> Vec<String> {
165    vec!["latest".to_string()]
166}
167
168impl Default for DockerConfig {
169    fn default() -> Self {
170        Self {
171            dockerfile: default_dockerfile(),
172            context: default_context(),
173            tags: default_tags(),
174        }
175    }
176}
177
178/// Kubernetes deployment configuration for an application
179#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
180pub struct DeploymentConfig {
181    /// Number of replicas
182    #[serde(default = "default_replicas")]
183    #[validate(range(min = 1, max = 100))]
184    pub replicas: u32,
185
186    /// Container port
187    #[serde(default = "default_port")]
188    #[validate(range(min = 1, max = 65535))]
189    pub port: u16,
190
191    /// CPU request (e.g., "100m", "1")
192    #[serde(default)]
193    pub cpu_request: Option<String>,
194
195    /// Memory request (e.g., "128Mi", "1Gi")
196    #[serde(default)]
197    pub memory_request: Option<String>,
198
199    /// CPU limit (e.g., "1", "2")
200    #[serde(default)]
201    pub cpu_limit: Option<String>,
202
203    /// Memory limit (e.g., "512Mi", "2Gi")
204    #[serde(default)]
205    pub memory_limit: Option<String>,
206
207    /// Environment variables
208    #[serde(default)]
209    pub env: Vec<EnvVar>,
210}
211
212fn default_replicas() -> u32 {
213    1
214}
215
216fn default_port() -> u16 {
217    8080
218}
219
220impl Default for DeploymentConfig {
221    fn default() -> Self {
222        Self {
223            replicas: default_replicas(),
224            port: default_port(),
225            cpu_request: None,
226            memory_request: None,
227            cpu_limit: None,
228            memory_limit: None,
229            env: Vec::new(),
230        }
231    }
232}
233
234/// Environment variable configuration
235#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
236pub struct EnvVar {
237    /// Variable name
238    #[validate(length(min = 1))]
239    pub name: String,
240
241    /// Variable value (for non-sensitive values)
242    #[serde(default)]
243    pub value: Option<String>,
244
245    /// Reference to a secret (alternative to value)
246    #[serde(default)]
247    pub secret_ref: Option<SecretRef>,
248}
249
250/// Reference to a Kubernetes secret
251#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
252pub struct SecretRef {
253    /// Secret name
254    #[validate(length(min = 1))]
255    pub name: String,
256
257    /// Key within the secret
258    #[validate(length(min = 1))]
259    pub key: String,
260}
261
262/// Infrastructure configuration for all providers
263#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
264pub struct InfrastructureConfig {
265    /// Infrastructure provider (e.g., "hetzner", "aws", "digitalocean")
266    #[serde(default = "default_provider")]
267    pub provider: String,
268
269    /// Network configuration
270    #[serde(default)]
271    #[validate(nested)]
272    pub network: Option<NetworkConfig>,
273
274    /// Server groups defining infrastructure topology
275    #[serde(default)]
276    pub servers: Vec<ServerGroup>,
277
278    /// K3s cluster configuration
279    #[validate(nested)]
280    pub k3s: Option<K3sConfig>,
281
282    /// PostgreSQL configuration
283    #[validate(nested)]
284    pub postgres: Option<PostgresConfig>,
285
286    /// DNS configuration
287    #[validate(nested)]
288    pub dns: Option<DnsConfig>,
289
290    /// GitLab-specific configuration
291    #[validate(nested)]
292    pub gitlab: Option<GitLabConfig>,
293}
294
295fn default_provider() -> String {
296    "hetzner".to_string()
297}
298
299/// Network configuration
300#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
301pub struct NetworkConfig {
302    /// Enable private networking between servers
303    #[serde(default)]
304    pub enable_private_network: bool,
305
306    /// Private network CIDR (e.g., "10.0.0.0/16")
307    pub private_network: Option<String>,
308
309    /// Firewall rules
310    #[serde(default)]
311    pub firewall_rules: Vec<FirewallRule>,
312}
313
314/// Firewall rule configuration
315#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
316pub struct FirewallRule {
317    /// Rule name
318    #[validate(length(min = 1))]
319    pub name: String,
320
321    /// Direction: "inbound" or "outbound"
322    pub direction: String,
323
324    /// Protocol: "tcp", "udp", "icmp", or "any"
325    pub protocol: String,
326
327    /// Port or port range (e.g., "80", "8000-9000")
328    pub port: Option<String>,
329
330    /// Source (CIDR or server group name)
331    pub source: String,
332
333    /// Destination (server group name or "any")
334    pub destination: String,
335
336    /// Action: "allow" or "deny"
337    pub action: String,
338}
339
340/// Server group defining a set of similar servers
341#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
342pub struct ServerGroup {
343    /// Unique name for this server group
344    #[validate(length(min = 1, max = 50))]
345    pub name: String,
346
347    /// Role/purpose of this server group
348    pub role: ServerRole,
349
350    /// Server type (e.g., "cx11", "cpx21" for Hetzner)
351    pub server_type: String,
352
353    /// Location/region (e.g., "nbg1", "fsn1" for Hetzner)
354    pub location: String,
355
356    /// Number of servers in this group
357    #[validate(range(min = 1, max = 50))]
358    pub count: u32,
359
360    /// Labels for organization and selection
361    #[serde(default)]
362    pub labels: HashMap<String, String>,
363
364    /// SSH key names or IDs
365    #[serde(default)]
366    pub ssh_keys: Vec<String>,
367
368    /// Image/OS to use (optional, defaults to provider default)
369    pub image: Option<String>,
370}
371
372/// Server role/purpose
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
374#[serde(rename_all = "kebab-case")]
375pub enum ServerRole {
376    /// K3s control plane node
377    K3sControl,
378    /// K3s worker node
379    K3sWorker,
380    /// PostgreSQL database server
381    Postgres,
382    /// MySQL/MariaDB database server
383    MySQL,
384    /// MongoDB database server
385    MongoDB,
386    /// Redis cache server
387    Redis,
388    /// Memcached cache server
389    Memcached,
390    /// RabbitMQ message broker
391    RabbitMQ,
392    /// Kafka message broker
393    Kafka,
394    /// Elasticsearch search engine
395    Elasticsearch,
396    /// Load balancer (HAProxy, Nginx, etc.)
397    LoadBalancer,
398    /// Monitoring server (Prometheus, Grafana, etc.)
399    Monitoring,
400    /// CI/CD runner (GitLab Runner, etc.)
401    CIRunner,
402    /// Bastion/Jump server for SSH access
403    Bastion,
404    /// Storage server (MinIO, NFS, etc.)
405    Storage,
406    /// Custom/generic server
407    Custom,
408}
409
410impl ServerRole {
411    /// Get all available server roles
412    pub fn all() -> Vec<Self> {
413        vec![
414            ServerRole::K3sControl,
415            ServerRole::K3sWorker,
416            ServerRole::Postgres,
417            ServerRole::MySQL,
418            ServerRole::MongoDB,
419            ServerRole::Redis,
420            ServerRole::Memcached,
421            ServerRole::RabbitMQ,
422            ServerRole::Kafka,
423            ServerRole::Elasticsearch,
424            ServerRole::LoadBalancer,
425            ServerRole::Monitoring,
426            ServerRole::CIRunner,
427            ServerRole::Bastion,
428            ServerRole::Storage,
429            ServerRole::Custom,
430        ]
431    }
432
433    /// Get display name for the role
434    pub fn display_name(&self) -> &'static str {
435        match self {
436            ServerRole::K3sControl => "K3s Control Plane",
437            ServerRole::K3sWorker => "K3s Worker Node",
438            ServerRole::Postgres => "PostgreSQL Database",
439            ServerRole::MySQL => "MySQL/MariaDB Database",
440            ServerRole::MongoDB => "MongoDB Database",
441            ServerRole::Redis => "Redis Cache",
442            ServerRole::Memcached => "Memcached Cache",
443            ServerRole::RabbitMQ => "RabbitMQ Message Broker",
444            ServerRole::Kafka => "Kafka Message Broker",
445            ServerRole::Elasticsearch => "Elasticsearch Search Engine",
446            ServerRole::LoadBalancer => "Load Balancer (HAProxy/Nginx)",
447            ServerRole::Monitoring => "Monitoring (Prometheus/Grafana)",
448            ServerRole::CIRunner => "CI/CD Runner (GitLab Runner)",
449            ServerRole::Bastion => "Bastion/Jump Server",
450            ServerRole::Storage => "Storage (MinIO/NFS)",
451            ServerRole::Custom => "Custom/Generic Server",
452        }
453    }
454
455    /// Get description for the role
456    pub fn description(&self) -> &'static str {
457        match self {
458            ServerRole::K3sControl => "Control plane node for K3s cluster management",
459            ServerRole::K3sWorker => "Worker node for running application workloads",
460            ServerRole::Postgres => "PostgreSQL relational database server",
461            ServerRole::MySQL => "MySQL or MariaDB relational database server",
462            ServerRole::MongoDB => "MongoDB NoSQL document database",
463            ServerRole::Redis => "In-memory data store and cache",
464            ServerRole::Memcached => "Distributed memory caching system",
465            ServerRole::RabbitMQ => "Message broker for async communication",
466            ServerRole::Kafka => "Distributed streaming platform and message broker",
467            ServerRole::Elasticsearch => "Distributed search and analytics engine",
468            ServerRole::LoadBalancer => "Traffic distribution and load balancing",
469            ServerRole::Monitoring => "Metrics collection and visualization",
470            ServerRole::CIRunner => "Continuous integration and deployment runner",
471            ServerRole::Bastion => "Secure SSH gateway for infrastructure access",
472            ServerRole::Storage => "Object storage or network file system",
473            ServerRole::Custom => "Custom server with user-defined purpose",
474        }
475    }
476}
477
478/// Setup mode for infrastructure configuration
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum SetupMode {
481    /// Quick setup - single K3s server (development/testing)
482    Quick,
483    /// Standard setup - HA production with control plane, workers, and database
484    Standard,
485    /// Advanced setup - full control over server topology
486    Advanced,
487}
488
489impl SetupMode {
490    /// Get all available setup modes
491    pub fn all() -> Vec<Self> {
492        vec![SetupMode::Quick, SetupMode::Standard, SetupMode::Advanced]
493    }
494
495    /// Get display name for the mode
496    pub fn display_name(&self) -> &'static str {
497        match self {
498            SetupMode::Quick => "Quick - Single Server (Development)",
499            SetupMode::Standard => "Standard - Multi-Server HA (Production)",
500            SetupMode::Advanced => "Advanced - Custom Topology",
501        }
502    }
503
504    /// Get description for the mode
505    pub fn description(&self) -> &'static str {
506        match self {
507            SetupMode::Quick => {
508                "Single K3s server for development and testing. Fastest to set up, lowest cost."
509            }
510            SetupMode::Standard => {
511                "Production-ready HA setup with control plane, workers, and dedicated database server."
512            }
513            SetupMode::Advanced => {
514                "Full control over infrastructure topology. Configure multiple server groups with custom roles."
515            }
516        }
517    }
518}
519
520/// K3s cluster configuration
521#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
522pub struct K3sConfig {
523    /// K3s version (e.g., "v1.28.5+k3s1")
524    pub version: String,
525
526    /// Server groups that are part of the cluster
527    #[validate(length(min = 1))]
528    pub deploy_on: Vec<String>,
529
530    /// Server groups that are control plane nodes
531    #[validate(length(min = 1))]
532    pub control_plane_servers: Vec<String>,
533
534    /// Server groups that are worker nodes (can be empty for single-node clusters)
535    #[serde(default)]
536    pub worker_servers: Vec<String>,
537
538    /// Enable Traefik ingress controller
539    #[serde(default = "default_true")]
540    pub enable_traefik: bool,
541
542    /// Enable metrics server
543    #[serde(default)]
544    pub enable_metrics_server: bool,
545
546    /// Additional flags for k3s server
547    #[serde(default)]
548    pub server_flags: Vec<String>,
549
550    /// Additional flags for k3s agent
551    #[serde(default)]
552    pub agent_flags: Vec<String>,
553}
554
555fn default_true() -> bool {
556    true
557}
558
559/// PostgreSQL configuration
560#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
561pub struct PostgresConfig {
562    /// PostgreSQL version
563    pub version: String,
564
565    /// Database name
566    #[validate(length(min = 1, max = 63))]
567    pub database_name: String,
568
569    /// Deployment mode
570    pub deployment_mode: PostgresDeploymentMode,
571
572    /// Configuration for standalone mode
573    #[serde(skip_serializing_if = "Option::is_none")]
574    #[validate(nested)]
575    pub standalone: Option<PostgresStandaloneConfig>,
576
577    /// Configuration for in-cluster mode
578    #[serde(skip_serializing_if = "Option::is_none")]
579    #[validate(nested)]
580    pub in_cluster: Option<PostgresInClusterConfig>,
581}
582
583/// PostgreSQL deployment mode
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
585#[serde(rename_all = "kebab-case")]
586pub enum PostgresDeploymentMode {
587    /// Standalone server (recommended for production)
588    Standalone,
589    /// Deployed in Kubernetes cluster
590    InCluster,
591}
592
593/// PostgreSQL standalone deployment configuration
594#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
595pub struct PostgresStandaloneConfig {
596    /// Server group to deploy on
597    #[validate(length(min = 1))]
598    pub deploy_on: String,
599
600    /// Data directory (optional, uses default if not specified)
601    pub data_dir: Option<String>,
602
603    /// Max connections (optional, uses default if not specified)
604    pub max_connections: Option<u32>,
605
606    /// Shared buffers setting (e.g., "256MB")
607    pub shared_buffers: Option<String>,
608}
609
610/// PostgreSQL in-cluster deployment configuration
611#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
612pub struct PostgresInClusterConfig {
613    /// Namespace to deploy in
614    #[validate(length(min = 1))]
615    pub namespace: String,
616
617    /// Storage class for persistent volume
618    pub storage_class: String,
619
620    /// Storage size (e.g., "20Gi")
621    pub storage_size: String,
622
623    /// Use operator (e.g., Zalando postgres-operator)
624    #[serde(default)]
625    pub use_operator: bool,
626}
627
628/// DNS configuration
629#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
630pub struct DnsConfig {
631    /// DNS provider (e.g., "cloudflare", "route53")
632    pub provider: String,
633
634    /// Primary domain
635    #[validate(length(min = 1))]
636    pub domain: String,
637
638    /// DNS records
639    #[serde(default)]
640    pub records: Vec<DnsRecord>,
641}
642
643/// DNS record configuration
644#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
645pub struct DnsRecord {
646    /// Record type (A, AAAA, CNAME, MX, TXT, etc.)
647    #[serde(rename = "type")]
648    pub record_type: String,
649
650    /// Record name (e.g., "@", "www", "api")
651    #[validate(length(min = 1))]
652    pub name: String,
653
654    /// Target - can be:
655    /// - Server group name (will use public IP)
656    /// - IP address
657    /// - Domain name (for CNAME)
658    pub target: String,
659
660    /// TTL in seconds
661    #[serde(default = "default_ttl")]
662    pub ttl: u32,
663
664    /// Enable Cloudflare proxy (Cloudflare-specific)
665    #[serde(default)]
666    pub proxied: bool,
667}
668
669fn default_ttl() -> u32 {
670    300
671}
672
673/// GitLab configuration
674#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
675pub struct GitLabConfig {
676    /// GitLab instance URL
677    #[validate(url)]
678    pub url: String,
679
680    /// Project namespace/group
681    #[validate(length(min = 1))]
682    pub namespace: String,
683}
684
685/// Configuration validation errors
686#[derive(Debug, Error)]
687pub enum ConfigError {
688    /// Invalid TOML format
689    #[error("Invalid TOML format: {0}")]
690    InvalidToml(String),
691
692    /// TOML deserialization error
693    #[error("TOML deserialization error: {0}")]
694    TomlDe(#[from] toml::de::Error),
695
696    /// Validation failed
697    #[error("Configuration validation failed: {0}")]
698    ValidationFailed(#[from] validator::ValidationErrors),
699
700    /// IO error
701    #[error("IO error: {0}")]
702    Io(#[from] std::io::Error),
703
704    /// Missing required provider configuration
705    #[error("Missing required configuration for provider: {0}")]
706    MissingProviderConfig(String),
707}
708
709impl LmrcConfig {
710    /// Load configuration from a TOML file
711    pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
712        let content = std::fs::read_to_string(path)?;
713        Self::from_toml_str(&content)
714    }
715
716    /// Parse configuration from a TOML string
717    pub fn from_toml_str(content: &str) -> Result<Self, ConfigError> {
718        let config: Self = toml::from_str(content)?;
719        config.validate()?;
720        config.validate_provider_configs()?;
721        Ok(config)
722    }
723
724    /// Validate that provider-specific configs are present when providers are selected
725    fn validate_provider_configs(&self) -> Result<(), ConfigError> {
726        // Validate server provider config
727        if self.infrastructure.servers.is_empty() {
728            return Err(ConfigError::MissingProviderConfig(
729                "servers (at least one server group required)".to_string(),
730            ));
731        }
732
733        // Validate kubernetes provider config
734        if self.providers.kubernetes == "k3s" && self.infrastructure.k3s.is_none() {
735            return Err(ConfigError::MissingProviderConfig("k3s".to_string()));
736        }
737
738        // Validate database provider config
739        if self.providers.database == "postgres" && self.infrastructure.postgres.is_none() {
740            return Err(ConfigError::MissingProviderConfig("postgres".to_string()));
741        }
742
743        // Validate DNS provider config
744        if self.infrastructure.dns.is_none() {
745            return Err(ConfigError::MissingProviderConfig("dns".to_string()));
746        }
747
748        // Validate Git provider config
749        if self.providers.git == "gitlab" && self.infrastructure.gitlab.is_none() {
750            return Err(ConfigError::MissingProviderConfig("gitlab".to_string()));
751        }
752
753        // Validate PostgreSQL deployment mode consistency
754        if let Some(postgres) = &self.infrastructure.postgres {
755            match postgres.deployment_mode {
756                PostgresDeploymentMode::Standalone => {
757                    if postgres.standalone.is_none() {
758                        return Err(ConfigError::InvalidToml(
759                            "PostgreSQL standalone mode requires 'standalone' configuration"
760                                .to_string(),
761                        ));
762                    }
763                    // Validate that deploy_on references a valid server group
764                    if let Some(standalone) = &postgres.standalone
765                        && !self
766                            .infrastructure
767                            .servers
768                            .iter()
769                            .any(|s| s.name == standalone.deploy_on)
770                    {
771                        return Err(ConfigError::InvalidToml(format!(
772                            "PostgreSQL standalone deploy_on '{}' does not reference a valid server group",
773                            standalone.deploy_on
774                        )));
775                    }
776                }
777                PostgresDeploymentMode::InCluster => {
778                    if postgres.in_cluster.is_none() {
779                        return Err(ConfigError::InvalidToml(
780                            "PostgreSQL in-cluster mode requires 'in_cluster' configuration"
781                                .to_string(),
782                        ));
783                    }
784                }
785            }
786        }
787
788        // Validate K3s server group references
789        if let Some(k3s) = &self.infrastructure.k3s {
790            for server_group in &k3s.deploy_on {
791                if !self
792                    .infrastructure
793                    .servers
794                    .iter()
795                    .any(|s| s.name == *server_group)
796                {
797                    return Err(ConfigError::InvalidToml(format!(
798                        "K3s deploy_on '{}' does not reference a valid server group",
799                        server_group
800                    )));
801                }
802            }
803            for server_group in &k3s.control_plane_servers {
804                if !self
805                    .infrastructure
806                    .servers
807                    .iter()
808                    .any(|s| s.name == *server_group)
809                {
810                    return Err(ConfigError::InvalidToml(format!(
811                        "K3s control_plane_servers '{}' does not reference a valid server group",
812                        server_group
813                    )));
814                }
815            }
816            for server_group in &k3s.worker_servers {
817                if !self
818                    .infrastructure
819                    .servers
820                    .iter()
821                    .any(|s| s.name == *server_group)
822                {
823                    return Err(ConfigError::InvalidToml(format!(
824                        "K3s worker_servers '{}' does not reference a valid server group",
825                        server_group
826                    )));
827                }
828            }
829        }
830
831        Ok(())
832    }
833
834    /// Generate a default/template configuration
835    pub fn template() -> Self {
836        let mut labels = HashMap::new();
837        labels.insert("environment".to_string(), "production".to_string());
838
839        Self {
840            project: ProjectConfig {
841                name: "my-project".to_string(),
842                description: "My LMRC Stack project".to_string(),
843            },
844            providers: ProviderConfig {
845                server: "hetzner".to_string(),
846                kubernetes: "k3s".to_string(),
847                database: "postgres".to_string(),
848                dns: "cloudflare".to_string(),
849                git: "gitlab".to_string(),
850            },
851            apps: AppsConfig {
852                applications: vec![ApplicationEntry {
853                    name: "api".to_string(),
854                    app_type: Some(AppType::Api),
855                    docker: Some(DockerConfig {
856                        dockerfile: "apps/api/Dockerfile".to_string(),
857                        context: "apps/api".to_string(),
858                        tags: vec!["latest".to_string()],
859                    }),
860                    deployment: Some(DeploymentConfig {
861                        replicas: 2,
862                        port: 8080,
863                        cpu_request: Some("100m".to_string()),
864                        memory_request: Some("128Mi".to_string()),
865                        cpu_limit: Some("1".to_string()),
866                        memory_limit: Some("512Mi".to_string()),
867                        env: vec![],
868                    }),
869                }],
870            },
871            infrastructure: InfrastructureConfig {
872                provider: "hetzner".to_string(),
873                network: Some(NetworkConfig {
874                    enable_private_network: true,
875                    private_network: Some("10.0.0.0/16".to_string()),
876                    firewall_rules: vec![],
877                }),
878                servers: vec![
879                    ServerGroup {
880                        name: "k3s-control".to_string(),
881                        role: ServerRole::K3sControl,
882                        server_type: "cpx11".to_string(),
883                        location: "nbg1".to_string(),
884                        count: 1,
885                        labels: labels.clone(),
886                        ssh_keys: vec![],
887                        image: None,
888                    },
889                    ServerGroup {
890                        name: "k3s-workers".to_string(),
891                        role: ServerRole::K3sWorker,
892                        server_type: "cx21".to_string(),
893                        location: "nbg1".to_string(),
894                        count: 2,
895                        labels: labels.clone(),
896                        ssh_keys: vec![],
897                        image: None,
898                    },
899                    ServerGroup {
900                        name: "postgres-server".to_string(),
901                        role: ServerRole::Postgres,
902                        server_type: "cx31".to_string(),
903                        location: "nbg1".to_string(),
904                        count: 1,
905                        labels,
906                        ssh_keys: vec![],
907                        image: None,
908                    },
909                ],
910                k3s: Some(K3sConfig {
911                    version: "v1.28.5+k3s1".to_string(),
912                    deploy_on: vec!["k3s-control".to_string(), "k3s-workers".to_string()],
913                    control_plane_servers: vec!["k3s-control".to_string()],
914                    worker_servers: vec!["k3s-workers".to_string()],
915                    enable_traefik: true,
916                    enable_metrics_server: false,
917                    server_flags: vec![],
918                    agent_flags: vec![],
919                }),
920                postgres: Some(PostgresConfig {
921                    version: "16".to_string(),
922                    database_name: "myapp".to_string(),
923                    deployment_mode: PostgresDeploymentMode::Standalone,
924                    standalone: Some(PostgresStandaloneConfig {
925                        deploy_on: "postgres-server".to_string(),
926                        data_dir: None,
927                        max_connections: None,
928                        shared_buffers: None,
929                    }),
930                    in_cluster: None,
931                }),
932                dns: Some(DnsConfig {
933                    provider: "cloudflare".to_string(),
934                    domain: "example.com".to_string(),
935                    records: vec![
936                        DnsRecord {
937                            record_type: "A".to_string(),
938                            name: "@".to_string(),
939                            target: "k3s-control".to_string(),
940                            ttl: 300,
941                            proxied: true,
942                        },
943                        DnsRecord {
944                            record_type: "A".to_string(),
945                            name: "api".to_string(),
946                            target: "k3s-control".to_string(),
947                            ttl: 300,
948                            proxied: true,
949                        },
950                    ],
951                }),
952                gitlab: Some(GitLabConfig {
953                    url: "https://gitlab.com".to_string(),
954                    namespace: "mygroup".to_string(),
955                }),
956            },
957        }
958    }
959
960    /// Convert config to TOML string
961    pub fn to_toml_string(&self) -> Result<String, ConfigError> {
962        toml::to_string_pretty(self).map_err(|e| ConfigError::InvalidToml(e.to_string()))
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn test_template_config_is_valid() {
972        let config = LmrcConfig::template();
973        assert!(config.validate().is_ok());
974        assert!(config.validate_provider_configs().is_ok());
975    }
976
977    #[test]
978    fn test_config_to_toml_and_back() {
979        let config = LmrcConfig::template();
980        let toml_str = config.to_toml_string().unwrap();
981        let parsed = LmrcConfig::from_toml_str(&toml_str).unwrap();
982        assert_eq!(config.project.name, parsed.project.name);
983    }
984
985    #[test]
986    fn test_missing_provider_config() {
987        let mut config = LmrcConfig::template();
988        config.infrastructure.servers = vec![];
989        assert!(config.validate_provider_configs().is_err());
990    }
991
992    #[test]
993    fn test_invalid_server_group_reference() {
994        let mut config = LmrcConfig::template();
995        if let Some(ref mut postgres) = config.infrastructure.postgres {
996            if let Some(ref mut standalone) = postgres.standalone {
997                standalone.deploy_on = "nonexistent-server".to_string();
998            }
999        }
1000        assert!(config.validate_provider_configs().is_err());
1001    }
1002}