1use crate::platform::api::client::PlatformApiClient;
10use crate::platform::api::types::{CloudProvider, LocationWithAvailability, ServerTypeSummary};
11
12#[derive(Debug, Clone)]
14pub struct CloudRegion {
15 pub id: &'static str,
17 pub name: &'static str,
19 pub location: &'static str,
21}
22
23#[derive(Debug, Clone)]
25pub struct MachineType {
26 pub id: &'static str,
28 pub name: &'static str,
30 pub cpu: &'static str,
32 pub memory: &'static str,
34 pub description: Option<&'static str>,
36}
37
38#[derive(Debug, Clone)]
44pub struct AcaResourcePair {
45 pub cpu: &'static str,
47 pub memory: &'static str,
49 pub label: &'static str,
51}
52
53pub static ACA_RESOURCE_PAIRS: &[AcaResourcePair] = &[
55 AcaResourcePair {
56 cpu: "0.25",
57 memory: "0.5Gi",
58 label: "0.25 vCPU, 0.5 GB",
59 },
60 AcaResourcePair {
61 cpu: "0.5",
62 memory: "1.0Gi",
63 label: "0.5 vCPU, 1 GB",
64 },
65 AcaResourcePair {
66 cpu: "0.75",
67 memory: "1.5Gi",
68 label: "0.75 vCPU, 1.5 GB",
69 },
70 AcaResourcePair {
71 cpu: "1.0",
72 memory: "2.0Gi",
73 label: "1 vCPU, 2 GB",
74 },
75 AcaResourcePair {
76 cpu: "1.25",
77 memory: "2.5Gi",
78 label: "1.25 vCPU, 2.5 GB",
79 },
80 AcaResourcePair {
81 cpu: "1.5",
82 memory: "3.0Gi",
83 label: "1.5 vCPU, 3 GB",
84 },
85 AcaResourcePair {
86 cpu: "1.75",
87 memory: "3.5Gi",
88 label: "1.75 vCPU, 3.5 GB",
89 },
90 AcaResourcePair {
91 cpu: "2.0",
92 memory: "4.0Gi",
93 label: "2 vCPU, 4 GB",
94 },
95];
96
97pub static AZURE_REGIONS: &[CloudRegion] = &[
99 CloudRegion {
101 id: "eastus",
102 name: "East US",
103 location: "Virginia",
104 },
105 CloudRegion {
106 id: "eastus2",
107 name: "East US 2",
108 location: "Virginia",
109 },
110 CloudRegion {
111 id: "westus",
112 name: "West US",
113 location: "California",
114 },
115 CloudRegion {
116 id: "westus2",
117 name: "West US 2",
118 location: "Washington",
119 },
120 CloudRegion {
121 id: "westus3",
122 name: "West US 3",
123 location: "Arizona",
124 },
125 CloudRegion {
126 id: "centralus",
127 name: "Central US",
128 location: "Iowa",
129 },
130 CloudRegion {
131 id: "canadacentral",
132 name: "Canada Central",
133 location: "Toronto",
134 },
135 CloudRegion {
136 id: "brazilsouth",
137 name: "Brazil South",
138 location: "São Paulo",
139 },
140 CloudRegion {
142 id: "westeurope",
143 name: "West Europe",
144 location: "Netherlands",
145 },
146 CloudRegion {
147 id: "northeurope",
148 name: "North Europe",
149 location: "Ireland",
150 },
151 CloudRegion {
152 id: "uksouth",
153 name: "UK South",
154 location: "London",
155 },
156 CloudRegion {
157 id: "ukwest",
158 name: "UK West",
159 location: "Cardiff",
160 },
161 CloudRegion {
162 id: "germanywestcentral",
163 name: "Germany West Central",
164 location: "Frankfurt",
165 },
166 CloudRegion {
167 id: "francecentral",
168 name: "France Central",
169 location: "Paris",
170 },
171 CloudRegion {
172 id: "swedencentral",
173 name: "Sweden Central",
174 location: "Gävle",
175 },
176 CloudRegion {
178 id: "eastasia",
179 name: "East Asia",
180 location: "Hong Kong",
181 },
182 CloudRegion {
183 id: "southeastasia",
184 name: "Southeast Asia",
185 location: "Singapore",
186 },
187 CloudRegion {
188 id: "japaneast",
189 name: "Japan East",
190 location: "Tokyo",
191 },
192 CloudRegion {
193 id: "japanwest",
194 name: "Japan West",
195 location: "Osaka",
196 },
197 CloudRegion {
198 id: "koreacentral",
199 name: "Korea Central",
200 location: "Seoul",
201 },
202 CloudRegion {
203 id: "australiaeast",
204 name: "Australia East",
205 location: "Sydney",
206 },
207 CloudRegion {
208 id: "centralindia",
209 name: "Central India",
210 location: "Pune",
211 },
212];
213
214#[derive(Debug, Clone)]
220pub struct CloudRunCpuMemory {
221 pub cpu: &'static str,
223 pub memory_options: &'static [&'static str],
225 pub default_memory: &'static str,
227}
228
229pub static CLOUD_RUN_CPU_MEMORY: &[CloudRunCpuMemory] = &[
231 CloudRunCpuMemory {
232 cpu: "1",
233 memory_options: &["128Mi", "256Mi", "512Mi", "1Gi", "2Gi", "4Gi"],
234 default_memory: "512Mi",
235 },
236 CloudRunCpuMemory {
237 cpu: "2",
238 memory_options: &["256Mi", "512Mi", "1Gi", "2Gi", "4Gi", "8Gi"],
239 default_memory: "2Gi",
240 },
241 CloudRunCpuMemory {
242 cpu: "4",
243 memory_options: &["512Mi", "1Gi", "2Gi", "4Gi", "8Gi", "16Gi"],
244 default_memory: "4Gi",
245 },
246 CloudRunCpuMemory {
247 cpu: "6",
248 memory_options: &["1Gi", "2Gi", "4Gi", "8Gi", "16Gi", "24Gi"],
249 default_memory: "8Gi",
250 },
251 CloudRunCpuMemory {
252 cpu: "8",
253 memory_options: &["2Gi", "4Gi", "8Gi", "16Gi", "24Gi", "32Gi"],
254 default_memory: "16Gi",
255 },
256];
257
258pub fn validate_aca_cpu_memory(cpu: &str, memory: &str) -> bool {
264 ACA_RESOURCE_PAIRS
265 .iter()
266 .any(|p| p.cpu == cpu && p.memory == memory)
267}
268
269pub fn validate_cloud_run_cpu_memory(cpu: &str, memory: &str) -> bool {
271 CLOUD_RUN_CPU_MEMORY
272 .iter()
273 .any(|c| c.cpu == cpu && c.memory_options.contains(&memory))
274}
275
276pub fn get_cloud_run_memory_for_cpu(cpu: &str) -> &'static [&'static str] {
278 CLOUD_RUN_CPU_MEMORY
279 .iter()
280 .find(|c| c.cpu == cpu)
281 .map(|c| c.memory_options)
282 .unwrap_or(&[])
283}
284
285pub static GCP_REGIONS: &[CloudRegion] = &[
291 CloudRegion {
293 id: "us-central1",
294 name: "Iowa",
295 location: "US Central",
296 },
297 CloudRegion {
298 id: "us-east1",
299 name: "South Carolina",
300 location: "US East",
301 },
302 CloudRegion {
303 id: "us-east4",
304 name: "Virginia",
305 location: "US East",
306 },
307 CloudRegion {
308 id: "us-west1",
309 name: "Oregon",
310 location: "US West",
311 },
312 CloudRegion {
313 id: "us-west2",
314 name: "Los Angeles",
315 location: "US West",
316 },
317 CloudRegion {
319 id: "europe-west1",
320 name: "Belgium",
321 location: "Europe",
322 },
323 CloudRegion {
324 id: "europe-west2",
325 name: "London",
326 location: "UK",
327 },
328 CloudRegion {
329 id: "europe-west3",
330 name: "Frankfurt",
331 location: "Germany",
332 },
333 CloudRegion {
334 id: "europe-west4",
335 name: "Netherlands",
336 location: "Europe",
337 },
338 CloudRegion {
339 id: "europe-north1",
340 name: "Finland",
341 location: "Europe",
342 },
343 CloudRegion {
345 id: "asia-east1",
346 name: "Taiwan",
347 location: "Asia Pacific",
348 },
349 CloudRegion {
350 id: "asia-northeast1",
351 name: "Tokyo",
352 location: "Japan",
353 },
354 CloudRegion {
355 id: "asia-southeast1",
356 name: "Singapore",
357 location: "Southeast Asia",
358 },
359 CloudRegion {
360 id: "australia-southeast1",
361 name: "Sydney",
362 location: "Australia",
363 },
364];
365
366pub static GCP_MACHINE_TYPES: &[MachineType] = &[
368 MachineType {
370 id: "e2-micro",
371 name: "e2-micro",
372 cpu: "0.25",
373 memory: "1 GB",
374 description: Some("Shared-core"),
375 },
376 MachineType {
377 id: "e2-small",
378 name: "e2-small",
379 cpu: "0.5",
380 memory: "2 GB",
381 description: Some("Shared-core"),
382 },
383 MachineType {
384 id: "e2-medium",
385 name: "e2-medium",
386 cpu: "1",
387 memory: "4 GB",
388 description: Some("Shared-core"),
389 },
390 MachineType {
391 id: "e2-standard-2",
392 name: "e2-standard-2",
393 cpu: "2",
394 memory: "8 GB",
395 description: None,
396 },
397 MachineType {
398 id: "e2-standard-4",
399 name: "e2-standard-4",
400 cpu: "4",
401 memory: "16 GB",
402 description: None,
403 },
404 MachineType {
405 id: "e2-standard-8",
406 name: "e2-standard-8",
407 cpu: "8",
408 memory: "32 GB",
409 description: None,
410 },
411 MachineType {
413 id: "n2-standard-2",
414 name: "n2-standard-2",
415 cpu: "2",
416 memory: "8 GB",
417 description: None,
418 },
419 MachineType {
420 id: "n2-standard-4",
421 name: "n2-standard-4",
422 cpu: "4",
423 memory: "16 GB",
424 description: None,
425 },
426 MachineType {
427 id: "n2-standard-8",
428 name: "n2-standard-8",
429 cpu: "8",
430 memory: "32 GB",
431 description: None,
432 },
433];
434
435pub fn get_regions_for_provider(provider: &CloudProvider) -> &'static [CloudRegion] {
442 match provider {
443 CloudProvider::Hetzner => &[], CloudProvider::Gcp => GCP_REGIONS,
445 CloudProvider::Azure => AZURE_REGIONS,
446 _ => &[], }
448}
449
450pub fn get_machine_types_for_provider(provider: &CloudProvider) -> &'static [MachineType] {
453 match provider {
454 CloudProvider::Hetzner => &[], CloudProvider::Gcp => GCP_MACHINE_TYPES,
456 _ => &[], }
458}
459
460pub fn get_default_region(provider: &CloudProvider) -> &'static str {
462 match provider {
463 CloudProvider::Hetzner => "nbg1",
464 CloudProvider::Gcp => "us-central1",
465 CloudProvider::Azure => "eastus",
466 _ => "",
467 }
468}
469
470pub fn get_default_machine_type(provider: &CloudProvider) -> &'static str {
473 match provider {
474 CloudProvider::Hetzner => "cx22",
475 CloudProvider::Gcp => "e2-small",
476 CloudProvider::Azure => "0.5",
477 _ => "",
478 }
479}
480
481#[derive(Debug, Clone)]
487pub struct DynamicCloudRegion {
488 pub id: String,
490 pub name: String,
492 pub location: String,
494 pub network_zone: String,
496 pub available_server_types: Vec<String>,
498}
499
500#[derive(Debug, Clone)]
502pub struct DynamicMachineType {
503 pub id: String,
505 pub name: String,
507 pub cores: i32,
509 pub memory_gb: f64,
511 pub disk_gb: i64,
513 pub price_monthly: f64,
515 pub price_hourly: f64,
517 pub available_in: Vec<String>,
519}
520
521#[derive(Debug)]
523pub enum HetznerFetchResult<T> {
524 Success(T),
526 NoCredentials,
528 ApiError(String),
530}
531
532fn location_to_dynamic_region(loc: &LocationWithAvailability) -> DynamicCloudRegion {
534 DynamicCloudRegion {
535 id: loc.location.name.clone(),
536 name: loc.location.city.clone(),
537 location: loc.location.country.clone(),
538 network_zone: loc.location.network_zone.clone(),
539 available_server_types: loc.available_server_types.clone(),
540 }
541}
542
543fn server_type_to_dynamic(st: &ServerTypeSummary) -> DynamicMachineType {
545 DynamicMachineType {
546 id: st.name.clone(),
547 name: st.name.clone(),
548 cores: st.cores,
549 memory_gb: st.memory_gb,
550 disk_gb: st.disk_gb,
551 price_monthly: st.price_monthly,
552 price_hourly: st.price_hourly,
553 available_in: st.available_in.clone(),
554 }
555}
556
557pub async fn get_hetzner_regions_dynamic(
566 client: &PlatformApiClient,
567 project_id: &str,
568) -> HetznerFetchResult<Vec<DynamicCloudRegion>> {
569 match client.get_hetzner_locations(project_id).await {
570 Ok(locations) => {
571 HetznerFetchResult::Success(locations.iter().map(location_to_dynamic_region).collect())
572 }
573 Err(e) => {
574 let error_msg = e.to_string();
575 if error_msg.contains("credentials")
577 || error_msg.contains("Unauthorized")
578 || error_msg.contains("token")
579 || error_msg.contains("API token")
580 || error_msg.contains("401")
581 || error_msg.contains("412")
582 {
584 HetznerFetchResult::NoCredentials
585 } else {
586 HetznerFetchResult::ApiError(error_msg)
587 }
588 }
589 }
590}
591
592pub async fn get_hetzner_server_types_dynamic(
601 client: &PlatformApiClient,
602 project_id: &str,
603 preferred_location: Option<&str>,
604) -> HetznerFetchResult<Vec<DynamicMachineType>> {
605 match client
606 .get_hetzner_server_types(project_id, preferred_location)
607 .await
608 {
609 Ok(server_types) => {
610 HetznerFetchResult::Success(server_types.iter().map(server_type_to_dynamic).collect())
611 }
612 Err(e) => {
613 let error_msg = e.to_string();
614 if error_msg.contains("credentials")
616 || error_msg.contains("Unauthorized")
617 || error_msg.contains("token")
618 || error_msg.contains("API token")
619 || error_msg.contains("401")
620 || error_msg.contains("412")
621 {
623 HetznerFetchResult::NoCredentials
624 } else {
625 HetznerFetchResult::ApiError(error_msg)
626 }
627 }
628 }
629}
630
631pub async fn check_hetzner_availability(
640 client: &PlatformApiClient,
641 project_id: &str,
642 location: &str,
643 server_type: &str,
644) -> (bool, Option<String>, Vec<String>) {
645 match client
646 .check_hetzner_availability(project_id, location, server_type)
647 .await
648 {
649 Ok(result) => (
650 result.available,
651 result.reason,
652 result.alternative_locations.unwrap_or_default(),
653 ),
654 Err(e) => {
655 (false, Some(format!("Failed to check: {}", e)), vec![])
657 }
658 }
659}
660
661pub async fn get_recommended_server_type(
671 client: &PlatformApiClient,
672 project_id: &str,
673 profile: &str,
674 preferred_location: Option<&str>,
675) -> Option<DynamicMachineType> {
676 let (min_cores, min_memory, prefer_dedicated) = match profile {
677 "minimal" => (1, 2.0, false),
678 "standard" => (2, 4.0, false),
679 "performance" => (4, 8.0, true),
680 "high-memory" => (2, 16.0, false),
681 _ => (2, 4.0, false), };
683
684 let server_types =
685 match get_hetzner_server_types_dynamic(client, project_id, preferred_location).await {
686 HetznerFetchResult::Success(types) => types,
687 _ => return None,
688 };
689
690 server_types
692 .into_iter()
693 .filter(|st| {
694 st.cores >= min_cores
695 && st.memory_gb >= min_memory
696 && (!prefer_dedicated || st.name.starts_with("ccx"))
697 })
698 .filter(|st| {
699 preferred_location.is_none_or(|loc| st.available_in.contains(&loc.to_string()))
701 })
702 .min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
703}
704
705pub async fn find_best_region(
710 client: &PlatformApiClient,
711 project_id: &str,
712 preferred_zone: Option<&str>,
713) -> Option<DynamicCloudRegion> {
714 let regions = match get_hetzner_regions_dynamic(client, project_id).await {
715 HetznerFetchResult::Success(r) => r,
716 _ => return None,
717 };
718
719 let mut sorted_regions = regions;
721 sorted_regions.sort_by(|a, b| {
722 let a_zone_match = preferred_zone.is_some_and(|z| a.network_zone == z);
723 let b_zone_match = preferred_zone.is_some_and(|z| b.network_zone == z);
724
725 match (a_zone_match, b_zone_match) {
726 (true, false) => std::cmp::Ordering::Less,
727 (false, true) => std::cmp::Ordering::Greater,
728 _ => b
729 .available_server_types
730 .len()
731 .cmp(&a.available_server_types.len()),
732 }
733 });
734
735 sorted_regions.into_iter().next()
736}
737
738pub async fn find_cheapest_available(
743 client: &PlatformApiClient,
744 project_id: &str,
745 region: &str,
746) -> Option<DynamicMachineType> {
747 let server_types =
748 match get_hetzner_server_types_dynamic(client, project_id, Some(region)).await {
749 HetznerFetchResult::Success(types) => types,
750 _ => return None,
751 };
752
753 server_types
755 .into_iter()
756 .filter(|st| st.available_in.contains(®ion.to_string()))
757 .min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
758}
759
760pub fn format_dynamic_region_display(region: &DynamicCloudRegion) -> String {
766 if region.available_server_types.is_empty() {
767 format!(
768 "{} ({}) - checking availability...",
769 region.name, region.location
770 )
771 } else {
772 format!(
773 "{} ({}) · {} server types available",
774 region.name,
775 region.location,
776 region.available_server_types.len()
777 )
778 }
779}
780
781pub fn format_dynamic_machine_type_display(machine: &DynamicMachineType) -> String {
783 format!(
784 "{} · {} vCPU · {:.0} GB · €{:.2}/mo",
785 machine.name, machine.cores, machine.memory_gb, machine.price_monthly
786 )
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn test_gcp_regions() {
795 assert!(!GCP_REGIONS.is_empty());
796 assert!(GCP_REGIONS.iter().any(|r| r.id == "us-central1"));
797 }
798
799 #[test]
800 fn test_gcp_machine_types() {
801 assert!(!GCP_MACHINE_TYPES.is_empty());
802 assert!(GCP_MACHINE_TYPES.iter().any(|m| m.id == "e2-small"));
803 }
804
805 #[test]
806 fn test_hetzner_returns_empty_static() {
807 let regions = get_regions_for_provider(&CloudProvider::Hetzner);
810 assert!(regions.is_empty());
811
812 let machines = get_machine_types_for_provider(&CloudProvider::Hetzner);
813 assert!(machines.is_empty());
814 }
815
816 #[test]
817 fn test_gcp_returns_static_data() {
818 let regions = get_regions_for_provider(&CloudProvider::Gcp);
819 assert!(!regions.is_empty());
820
821 let machines = get_machine_types_for_provider(&CloudProvider::Gcp);
822 assert!(!machines.is_empty());
823 }
824
825 #[test]
826 fn test_defaults() {
827 assert_eq!(get_default_region(&CloudProvider::Hetzner), "nbg1");
828 assert_eq!(get_default_region(&CloudProvider::Gcp), "us-central1");
829 assert_eq!(get_default_region(&CloudProvider::Azure), "eastus");
830 assert_eq!(get_default_machine_type(&CloudProvider::Hetzner), "cx22");
831 assert_eq!(get_default_machine_type(&CloudProvider::Gcp), "e2-small");
832 assert_eq!(get_default_machine_type(&CloudProvider::Azure), "0.5");
833 }
834
835 #[test]
836 fn test_azure_regions() {
837 assert!(!AZURE_REGIONS.is_empty());
838 assert_eq!(AZURE_REGIONS.len(), 22);
839 assert!(AZURE_REGIONS.iter().any(|r| r.id == "eastus"));
840 assert!(AZURE_REGIONS.iter().any(|r| r.id == "westeurope"));
841 }
842
843 #[test]
844 fn test_azure_regions_via_provider() {
845 let regions = get_regions_for_provider(&CloudProvider::Azure);
846 assert!(!regions.is_empty());
847 assert_eq!(regions.len(), 22);
848 }
849
850 #[test]
851 fn test_aca_resource_pairs() {
852 assert_eq!(ACA_RESOURCE_PAIRS.len(), 8);
853 assert_eq!(ACA_RESOURCE_PAIRS[0].cpu, "0.25");
854 assert_eq!(ACA_RESOURCE_PAIRS[0].memory, "0.5Gi");
855 assert_eq!(ACA_RESOURCE_PAIRS[7].cpu, "2.0");
856 assert_eq!(ACA_RESOURCE_PAIRS[7].memory, "4.0Gi");
857 }
858
859 #[test]
860 fn test_validate_aca_cpu_memory() {
861 assert!(validate_aca_cpu_memory("0.5", "1.0Gi"));
862 assert!(validate_aca_cpu_memory("2.0", "4.0Gi"));
863 assert!(!validate_aca_cpu_memory("0.5", "4.0Gi")); assert!(!validate_aca_cpu_memory("3.0", "8.0Gi")); }
866
867 #[test]
868 fn test_cloud_run_cpu_memory() {
869 assert_eq!(CLOUD_RUN_CPU_MEMORY.len(), 5);
870 assert_eq!(CLOUD_RUN_CPU_MEMORY[0].cpu, "1");
871 assert_eq!(CLOUD_RUN_CPU_MEMORY[0].default_memory, "512Mi");
872 }
873
874 #[test]
875 fn test_validate_cloud_run_cpu_memory() {
876 assert!(validate_cloud_run_cpu_memory("2", "4Gi"));
877 assert!(validate_cloud_run_cpu_memory("1", "512Mi"));
878 assert!(!validate_cloud_run_cpu_memory("1", "16Gi")); assert!(!validate_cloud_run_cpu_memory("3", "4Gi")); }
881
882 #[test]
883 fn test_get_cloud_run_memory_for_cpu() {
884 let options = get_cloud_run_memory_for_cpu("1");
885 assert_eq!(options.len(), 6);
886 assert!(options.contains(&"512Mi"));
887 assert!(options.contains(&"4Gi"));
888
889 let empty = get_cloud_run_memory_for_cpu("99");
890 assert!(empty.is_empty());
891 }
892
893 #[test]
894 fn test_dynamic_region_display() {
895 let region = DynamicCloudRegion {
896 id: "nbg1".to_string(),
897 name: "Nuremberg".to_string(),
898 location: "Germany".to_string(),
899 network_zone: "eu-central".to_string(),
900 available_server_types: vec!["cx22".to_string(), "cx32".to_string()],
901 };
902 let display = format_dynamic_region_display(®ion);
903 assert!(display.contains("Nuremberg"));
904 assert!(display.contains("2 server types"));
905 }
906
907 #[test]
908 fn test_dynamic_machine_display() {
909 let machine = DynamicMachineType {
910 id: "cx22".to_string(),
911 name: "cx22".to_string(),
912 cores: 2,
913 memory_gb: 4.0,
914 disk_gb: 40,
915 price_monthly: 5.95,
916 price_hourly: 0.008,
917 available_in: vec!["nbg1".to_string()],
918 };
919 let display = format_dynamic_machine_type_display(&machine);
920 assert!(display.contains("cx22"));
921 assert!(display.contains("2 vCPU"));
922 assert!(display.contains("€5.95/mo"));
923 }
924}