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