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