1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use thiserror::Error;
9use validator::Validate;
10
11pub mod providers;
12
13#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
15pub struct LmrcConfig {
16 #[validate(nested)]
18 pub project: ProjectConfig,
19
20 #[validate(nested)]
22 pub providers: ProviderConfig,
23
24 #[validate(nested)]
26 pub apps: AppsConfig,
27
28 #[validate(nested)]
30 pub infrastructure: InfrastructureConfig,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
35pub struct ProjectConfig {
36 #[validate(length(min = 1, max = 100))]
38 pub name: String,
39
40 #[validate(length(min = 1, max = 500))]
42 pub description: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
47pub struct ProviderConfig {
48 pub server: String,
50
51 pub kubernetes: String,
53
54 pub database: String,
56
57 pub dns: String,
59
60 pub git: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
66pub struct AppsConfig {
67 #[validate(length(min = 1))]
69 pub applications: Vec<ApplicationEntry>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
74pub struct ApplicationEntry {
75 #[validate(length(min = 1, max = 50))]
77 pub name: String,
78
79 #[validate(length(min = 1, max = 20))]
81 pub app_type: String,
82
83 #[serde(default)]
85 #[validate(nested)]
86 pub docker: Option<DockerConfig>,
87
88 #[serde(default)]
90 #[validate(nested)]
91 pub deployment: Option<DeploymentConfig>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
96pub struct DockerConfig {
97 #[serde(default = "default_dockerfile")]
99 pub dockerfile: String,
100
101 #[serde(default = "default_context")]
103 pub context: String,
104
105 #[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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
134pub struct DeploymentConfig {
135 #[serde(default = "default_replicas")]
137 #[validate(range(min = 1, max = 100))]
138 pub replicas: u32,
139
140 #[serde(default = "default_port")]
142 #[validate(range(min = 1, max = 65535))]
143 pub port: u16,
144
145 #[serde(default)]
147 pub cpu_request: Option<String>,
148
149 #[serde(default)]
151 pub memory_request: Option<String>,
152
153 #[serde(default)]
155 pub cpu_limit: Option<String>,
156
157 #[serde(default)]
159 pub memory_limit: Option<String>,
160
161 #[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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
190pub struct EnvVar {
191 #[validate(length(min = 1))]
193 pub name: String,
194
195 #[serde(default)]
197 pub value: Option<String>,
198
199 #[serde(default)]
201 pub secret_ref: Option<SecretRef>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
206pub struct SecretRef {
207 #[validate(length(min = 1))]
209 pub name: String,
210
211 #[validate(length(min = 1))]
213 pub key: String,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
218pub struct InfrastructureConfig {
219 #[serde(default = "default_provider")]
221 pub provider: String,
222
223 #[serde(default)]
225 #[validate(nested)]
226 pub network: Option<NetworkConfig>,
227
228 #[serde(default)]
230 pub servers: Vec<ServerGroup>,
231
232 #[validate(nested)]
234 pub k3s: Option<K3sConfig>,
235
236 #[validate(nested)]
238 pub postgres: Option<PostgresConfig>,
239
240 #[validate(nested)]
242 pub dns: Option<DnsConfig>,
243
244 #[validate(nested)]
246 pub gitlab: Option<GitLabConfig>,
247}
248
249fn default_provider() -> String {
250 "hetzner".to_string()
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
255pub struct NetworkConfig {
256 #[serde(default)]
258 pub enable_private_network: bool,
259
260 pub private_network: Option<String>,
262
263 #[serde(default)]
265 pub firewall_rules: Vec<FirewallRule>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
270pub struct FirewallRule {
271 #[validate(length(min = 1))]
273 pub name: String,
274
275 pub direction: String,
277
278 pub protocol: String,
280
281 pub port: Option<String>,
283
284 pub source: String,
286
287 pub destination: String,
289
290 pub action: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
296pub struct ServerGroup {
297 #[validate(length(min = 1, max = 50))]
299 pub name: String,
300
301 pub role: ServerRole,
303
304 pub server_type: String,
306
307 pub location: String,
309
310 #[validate(range(min = 1, max = 50))]
312 pub count: u32,
313
314 #[serde(default)]
316 pub labels: HashMap<String, String>,
317
318 #[serde(default)]
320 pub ssh_keys: Vec<String>,
321
322 pub image: Option<String>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
328#[serde(rename_all = "kebab-case")]
329pub enum ServerRole {
330 K3sControl,
332 K3sWorker,
334 Postgres,
336 MySQL,
338 MongoDB,
340 Redis,
342 Memcached,
344 RabbitMQ,
346 Kafka,
348 Elasticsearch,
350 LoadBalancer,
352 Monitoring,
354 CIRunner,
356 Bastion,
358 Storage,
360 Custom,
362}
363
364impl ServerRole {
365 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub enum SetupMode {
435 Quick,
437 Standard,
439 Advanced,
441}
442
443impl SetupMode {
444 pub fn all() -> Vec<Self> {
446 vec![SetupMode::Quick, SetupMode::Standard, SetupMode::Advanced]
447 }
448
449 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 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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
476pub struct K3sConfig {
477 pub version: String,
479
480 #[validate(length(min = 1))]
482 pub deploy_on: Vec<String>,
483
484 #[validate(length(min = 1))]
486 pub control_plane_servers: Vec<String>,
487
488 #[serde(default)]
490 pub worker_servers: Vec<String>,
491
492 #[serde(default = "default_true")]
494 pub enable_traefik: bool,
495
496 #[serde(default)]
498 pub enable_metrics_server: bool,
499
500 #[serde(default)]
502 pub server_flags: Vec<String>,
503
504 #[serde(default)]
506 pub agent_flags: Vec<String>,
507}
508
509fn default_true() -> bool {
510 true
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
515pub struct PostgresConfig {
516 pub version: String,
518
519 #[validate(length(min = 1, max = 63))]
521 pub database_name: String,
522
523 pub deployment_mode: PostgresDeploymentMode,
525
526 #[serde(skip_serializing_if = "Option::is_none")]
528 #[validate(nested)]
529 pub standalone: Option<PostgresStandaloneConfig>,
530
531 #[serde(skip_serializing_if = "Option::is_none")]
533 #[validate(nested)]
534 pub in_cluster: Option<PostgresInClusterConfig>,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
539#[serde(rename_all = "kebab-case")]
540pub enum PostgresDeploymentMode {
541 Standalone,
543 InCluster,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
549pub struct PostgresStandaloneConfig {
550 #[validate(length(min = 1))]
552 pub deploy_on: String,
553
554 pub data_dir: Option<String>,
556
557 pub max_connections: Option<u32>,
559
560 pub shared_buffers: Option<String>,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
566pub struct PostgresInClusterConfig {
567 #[validate(length(min = 1))]
569 pub namespace: String,
570
571 pub storage_class: String,
573
574 pub storage_size: String,
576
577 #[serde(default)]
579 pub use_operator: bool,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
584pub struct DnsConfig {
585 pub provider: String,
587
588 #[validate(length(min = 1))]
590 pub domain: String,
591
592 #[serde(default)]
594 pub records: Vec<DnsRecord>,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
599pub struct DnsRecord {
600 #[serde(rename = "type")]
602 pub record_type: String,
603
604 #[validate(length(min = 1))]
606 pub name: String,
607
608 pub target: String,
613
614 #[serde(default = "default_ttl")]
616 pub ttl: u32,
617
618 #[serde(default)]
620 pub proxied: bool,
621}
622
623fn default_ttl() -> u32 {
624 300
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
629pub struct GitLabConfig {
630 #[validate(url)]
632 pub url: String,
633
634 #[validate(length(min = 1))]
636 pub namespace: String,
637}
638
639#[derive(Debug, Error)]
641pub enum ConfigError {
642 #[error("Invalid TOML format: {0}")]
644 InvalidToml(String),
645
646 #[error("TOML deserialization error: {0}")]
648 TomlDe(#[from] toml::de::Error),
649
650 #[error("Configuration validation failed: {0}")]
652 ValidationFailed(#[from] validator::ValidationErrors),
653
654 #[error("IO error: {0}")]
656 Io(#[from] std::io::Error),
657
658 #[error("Missing required configuration for provider: {0}")]
660 MissingProviderConfig(String),
661}
662
663impl LmrcConfig {
664 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 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 fn validate_provider_configs(&self) -> Result<(), ConfigError> {
680 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 if self.providers.kubernetes == "k3s" && self.infrastructure.k3s.is_none() {
689 return Err(ConfigError::MissingProviderConfig("k3s".to_string()));
690 }
691
692 if self.providers.database == "postgres" && self.infrastructure.postgres.is_none() {
694 return Err(ConfigError::MissingProviderConfig("postgres".to_string()));
695 }
696
697 if self.infrastructure.dns.is_none() {
699 return Err(ConfigError::MissingProviderConfig("dns".to_string()));
700 }
701
702 if self.providers.git == "gitlab" && self.infrastructure.gitlab.is_none() {
704 return Err(ConfigError::MissingProviderConfig("gitlab".to_string()));
705 }
706
707 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 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 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 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 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}