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
311fn default_provider() -> String {
312 "hetzner".to_string()
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
317pub struct NetworkConfig {
318 #[serde(default)]
320 pub enable_private_network: bool,
321
322 pub private_network: Option<String>,
324
325 #[serde(default)]
327 pub firewall_rules: Vec<FirewallRule>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
332pub struct FirewallRule {
333 #[validate(length(min = 1))]
335 pub name: String,
336
337 pub direction: String,
339
340 pub protocol: String,
342
343 pub port: Option<String>,
345
346 pub source: String,
348
349 pub destination: String,
351
352 pub action: String,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
358pub struct ServerGroup {
359 #[validate(length(min = 1, max = 50))]
361 pub name: String,
362
363 pub role: ServerRole,
365
366 pub server_type: String,
368
369 pub location: String,
371
372 #[validate(range(min = 1, max = 50))]
374 pub count: u32,
375
376 #[serde(default)]
378 pub labels: HashMap<String, String>,
379
380 #[serde(default)]
382 pub ssh_keys: Vec<String>,
383
384 pub image: Option<String>,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(rename_all = "kebab-case")]
391pub enum ServerRole {
392 K3sControl,
394 K3sWorker,
396 Postgres,
398 MySQL,
400 MongoDB,
402 Redis,
404 Memcached,
406 RabbitMQ,
408 Kafka,
410 Elasticsearch,
412 LoadBalancer,
414 Monitoring,
416 CIRunner,
418 Bastion,
420 Storage,
422 Custom,
424}
425
426impl ServerRole {
427 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub enum SetupMode {
497 Quick,
499 Standard,
501 Advanced,
503}
504
505impl SetupMode {
506 pub fn all() -> Vec<Self> {
508 vec![SetupMode::Quick, SetupMode::Standard, SetupMode::Advanced]
509 }
510
511 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 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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
538pub struct K3sConfig {
539 pub version: String,
541
542 #[validate(length(min = 1))]
544 pub deploy_on: Vec<String>,
545
546 #[validate(length(min = 1))]
548 pub control_plane_servers: Vec<String>,
549
550 #[serde(default)]
552 pub worker_servers: Vec<String>,
553
554 #[serde(default = "default_true")]
556 pub enable_traefik: bool,
557
558 #[serde(default)]
560 pub enable_metrics_server: bool,
561
562 #[serde(default)]
564 pub server_flags: Vec<String>,
565
566 #[serde(default)]
568 pub agent_flags: Vec<String>,
569}
570
571fn default_true() -> bool {
572 true
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
577pub struct PostgresConfig {
578 pub version: String,
580
581 #[validate(length(min = 1, max = 63))]
583 pub database_name: String,
584
585 pub deployment_mode: PostgresDeploymentMode,
587
588 #[serde(skip_serializing_if = "Option::is_none")]
590 #[validate(nested)]
591 pub standalone: Option<PostgresStandaloneConfig>,
592
593 #[serde(skip_serializing_if = "Option::is_none")]
595 #[validate(nested)]
596 pub in_cluster: Option<PostgresInClusterConfig>,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
601#[serde(rename_all = "kebab-case")]
602pub enum PostgresDeploymentMode {
603 Standalone,
605 InCluster,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
611pub struct PostgresStandaloneConfig {
612 #[validate(length(min = 1))]
614 pub deploy_on: String,
615
616 pub data_dir: Option<String>,
618
619 pub max_connections: Option<u32>,
621
622 pub shared_buffers: Option<String>,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
628pub struct PostgresInClusterConfig {
629 #[validate(length(min = 1))]
631 pub namespace: String,
632
633 pub storage_class: String,
635
636 pub storage_size: String,
638
639 #[serde(default)]
641 pub use_operator: bool,
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
646pub struct RabbitMqConfig {
647 pub version: String,
649
650 pub deployment_mode: RabbitMqDeploymentMode,
652
653 #[serde(skip_serializing_if = "Option::is_none")]
655 #[validate(nested)]
656 pub standalone: Option<RabbitMqStandaloneConfig>,
657
658 #[serde(skip_serializing_if = "Option::is_none")]
660 #[validate(nested)]
661 pub in_cluster: Option<RabbitMqInClusterConfig>,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
666#[serde(rename_all = "kebab-case")]
667pub enum RabbitMqDeploymentMode {
668 Standalone,
670 InCluster,
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
676pub struct RabbitMqStandaloneConfig {
677 #[validate(length(min = 1))]
679 pub deploy_on: String,
680
681 #[serde(default = "default_true")]
683 pub enable_management: bool,
684
685 #[serde(default = "default_management_port")]
687 pub management_port: u16,
688
689 #[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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
704pub struct RabbitMqInClusterConfig {
705 #[validate(length(min = 1))]
707 pub namespace: String,
708
709 pub storage_class: String,
711
712 pub storage_size: String,
714
715 #[serde(default = "default_rabbitmq_replicas")]
717 #[validate(range(min = 1, max = 10))]
718 pub replicas: u32,
719
720 #[serde(default)]
722 pub use_operator: bool,
723}
724
725fn default_rabbitmq_replicas() -> u32 {
726 1
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
731pub struct VaultConfig {
732 pub version: String,
734
735 pub deployment_mode: VaultDeploymentMode,
737
738 #[serde(skip_serializing_if = "Option::is_none")]
740 #[validate(nested)]
741 pub in_cluster: Option<VaultInClusterConfig>,
742}
743
744#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
746#[serde(rename_all = "kebab-case")]
747pub enum VaultDeploymentMode {
748 InCluster,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
754pub struct VaultInClusterConfig {
755 #[validate(length(min = 1))]
757 pub namespace: String,
758
759 #[serde(default = "default_vault_replicas")]
761 #[validate(range(min = 1, max = 5))]
762 pub replicas: u32,
763
764 #[serde(default = "default_vault_storage")]
766 pub storage_size: String,
767
768 pub storage_class: Option<String>,
770
771 #[serde(default = "default_true")]
773 pub enable_ui: bool,
774
775 #[serde(default = "default_service_type")]
777 pub service_type: String,
778
779 pub node_port: Option<u16>,
781
782 pub ingress_host: Option<String>,
784
785 #[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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
804pub struct DnsConfig {
805 pub provider: String,
807
808 #[validate(length(min = 1))]
810 pub domain: String,
811
812 #[serde(default)]
814 pub records: Vec<DnsRecord>,
815}
816
817#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
819pub struct DnsRecord {
820 #[serde(rename = "type")]
822 pub record_type: String,
823
824 #[validate(length(min = 1))]
826 pub name: String,
827
828 pub target: String,
833
834 #[serde(default = "default_ttl")]
836 pub ttl: u32,
837
838 #[serde(default)]
840 pub proxied: bool,
841}
842
843fn default_ttl() -> u32 {
844 300
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
849pub struct GitLabConfig {
850 #[validate(url)]
852 pub url: String,
853
854 #[validate(length(min = 1))]
856 pub namespace: String,
857}
858
859#[derive(Debug, Error)]
861pub enum ConfigError {
862 #[error("Invalid TOML format: {0}")]
864 InvalidToml(String),
865
866 #[error("TOML deserialization error: {0}")]
868 TomlDe(#[from] toml::de::Error),
869
870 #[error("Configuration validation failed: {0}")]
872 ValidationFailed(#[from] validator::ValidationErrors),
873
874 #[error("IO error: {0}")]
876 Io(#[from] std::io::Error),
877
878 #[error("Missing required configuration for provider: {0}")]
880 MissingProviderConfig(String),
881}
882
883impl LmrcConfig {
884 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 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 fn validate_provider_configs(&self) -> Result<(), ConfigError> {
900 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 if self.providers.kubernetes == "k3s" && self.infrastructure.k3s.is_none() {
909 return Err(ConfigError::MissingProviderConfig("k3s".to_string()));
910 }
911
912 if self.providers.database == "postgres" && self.infrastructure.postgres.is_none() {
914 return Err(ConfigError::MissingProviderConfig("postgres".to_string()));
915 }
916
917 if self.infrastructure.dns.is_none() {
919 return Err(ConfigError::MissingProviderConfig("dns".to_string()));
920 }
921
922 if self.providers.git == "gitlab" && self.infrastructure.gitlab.is_none() {
924 return Err(ConfigError::MissingProviderConfig("gitlab".to_string()));
925 }
926
927 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 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 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 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 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 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 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}