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 #[serde(default = "default_queue_provider")]
59 pub queue: String,
60
61 pub dns: String,
63
64 pub git: String,
66}
67
68fn default_queue_provider() -> String {
69 "rabbitmq".to_string()
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
74pub struct AppsConfig {
75 #[validate(length(min = 1))]
77 pub applications: Vec<ApplicationEntry>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "kebab-case")]
83pub enum AppType {
84 Gateway,
86 Api,
88 Migrator,
90 Basic,
92}
93
94impl AppType {
95 pub fn all() -> Vec<Self> {
97 vec![
98 AppType::Gateway,
99 AppType::Api,
100 AppType::Migrator,
101 AppType::Basic,
102 ]
103 }
104
105 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 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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
128pub struct ApplicationEntry {
129 #[validate(length(min = 1, max = 50))]
131 pub name: String,
132
133 #[serde(default)]
135 pub app_type: Option<AppType>,
136
137 #[serde(default)]
139 #[validate(nested)]
140 pub docker: Option<DockerConfig>,
141
142 #[serde(default)]
144 #[validate(nested)]
145 pub deployment: Option<DeploymentConfig>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
150pub struct DockerConfig {
151 #[serde(default = "default_dockerfile")]
153 pub dockerfile: String,
154
155 #[serde(default = "default_context")]
157 pub context: String,
158
159 #[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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
188pub struct DeploymentConfig {
189 #[serde(default = "default_replicas")]
191 #[validate(range(min = 1, max = 100))]
192 pub replicas: u32,
193
194 #[serde(default = "default_port")]
196 #[validate(range(min = 1, max = 65535))]
197 pub port: u16,
198
199 #[serde(default)]
201 pub cpu_request: Option<String>,
202
203 #[serde(default)]
205 pub memory_request: Option<String>,
206
207 #[serde(default)]
209 pub cpu_limit: Option<String>,
210
211 #[serde(default)]
213 pub memory_limit: Option<String>,
214
215 #[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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
244pub struct EnvVar {
245 #[validate(length(min = 1))]
247 pub name: String,
248
249 #[serde(default)]
251 pub value: Option<String>,
252
253 #[serde(default)]
255 pub secret_ref: Option<SecretRef>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
260pub struct SecretRef {
261 #[validate(length(min = 1))]
263 pub name: String,
264
265 #[validate(length(min = 1))]
267 pub key: String,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
272pub struct InfrastructureConfig {
273 #[serde(default = "default_provider")]
275 pub provider: String,
276
277 #[serde(default)]
279 #[validate(nested)]
280 pub network: Option<NetworkConfig>,
281
282 #[serde(default)]
284 pub servers: Vec<ServerGroup>,
285
286 #[validate(nested)]
288 pub k3s: Option<K3sConfig>,
289
290 #[validate(nested)]
292 pub postgres: Option<PostgresConfig>,
293
294 #[validate(nested)]
296 pub rabbitmq: Option<RabbitMqConfig>,
297
298 #[validate(nested)]
300 pub vault: Option<VaultConfig>,
301
302 #[validate(nested)]
304 pub dns: Option<DnsConfig>,
305
306 #[validate(nested)]
308 pub gitlab: Option<GitLabConfig>,
309
310 #[serde(default)]
312 #[validate(nested)]
313 pub load_balancer: Option<LoadBalancerConfig>,
314}
315
316fn default_provider() -> String {
317 "hetzner".to_string()
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
322pub struct NetworkConfig {
323 #[serde(default)]
325 pub enable_private_network: bool,
326
327 pub private_network: Option<String>,
329
330 #[serde(default)]
332 pub firewall_rules: Vec<FirewallRule>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
337pub struct FirewallRule {
338 #[validate(length(min = 1))]
340 pub name: String,
341
342 pub direction: String,
344
345 pub protocol: String,
347
348 pub port: Option<String>,
350
351 pub source: String,
353
354 pub destination: String,
356
357 pub action: String,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
363pub struct ServerGroup {
364 #[validate(length(min = 1, max = 50))]
366 pub name: String,
367
368 pub role: ServerRole,
370
371 pub server_type: String,
373
374 pub location: String,
376
377 #[validate(range(min = 1, max = 50))]
379 pub count: u32,
380
381 #[serde(default)]
383 pub labels: HashMap<String, String>,
384
385 #[serde(default)]
387 pub ssh_keys: Vec<String>,
388
389 pub image: Option<String>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
395#[serde(rename_all = "kebab-case")]
396pub enum ServerRole {
397 K3sControl,
399 K3sWorker,
401 Postgres,
403 MySQL,
405 MongoDB,
407 Redis,
409 Memcached,
411 RabbitMQ,
413 Kafka,
415 Elasticsearch,
417 LoadBalancer,
419 Monitoring,
421 CIRunner,
423 Bastion,
425 Storage,
427 Custom,
429}
430
431impl ServerRole {
432 pub fn all() -> Vec<Self> {
434 vec![
435 ServerRole::K3sControl,
436 ServerRole::K3sWorker,
437 ServerRole::Postgres,
438 ServerRole::MySQL,
439 ServerRole::MongoDB,
440 ServerRole::Redis,
441 ServerRole::Memcached,
442 ServerRole::RabbitMQ,
443 ServerRole::Kafka,
444 ServerRole::Elasticsearch,
445 ServerRole::LoadBalancer,
446 ServerRole::Monitoring,
447 ServerRole::CIRunner,
448 ServerRole::Bastion,
449 ServerRole::Storage,
450 ServerRole::Custom,
451 ]
452 }
453
454 pub fn display_name(&self) -> &'static str {
456 match self {
457 ServerRole::K3sControl => "K3s Control Plane",
458 ServerRole::K3sWorker => "K3s Worker Node",
459 ServerRole::Postgres => "PostgreSQL Database",
460 ServerRole::MySQL => "MySQL/MariaDB Database",
461 ServerRole::MongoDB => "MongoDB Database",
462 ServerRole::Redis => "Redis Cache",
463 ServerRole::Memcached => "Memcached Cache",
464 ServerRole::RabbitMQ => "RabbitMQ Message Broker",
465 ServerRole::Kafka => "Kafka Message Broker",
466 ServerRole::Elasticsearch => "Elasticsearch Search Engine",
467 ServerRole::LoadBalancer => "Load Balancer (HAProxy/Nginx)",
468 ServerRole::Monitoring => "Monitoring (Prometheus/Grafana)",
469 ServerRole::CIRunner => "CI/CD Runner (GitLab Runner)",
470 ServerRole::Bastion => "Bastion/Jump Server",
471 ServerRole::Storage => "Storage (MinIO/NFS)",
472 ServerRole::Custom => "Custom/Generic Server",
473 }
474 }
475
476 pub fn description(&self) -> &'static str {
478 match self {
479 ServerRole::K3sControl => "Control plane node for K3s cluster management",
480 ServerRole::K3sWorker => "Worker node for running application workloads",
481 ServerRole::Postgres => "PostgreSQL relational database server",
482 ServerRole::MySQL => "MySQL or MariaDB relational database server",
483 ServerRole::MongoDB => "MongoDB NoSQL document database",
484 ServerRole::Redis => "In-memory data store and cache",
485 ServerRole::Memcached => "Distributed memory caching system",
486 ServerRole::RabbitMQ => "Message broker for async communication",
487 ServerRole::Kafka => "Distributed streaming platform and message broker",
488 ServerRole::Elasticsearch => "Distributed search and analytics engine",
489 ServerRole::LoadBalancer => "Traffic distribution and load balancing",
490 ServerRole::Monitoring => "Metrics collection and visualization",
491 ServerRole::CIRunner => "Continuous integration and deployment runner",
492 ServerRole::Bastion => "Secure SSH gateway for infrastructure access",
493 ServerRole::Storage => "Object storage or network file system",
494 ServerRole::Custom => "Custom server with user-defined purpose",
495 }
496 }
497}
498
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501pub enum SetupMode {
502 Quick,
504 Standard,
506 Advanced,
508}
509
510impl SetupMode {
511 pub fn all() -> Vec<Self> {
513 vec![SetupMode::Quick, SetupMode::Standard, SetupMode::Advanced]
514 }
515
516 pub fn display_name(&self) -> &'static str {
518 match self {
519 SetupMode::Quick => "Quick - Single Server (Development)",
520 SetupMode::Standard => "Standard - Multi-Server HA (Production)",
521 SetupMode::Advanced => "Advanced - Custom Topology",
522 }
523 }
524
525 pub fn description(&self) -> &'static str {
527 match self {
528 SetupMode::Quick => {
529 "Single K3s server for development and testing. Fastest to set up, lowest cost."
530 }
531 SetupMode::Standard => {
532 "Production-ready HA setup with control plane, workers, and dedicated database server."
533 }
534 SetupMode::Advanced => {
535 "Full control over infrastructure topology. Configure multiple server groups with custom roles."
536 }
537 }
538 }
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
543pub struct K3sConfig {
544 pub version: String,
546
547 #[validate(length(min = 1))]
549 pub deploy_on: Vec<String>,
550
551 #[validate(length(min = 1))]
553 pub control_plane_servers: Vec<String>,
554
555 #[serde(default)]
557 pub worker_servers: Vec<String>,
558
559 #[serde(default = "default_true")]
561 pub enable_traefik: bool,
562
563 #[serde(default)]
565 pub enable_metrics_server: bool,
566
567 #[serde(default)]
569 pub server_flags: Vec<String>,
570
571 #[serde(default)]
573 pub agent_flags: Vec<String>,
574}
575
576fn default_true() -> bool {
577 true
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
582pub struct PostgresConfig {
583 pub version: String,
585
586 #[validate(length(min = 1, max = 63))]
588 pub database_name: String,
589
590 pub deployment_mode: PostgresDeploymentMode,
592
593 #[serde(skip_serializing_if = "Option::is_none")]
595 #[validate(nested)]
596 pub standalone: Option<PostgresStandaloneConfig>,
597
598 #[serde(skip_serializing_if = "Option::is_none")]
600 #[validate(nested)]
601 pub in_cluster: Option<PostgresInClusterConfig>,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
606#[serde(rename_all = "kebab-case")]
607pub enum PostgresDeploymentMode {
608 Standalone,
610 InCluster,
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
616pub struct PostgresStandaloneConfig {
617 #[validate(length(min = 1))]
619 pub deploy_on: String,
620
621 pub data_dir: Option<String>,
623
624 pub max_connections: Option<u32>,
626
627 pub shared_buffers: Option<String>,
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
633pub struct PostgresInClusterConfig {
634 #[validate(length(min = 1))]
636 pub namespace: String,
637
638 pub storage_class: String,
640
641 pub storage_size: String,
643
644 #[serde(default)]
646 pub use_operator: bool,
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
651pub struct RabbitMqConfig {
652 pub version: String,
654
655 pub deployment_mode: RabbitMqDeploymentMode,
657
658 #[serde(skip_serializing_if = "Option::is_none")]
660 #[validate(nested)]
661 pub standalone: Option<RabbitMqStandaloneConfig>,
662
663 #[serde(skip_serializing_if = "Option::is_none")]
665 #[validate(nested)]
666 pub in_cluster: Option<RabbitMqInClusterConfig>,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
671#[serde(rename_all = "kebab-case")]
672pub enum RabbitMqDeploymentMode {
673 Standalone,
675 InCluster,
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
681pub struct RabbitMqStandaloneConfig {
682 #[validate(length(min = 1))]
684 pub deploy_on: String,
685
686 #[serde(default = "default_true")]
688 pub enable_management: bool,
689
690 #[serde(default = "default_management_port")]
692 pub management_port: u16,
693
694 #[serde(default = "default_amqp_port")]
696 pub amqp_port: u16,
697}
698
699fn default_management_port() -> u16 {
700 15672
701}
702
703fn default_amqp_port() -> u16 {
704 5672
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
709pub struct RabbitMqInClusterConfig {
710 #[validate(length(min = 1))]
712 pub namespace: String,
713
714 pub storage_class: String,
716
717 pub storage_size: String,
719
720 #[serde(default = "default_rabbitmq_replicas")]
722 #[validate(range(min = 1, max = 10))]
723 pub replicas: u32,
724
725 #[serde(default)]
727 pub use_operator: bool,
728}
729
730fn default_rabbitmq_replicas() -> u32 {
731 1
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
736pub struct VaultConfig {
737 pub version: String,
739
740 pub deployment_mode: VaultDeploymentMode,
742
743 #[serde(skip_serializing_if = "Option::is_none")]
745 #[validate(nested)]
746 pub standalone: Option<VaultStandaloneConfig>,
747
748 #[serde(skip_serializing_if = "Option::is_none")]
750 #[validate(nested)]
751 pub in_cluster: Option<VaultInClusterConfig>,
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
756#[serde(rename_all = "kebab-case")]
757pub enum VaultDeploymentMode {
758 Standalone,
760 InCluster,
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
766pub struct VaultStandaloneConfig {
767 #[validate(length(min = 1))]
769 pub deploy_on: String,
770
771 #[serde(default = "default_true")]
773 pub enable_ui: bool,
774
775 #[serde(default = "default_vault_port")]
777 pub api_port: u16,
778}
779
780fn default_vault_port() -> u16 {
781 8200
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
786pub struct VaultInClusterConfig {
787 #[validate(length(min = 1))]
789 pub namespace: String,
790
791 #[serde(default = "default_vault_replicas")]
793 #[validate(range(min = 1, max = 5))]
794 pub replicas: u32,
795
796 #[serde(default = "default_vault_storage")]
798 pub storage_size: String,
799
800 pub storage_class: Option<String>,
802
803 #[serde(default = "default_true")]
805 pub enable_ui: bool,
806
807 #[serde(default = "default_service_type")]
809 pub service_type: String,
810
811 pub node_port: Option<u16>,
813
814 pub ingress_host: Option<String>,
816
817 #[serde(default = "default_true")]
819 pub enable_tls: bool,
820}
821
822fn default_vault_replicas() -> u32 {
823 1
824}
825
826fn default_vault_storage() -> String {
827 "10Gi".to_string()
828}
829
830fn default_service_type() -> String {
831 "ClusterIP".to_string()
832}
833
834#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
836pub struct DnsConfig {
837 pub provider: String,
839
840 #[validate(length(min = 1))]
842 pub domain: String,
843
844 #[serde(default)]
846 pub records: Vec<DnsRecord>,
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
851pub struct DnsRecord {
852 #[serde(rename = "type")]
854 pub record_type: String,
855
856 #[validate(length(min = 1))]
858 pub name: String,
859
860 pub target: String,
865
866 #[serde(default = "default_ttl")]
868 pub ttl: u32,
869
870 #[serde(default)]
872 pub proxied: bool,
873}
874
875fn default_ttl() -> u32 {
876 300
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
881pub struct GitLabConfig {
882 #[validate(url)]
884 pub url: String,
885
886 #[validate(length(min = 1))]
888 pub namespace: String,
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
893pub struct LoadBalancerConfig {
894 #[validate(length(min = 1, max = 50))]
896 pub name: String,
897
898 #[serde(rename = "type")]
900 pub lb_type: String,
901
902 pub location: String,
904
905 pub algorithm: String,
907
908 #[validate(length(min = 1))]
910 pub targets: Vec<LoadBalancerTarget>,
911
912 #[validate(length(min = 1))]
914 pub services: Vec<LoadBalancerService>,
915}
916
917#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
919pub struct LoadBalancerTarget {
920 #[serde(rename = "type")]
922 pub target_type: String,
923
924 #[serde(default)]
926 pub use_private_ip: bool,
927
928 #[validate(length(min = 1))]
930 pub servers: Vec<String>,
931}
932
933#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
935pub struct LoadBalancerService {
936 pub protocol: String,
938
939 #[validate(range(min = 1, max = 65535))]
941 pub listen_port: u16,
942
943 #[validate(range(min = 1, max = 65535))]
945 pub destination_port: u16,
946
947 pub health_check_protocol: String,
949
950 #[validate(range(min = 1, max = 65535))]
952 pub health_check_port: u16,
953
954 #[serde(default = "default_health_check_path")]
956 pub health_check_path: String,
957
958 #[serde(default = "default_health_check_interval")]
960 #[validate(range(min = 1, max = 3600))]
961 pub health_check_interval: u64,
962
963 #[serde(default = "default_health_check_timeout")]
965 #[validate(range(min = 1, max = 300))]
966 pub health_check_timeout: u64,
967
968 #[serde(default = "default_health_check_retries")]
970 #[validate(range(min = 1, max = 10))]
971 pub health_check_retries: u64,
972
973 #[serde(default)]
975 pub http_redirect_http_to_https: bool,
976}
977
978fn default_health_check_path() -> String {
979 "/".to_string()
980}
981
982fn default_health_check_interval() -> u64 {
983 15
984}
985
986fn default_health_check_timeout() -> u64 {
987 10
988}
989
990fn default_health_check_retries() -> u64 {
991 3
992}
993
994#[derive(Debug, Error)]
996pub enum ConfigError {
997 #[error("Invalid TOML format: {0}")]
999 InvalidToml(String),
1000
1001 #[error("TOML deserialization error: {0}")]
1003 TomlDe(#[from] toml::de::Error),
1004
1005 #[error("Configuration validation failed: {0}")]
1007 ValidationFailed(#[from] validator::ValidationErrors),
1008
1009 #[error("IO error: {0}")]
1011 Io(#[from] std::io::Error),
1012
1013 #[error("Missing required configuration for provider: {0}")]
1015 MissingProviderConfig(String),
1016
1017 #[error("Architecture violation: {0}")]
1019 ArchitectureViolation(String),
1020}
1021
1022impl LmrcConfig {
1023 pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
1025 let content = std::fs::read_to_string(path)?;
1026 Self::from_toml_str(&content)
1027 }
1028
1029 pub fn from_toml_str(content: &str) -> Result<Self, ConfigError> {
1031 let config: Self = toml::from_str(content)?;
1032 config.validate()?;
1033 config.validate_provider_configs()?;
1034 Ok(config)
1035 }
1036
1037 fn validate_provider_configs(&self) -> Result<(), ConfigError> {
1039 if self.infrastructure.servers.is_empty() {
1041 return Err(ConfigError::MissingProviderConfig(
1042 "servers (at least one server group required)".to_string(),
1043 ));
1044 }
1045
1046 if self.providers.kubernetes == "k3s" && self.infrastructure.k3s.is_none() {
1048 return Err(ConfigError::MissingProviderConfig("k3s".to_string()));
1049 }
1050
1051 if self.providers.database == "postgres" && self.infrastructure.postgres.is_none() {
1053 return Err(ConfigError::MissingProviderConfig("postgres".to_string()));
1054 }
1055
1056 if self.infrastructure.dns.is_none() {
1058 return Err(ConfigError::MissingProviderConfig("dns".to_string()));
1059 }
1060
1061 if self.providers.git == "gitlab" && self.infrastructure.gitlab.is_none() {
1063 return Err(ConfigError::MissingProviderConfig("gitlab".to_string()));
1064 }
1065
1066 if let Some(postgres) = &self.infrastructure.postgres {
1068 match postgres.deployment_mode {
1069 PostgresDeploymentMode::Standalone => {
1070 if postgres.standalone.is_none() {
1071 return Err(ConfigError::InvalidToml(
1072 "PostgreSQL standalone mode requires 'standalone' configuration"
1073 .to_string(),
1074 ));
1075 }
1076 if let Some(standalone) = &postgres.standalone
1078 && !self
1079 .infrastructure
1080 .servers
1081 .iter()
1082 .any(|s| s.name == standalone.deploy_on)
1083 {
1084 return Err(ConfigError::InvalidToml(format!(
1085 "PostgreSQL standalone deploy_on '{}' does not reference a valid server group",
1086 standalone.deploy_on
1087 )));
1088 }
1089 }
1090 PostgresDeploymentMode::InCluster => {
1091 return Err(ConfigError::ArchitectureViolation(
1093 "PostgreSQL in-cluster deployment is not allowed. \
1094 LMRC Stack architecture principle: 'Stateless Kubernetes - No In-Cluster Services'. \
1095 Stateful services (databases, message queues) must run on dedicated servers, \
1096 not in Kubernetes. Please use deployment_mode = 'standalone' instead. \
1097 See docs/architecture/PRINCIPLES.md for details.".to_string()
1098 ));
1099 }
1100 }
1101 }
1102
1103 if let Some(rabbitmq) = &self.infrastructure.rabbitmq {
1105 match rabbitmq.deployment_mode {
1106 RabbitMqDeploymentMode::Standalone => {
1107 if rabbitmq.standalone.is_none() {
1108 return Err(ConfigError::InvalidToml(
1109 "RabbitMQ standalone mode requires 'standalone' configuration"
1110 .to_string(),
1111 ));
1112 }
1113 if let Some(standalone) = &rabbitmq.standalone
1115 && !self
1116 .infrastructure
1117 .servers
1118 .iter()
1119 .any(|s| s.name == standalone.deploy_on)
1120 {
1121 return Err(ConfigError::InvalidToml(format!(
1122 "RabbitMQ standalone deploy_on '{}' does not reference a valid server group",
1123 standalone.deploy_on
1124 )));
1125 }
1126 }
1127 RabbitMqDeploymentMode::InCluster => {
1128 return Err(ConfigError::ArchitectureViolation(
1130 "RabbitMQ in-cluster deployment is not allowed. \
1131 LMRC Stack architecture principle: 'Stateless Kubernetes - No In-Cluster Services'. \
1132 Stateful services (databases, message queues) must run on dedicated servers, \
1133 not in Kubernetes. Please use deployment_mode = 'standalone' instead. \
1134 See docs/architecture/PRINCIPLES.md for details.".to_string()
1135 ));
1136 }
1137 }
1138 }
1139
1140 if let Some(vault) = &self.infrastructure.vault {
1142 match vault.deployment_mode {
1143 VaultDeploymentMode::Standalone => {
1144 if vault.standalone.is_none() {
1145 return Err(ConfigError::InvalidToml(
1146 "Vault standalone mode requires 'standalone' configuration"
1147 .to_string(),
1148 ));
1149 }
1150 if let Some(standalone) = &vault.standalone
1152 && !self
1153 .infrastructure
1154 .servers
1155 .iter()
1156 .any(|s| s.name == standalone.deploy_on)
1157 {
1158 return Err(ConfigError::InvalidToml(format!(
1159 "Vault standalone deploy_on '{}' does not reference a valid server group",
1160 standalone.deploy_on
1161 )));
1162 }
1163 }
1164 VaultDeploymentMode::InCluster => {
1165 if vault.in_cluster.is_none() {
1166 return Err(ConfigError::InvalidToml(
1167 "Vault in-cluster mode requires 'in_cluster' configuration"
1168 .to_string(),
1169 ));
1170 }
1171 }
1172 }
1173 }
1174
1175 if let Some(lb) = &self.infrastructure.load_balancer {
1177 for target in &lb.targets {
1179 for server_name in &target.servers {
1180 if !self
1181 .infrastructure
1182 .servers
1183 .iter()
1184 .any(|s| s.name == *server_name)
1185 {
1186 return Err(ConfigError::InvalidToml(format!(
1187 "Load balancer target server '{}' does not reference a valid server group",
1188 server_name
1189 )));
1190 }
1191 }
1192 }
1193 }
1194
1195 if let Some(k3s) = &self.infrastructure.k3s {
1197 for server_group in &k3s.deploy_on {
1198 if !self
1199 .infrastructure
1200 .servers
1201 .iter()
1202 .any(|s| s.name == *server_group)
1203 {
1204 return Err(ConfigError::InvalidToml(format!(
1205 "K3s deploy_on '{}' does not reference a valid server group",
1206 server_group
1207 )));
1208 }
1209 }
1210 for server_group in &k3s.control_plane_servers {
1211 if !self
1212 .infrastructure
1213 .servers
1214 .iter()
1215 .any(|s| s.name == *server_group)
1216 {
1217 return Err(ConfigError::InvalidToml(format!(
1218 "K3s control_plane_servers '{}' does not reference a valid server group",
1219 server_group
1220 )));
1221 }
1222 }
1223 for server_group in &k3s.worker_servers {
1224 if !self
1225 .infrastructure
1226 .servers
1227 .iter()
1228 .any(|s| s.name == *server_group)
1229 {
1230 return Err(ConfigError::InvalidToml(format!(
1231 "K3s worker_servers '{}' does not reference a valid server group",
1232 server_group
1233 )));
1234 }
1235 }
1236 }
1237
1238 Ok(())
1239 }
1240
1241 pub fn template() -> Self {
1243 let mut labels = HashMap::new();
1244 labels.insert("environment".to_string(), "production".to_string());
1245
1246 Self {
1247 project: ProjectConfig {
1248 name: "my-project".to_string(),
1249 description: "My LMRC Stack project".to_string(),
1250 },
1251 providers: ProviderConfig {
1252 server: "hetzner".to_string(),
1253 kubernetes: "k3s".to_string(),
1254 database: "postgres".to_string(),
1255 queue: "rabbitmq".to_string(),
1256 dns: "cloudflare".to_string(),
1257 git: "gitlab".to_string(),
1258 },
1259 apps: AppsConfig {
1260 applications: vec![ApplicationEntry {
1261 name: "api".to_string(),
1262 app_type: Some(AppType::Api),
1263 docker: Some(DockerConfig {
1264 dockerfile: "apps/api/Dockerfile".to_string(),
1265 context: "apps/api".to_string(),
1266 tags: vec!["latest".to_string()],
1267 }),
1268 deployment: Some(DeploymentConfig {
1269 replicas: 2,
1270 port: 8080,
1271 cpu_request: Some("100m".to_string()),
1272 memory_request: Some("128Mi".to_string()),
1273 cpu_limit: Some("1".to_string()),
1274 memory_limit: Some("512Mi".to_string()),
1275 env: vec![],
1276 }),
1277 }],
1278 },
1279 infrastructure: InfrastructureConfig {
1280 provider: "hetzner".to_string(),
1281 network: Some(NetworkConfig {
1282 enable_private_network: true,
1283 private_network: Some("10.0.0.0/16".to_string()),
1284 firewall_rules: vec![],
1285 }),
1286 servers: vec![
1287 ServerGroup {
1288 name: "k3s-control".to_string(),
1289 role: ServerRole::K3sControl,
1290 server_type: "cpx11".to_string(),
1291 location: "nbg1".to_string(),
1292 count: 1,
1293 labels: labels.clone(),
1294 ssh_keys: vec![],
1295 image: None,
1296 },
1297 ServerGroup {
1298 name: "k3s-workers".to_string(),
1299 role: ServerRole::K3sWorker,
1300 server_type: "cx21".to_string(),
1301 location: "nbg1".to_string(),
1302 count: 2,
1303 labels: labels.clone(),
1304 ssh_keys: vec![],
1305 image: None,
1306 },
1307 ServerGroup {
1308 name: "postgres-server".to_string(),
1309 role: ServerRole::Postgres,
1310 server_type: "cx31".to_string(),
1311 location: "nbg1".to_string(),
1312 count: 1,
1313 labels,
1314 ssh_keys: vec![],
1315 image: None,
1316 },
1317 ],
1318 k3s: Some(K3sConfig {
1319 version: "v1.28.5+k3s1".to_string(),
1320 deploy_on: vec!["k3s-control".to_string(), "k3s-workers".to_string()],
1321 control_plane_servers: vec!["k3s-control".to_string()],
1322 worker_servers: vec!["k3s-workers".to_string()],
1323 enable_traefik: true,
1324 enable_metrics_server: false,
1325 server_flags: vec![],
1326 agent_flags: vec![],
1327 }),
1328 postgres: Some(PostgresConfig {
1329 version: "16".to_string(),
1330 database_name: "myapp".to_string(),
1331 deployment_mode: PostgresDeploymentMode::Standalone,
1332 standalone: Some(PostgresStandaloneConfig {
1333 deploy_on: "postgres-server".to_string(),
1334 data_dir: None,
1335 max_connections: None,
1336 shared_buffers: None,
1337 }),
1338 in_cluster: None,
1339 }),
1340 rabbitmq: None,
1341 vault: None,
1342 dns: Some(DnsConfig {
1343 provider: "cloudflare".to_string(),
1344 domain: "example.com".to_string(),
1345 records: vec![
1346 DnsRecord {
1347 record_type: "A".to_string(),
1348 name: "@".to_string(),
1349 target: "k3s-control".to_string(),
1350 ttl: 300,
1351 proxied: true,
1352 },
1353 DnsRecord {
1354 record_type: "A".to_string(),
1355 name: "api".to_string(),
1356 target: "k3s-control".to_string(),
1357 ttl: 300,
1358 proxied: true,
1359 },
1360 ],
1361 }),
1362 gitlab: Some(GitLabConfig {
1363 url: "https://gitlab.com".to_string(),
1364 namespace: "mygroup".to_string(),
1365 }),
1366 load_balancer: None,
1367 },
1368 }
1369 }
1370
1371 pub fn to_toml_string(&self) -> Result<String, ConfigError> {
1373 toml::to_string_pretty(self).map_err(|e| ConfigError::InvalidToml(e.to_string()))
1374 }
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380
1381 #[test]
1382 fn test_template_config_is_valid() {
1383 let config = LmrcConfig::template();
1384 assert!(config.validate().is_ok());
1385 assert!(config.validate_provider_configs().is_ok());
1386 }
1387
1388 #[test]
1389 fn test_config_to_toml_and_back() {
1390 let config = LmrcConfig::template();
1391 let toml_str = config.to_toml_string().unwrap();
1392 let parsed = LmrcConfig::from_toml_str(&toml_str).unwrap();
1393 assert_eq!(config.project.name, parsed.project.name);
1394 }
1395
1396 #[test]
1397 fn test_missing_provider_config() {
1398 let mut config = LmrcConfig::template();
1399 config.infrastructure.servers = vec![];
1400 assert!(config.validate_provider_configs().is_err());
1401 }
1402
1403 #[test]
1404 fn test_invalid_server_group_reference() {
1405 let mut config = LmrcConfig::template();
1406 if let Some(ref mut postgres) = config.infrastructure.postgres {
1407 if let Some(ref mut standalone) = postgres.standalone {
1408 standalone.deploy_on = "nonexistent-server".to_string();
1409 }
1410 }
1411 assert!(config.validate_provider_configs().is_err());
1412 }
1413}