Skip to main content

syncable_cli/wizard/
infrastructure_selection.rs

1//! Infrastructure selection step for the deployment wizard
2//!
3//! Handles region and machine type selection for Cloud Runner deployments.
4//!
5//! For Hetzner: Uses DYNAMIC fetching from Hetzner API - no hardcoded fallback.
6//! The agent gets real-time availability and pricing for smart resource selection.
7//!
8//! For GCP: Uses static data (dynamic fetching not yet implemented).
9
10use crate::platform::api::client::PlatformApiClient;
11use crate::platform::api::types::CloudProvider;
12use crate::wizard::cloud_provider_data::{
13    ACA_RESOURCE_PAIRS, CLOUD_RUN_CPU_MEMORY, DynamicCloudRegion, DynamicMachineType,
14    HetznerFetchResult, get_default_machine_type, get_default_region, get_hetzner_regions_dynamic,
15    get_hetzner_server_types_dynamic, get_machine_types_for_provider, get_regions_for_provider,
16};
17use crate::wizard::render::{display_step_header, wizard_render_config};
18use colored::Colorize;
19use inquire::{InquireError, Select};
20use std::fmt;
21
22/// Result of infrastructure selection step
23#[derive(Debug, Clone)]
24pub enum InfrastructureSelectionResult {
25    /// User selected region and machine type
26    Selected {
27        region: String,
28        machine_type: String,
29        cpu: Option<String>,
30        memory: Option<String>,
31    },
32    /// User wants to go back
33    Back,
34    /// User cancelled the wizard
35    Cancelled,
36}
37
38/// Wrapper for displaying dynamic region options with availability info
39struct DynamicRegionOption {
40    region: DynamicCloudRegion,
41}
42
43impl fmt::Display for DynamicRegionOption {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        let availability = if !self.region.available_server_types.is_empty() {
46            format!(
47                " · {} types available",
48                self.region.available_server_types.len()
49            )
50        } else {
51            String::new()
52        };
53        write!(
54            f,
55            "{}  {}{}",
56            self.region.id.cyan(),
57            format!("{}  ({})", self.region.name, self.region.location).dimmed(),
58            availability.green()
59        )
60    }
61}
62
63/// Wrapper for displaying dynamic machine type options with pricing
64struct DynamicMachineTypeOption {
65    machine: DynamicMachineType,
66}
67
68impl fmt::Display for DynamicMachineTypeOption {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        let specs = format!(
71            "{} vCPU · {:.0} GB",
72            self.machine.cores, self.machine.memory_gb
73        );
74        let price = if self.machine.price_monthly > 0.0 {
75            format!(" · €{:.2}/mo", self.machine.price_monthly)
76        } else {
77            String::new()
78        };
79        write!(
80            f,
81            "{}  {}{}",
82            self.machine.name.cyan(),
83            specs.dimmed(),
84            price.green()
85        )
86    }
87}
88
89/// Select region and machine type for Cloud Runner deployment
90///
91/// Uses dynamic fetching for Hetzner to get real-time availability and pricing.
92/// Falls back to static data for other providers or if API fails.
93pub async fn select_infrastructure(
94    provider: &CloudProvider,
95    step_number: u8,
96    client: Option<&PlatformApiClient>,
97    project_id: Option<&str>,
98) -> InfrastructureSelectionResult {
99    // Select region first
100    let region = match select_region(provider, step_number, client, project_id).await {
101        Some(r) => r,
102        None => return InfrastructureSelectionResult::Back,
103    };
104
105    // Then select machine type (returns machine_type, optional cpu, optional memory)
106    match select_machine_type(provider, &region, client, project_id).await {
107        Some((machine_type, cpu, memory)) => InfrastructureSelectionResult::Selected {
108            region,
109            machine_type,
110            cpu,
111            memory,
112        },
113        None => InfrastructureSelectionResult::Back,
114    }
115}
116
117/// Legacy synchronous version for backward compatibility
118pub fn select_infrastructure_sync(
119    provider: &CloudProvider,
120    step_number: u8,
121) -> InfrastructureSelectionResult {
122    // Select region first using static data
123    let region = match select_region_static(provider, step_number) {
124        Some(r) => r,
125        None => return InfrastructureSelectionResult::Back,
126    };
127
128    // Then select machine type using static data
129    match select_machine_type_static(provider) {
130        Some(machine_type) => InfrastructureSelectionResult::Selected {
131            region,
132            machine_type,
133            cpu: None,
134            memory: None,
135        },
136        None => InfrastructureSelectionResult::Back,
137    }
138}
139
140/// Select region/location for deployment with dynamic fetching
141async fn select_region(
142    provider: &CloudProvider,
143    step_number: u8,
144    client: Option<&PlatformApiClient>,
145    project_id: Option<&str>,
146) -> Option<String> {
147    let provider_name = match provider {
148        CloudProvider::Hetzner => "Hetzner",
149        CloudProvider::Gcp => "GCP",
150        _ => "Cloud",
151    };
152
153    display_step_header(
154        step_number,
155        &format!("Select {} Region", provider_name),
156        "Choose the geographic location for your deployment.",
157    );
158
159    // For Hetzner: REQUIRE dynamic fetching - no static fallback
160    if *provider == CloudProvider::Hetzner {
161        if let (Some(client), Some(project_id)) = (client, project_id) {
162            match get_hetzner_regions_dynamic(client, project_id).await {
163                HetznerFetchResult::Success(regions) => {
164                    if regions.is_empty() {
165                        println!(
166                            "\n{} No Hetzner regions available. Please check your Hetzner account.",
167                            "✗".red()
168                        );
169                        return None;
170                    }
171                    return select_region_from_dynamic(regions, provider);
172                }
173                HetznerFetchResult::NoCredentials => {
174                    println!(
175                        "\n{} Hetzner credentials not configured for this project.",
176                        "✗".red()
177                    );
178                    println!(
179                        "  {} Please add your Hetzner API token in project settings.",
180                        "→".dimmed()
181                    );
182                    return None;
183                }
184                HetznerFetchResult::ApiError(err) => {
185                    println!("\n{} Failed to fetch Hetzner regions: {}", "✗".red(), err);
186                    return None;
187                }
188            }
189        } else {
190            println!(
191                "\n{} Cannot fetch Hetzner regions without authentication.",
192                "✗".red()
193            );
194            return None;
195        }
196    }
197
198    // For other providers: Use static data
199    select_region_static(provider, step_number)
200}
201
202/// Select region from dynamic data with availability info
203fn select_region_from_dynamic(
204    regions: Vec<DynamicCloudRegion>,
205    provider: &CloudProvider,
206) -> Option<String> {
207    if regions.is_empty() {
208        println!("\n{} No regions available for this provider.", "⚠".yellow());
209        return None;
210    }
211
212    let default_region = get_default_region(provider);
213    let default_index = regions
214        .iter()
215        .position(|r| r.id == default_region)
216        .unwrap_or(0);
217
218    let options: Vec<DynamicRegionOption> = regions
219        .into_iter()
220        .map(|r| DynamicRegionOption { region: r })
221        .collect();
222
223    let selection = Select::new("Select region:", options)
224        .with_render_config(wizard_render_config())
225        .with_starting_cursor(default_index)
226        .with_help_message("Use ↑/↓ to navigate, Enter to select · Real-time availability shown")
227        .prompt();
228
229    match selection {
230        Ok(selected) => {
231            println!(
232                "\n{} Selected region: {} ({})",
233                "✓".green(),
234                selected.region.name.cyan(),
235                selected.region.id
236            );
237            Some(selected.region.id)
238        }
239        Err(InquireError::OperationCanceled) => None,
240        Err(InquireError::OperationInterrupted) => None,
241        Err(_) => None,
242    }
243}
244
245/// Select region using static data (fallback)
246fn select_region_static(provider: &CloudProvider, step_number: u8) -> Option<String> {
247    display_step_header(
248        step_number,
249        &format!(
250            "Select {} Region",
251            match provider {
252                CloudProvider::Hetzner => "Hetzner",
253                CloudProvider::Gcp => "GCP",
254                _ => "Cloud",
255            }
256        ),
257        "Choose the geographic location for your deployment.",
258    );
259
260    let regions = get_regions_for_provider(provider);
261    if regions.is_empty() {
262        println!("\n{} No regions available for this provider.", "⚠".yellow());
263        return None;
264    }
265
266    let default_region = get_default_region(provider);
267    let default_index = regions
268        .iter()
269        .position(|r| r.id == default_region)
270        .unwrap_or(0);
271
272    // Convert static regions to dynamic format for consistent display
273    let options: Vec<DynamicRegionOption> = regions
274        .iter()
275        .map(|r| DynamicRegionOption {
276            region: DynamicCloudRegion {
277                id: r.id.to_string(),
278                name: r.name.to_string(),
279                location: r.location.to_string(),
280                network_zone: String::new(),
281                available_server_types: vec![],
282            },
283        })
284        .collect();
285
286    let selection = Select::new("Select region:", options)
287        .with_render_config(wizard_render_config())
288        .with_starting_cursor(default_index)
289        .with_help_message("Use ↑/↓ to navigate, Enter to select")
290        .prompt();
291
292    match selection {
293        Ok(selected) => {
294            println!(
295                "\n{} Selected region: {} ({})",
296                "✓".green(),
297                selected.region.name.cyan(),
298                selected.region.id
299            );
300            Some(selected.region.id)
301        }
302        Err(InquireError::OperationCanceled) => None,
303        Err(InquireError::OperationInterrupted) => None,
304        Err(_) => None,
305    }
306}
307
308/// Select machine/instance type for deployment with dynamic fetching
309///
310/// Returns (machine_type_id, optional_cpu, optional_memory)
311async fn select_machine_type(
312    provider: &CloudProvider,
313    region: &str,
314    client: Option<&PlatformApiClient>,
315    project_id: Option<&str>,
316) -> Option<(String, Option<String>, Option<String>)> {
317    println!();
318    println!(
319        "{}",
320        "─── Machine Type ────────────────────────────".dimmed()
321    );
322    println!("  {}", "Select the VM size for your deployment.".dimmed());
323
324    // For Hetzner: REQUIRE dynamic fetching - no static fallback
325    if *provider == CloudProvider::Hetzner {
326        if let (Some(client), Some(project_id)) = (client, project_id) {
327            match get_hetzner_server_types_dynamic(client, project_id, Some(region)).await {
328                HetznerFetchResult::Success(machine_types) => {
329                    if machine_types.is_empty() {
330                        println!(
331                            "\n{} No Hetzner server types available. Please check your Hetzner account.",
332                            "✗".red()
333                        );
334                        return None;
335                    }
336                    return select_machine_type_from_dynamic(machine_types, provider, region)
337                        .map(|m| (m, None, None));
338                }
339                HetznerFetchResult::NoCredentials => {
340                    println!(
341                        "\n{} Hetzner credentials not configured for this project.",
342                        "✗".red()
343                    );
344                    println!(
345                        "  {} Please add your Hetzner API token in project settings.",
346                        "→".dimmed()
347                    );
348                    return None;
349                }
350                HetznerFetchResult::ApiError(err) => {
351                    println!(
352                        "\n{} Failed to fetch Hetzner server types: {}",
353                        "✗".red(),
354                        err
355                    );
356                    return None;
357                }
358            }
359        } else {
360            println!(
361                "\n{} Cannot fetch Hetzner server types without authentication.",
362                "✗".red()
363            );
364            return None;
365        }
366    }
367
368    // Non-Hetzner providers: Azure ACA and GCP Cloud Run have custom selection UIs
369    match provider {
370        CloudProvider::Azure => {
371            select_aca_resource_pair().map(|(machine, cpu, mem)| (machine, Some(cpu), Some(mem)))
372        }
373        CloudProvider::Gcp => {
374            select_cloud_run_resources().map(|(machine, cpu, mem)| (machine, Some(cpu), Some(mem)))
375        }
376        _ => select_machine_type_static(provider).map(|m| (m, None, None)),
377    }
378}
379
380/// Select Azure Container Apps resource pair (CPU + memory combo)
381///
382/// Returns (machine_type_id, cpu, memory) e.g. ("0.5-cpu-1.0Gi-mem", "0.5", "1.0Gi")
383fn select_aca_resource_pair() -> Option<(String, String, String)> {
384    let pairs = ACA_RESOURCE_PAIRS;
385    if pairs.is_empty() {
386        println!(
387            "\n{} No Azure Container Apps resource options available.",
388            "⚠".yellow()
389        );
390        return None;
391    }
392
393    let labels: Vec<String> = pairs.iter().map(|p| p.label.to_string()).collect();
394    // Default to index 1 (0.5 vCPU / 1 GB)
395    let default_index = 1;
396
397    let selection = Select::new("Select resource allocation:", labels)
398        .with_render_config(wizard_render_config())
399        .with_starting_cursor(default_index)
400        .with_help_message("Azure Container Apps fixed CPU/memory pairs")
401        .prompt();
402
403    match selection {
404        Ok(selected_label) => {
405            let pair = pairs.iter().find(|p| p.label == selected_label)?;
406            let machine_type_id = format!("{}-cpu-{}-mem", pair.cpu, pair.memory);
407            println!(
408                "\n{} Selected: {} vCPU / {}",
409                "✓".green(),
410                pair.cpu.cyan(),
411                pair.memory.cyan()
412            );
413            Some((
414                machine_type_id,
415                pair.cpu.to_string(),
416                pair.memory.to_string(),
417            ))
418        }
419        Err(InquireError::OperationCanceled) => None,
420        Err(InquireError::OperationInterrupted) => None,
421        Err(_) => None,
422    }
423}
424
425/// Select GCP Cloud Run resources (two-step: CPU then memory)
426///
427/// Returns (machine_type_id, cpu, memory) e.g. ("2-cpu-2Gi-mem", "2", "2Gi")
428fn select_cloud_run_resources() -> Option<(String, String, String)> {
429    let cpu_levels = CLOUD_RUN_CPU_MEMORY;
430    if cpu_levels.is_empty() {
431        println!("\n{} No Cloud Run CPU options available.", "⚠".yellow());
432        return None;
433    }
434
435    // Step 1: Select CPU
436    let cpu_labels: Vec<String> = cpu_levels
437        .iter()
438        .map(|c| format!("{} vCPU", c.cpu))
439        .collect();
440
441    let cpu_selection = Select::new("Select CPU allocation:", cpu_labels)
442        .with_render_config(wizard_render_config())
443        .with_starting_cursor(0) // Default to 1 vCPU
444        .with_help_message("Cloud Run CPU allocation")
445        .prompt();
446
447    let selected_cpu = match cpu_selection {
448        Ok(label) => {
449            let cpu_str = label.replace(" vCPU", "");
450            cpu_levels.iter().find(|c| c.cpu == cpu_str)?
451        }
452        Err(InquireError::OperationCanceled) => return None,
453        Err(InquireError::OperationInterrupted) => return None,
454        Err(_) => return None,
455    };
456
457    // Step 2: Select memory for that CPU level
458    let memory_options: Vec<String> = selected_cpu
459        .memory_options
460        .iter()
461        .map(|m| m.to_string())
462        .collect();
463
464    let default_mem_index = memory_options
465        .iter()
466        .position(|m| m == selected_cpu.default_memory)
467        .unwrap_or(0);
468
469    let mem_selection = Select::new("Select memory allocation:", memory_options)
470        .with_render_config(wizard_render_config())
471        .with_starting_cursor(default_mem_index)
472        .with_help_message("Memory must be compatible with selected CPU")
473        .prompt();
474
475    match mem_selection {
476        Ok(selected_memory) => {
477            let machine_type_id = format!("{}-cpu-{}-mem", selected_cpu.cpu, selected_memory);
478            println!(
479                "\n{} Selected: {} vCPU / {}",
480                "✓".green(),
481                selected_cpu.cpu.cyan(),
482                selected_memory.cyan()
483            );
484            Some((
485                machine_type_id,
486                selected_cpu.cpu.to_string(),
487                selected_memory,
488            ))
489        }
490        Err(InquireError::OperationCanceled) => None,
491        Err(InquireError::OperationInterrupted) => None,
492        Err(_) => None,
493    }
494}
495
496/// Select machine type from dynamic data with pricing info
497fn select_machine_type_from_dynamic(
498    machine_types: Vec<DynamicMachineType>,
499    provider: &CloudProvider,
500    region: &str,
501) -> Option<String> {
502    if machine_types.is_empty() {
503        println!(
504            "\n{} No machine types available for this provider.",
505            "⚠".yellow()
506        );
507        return None;
508    }
509
510    // Filter to only show types available in selected region
511    let available_types: Vec<DynamicMachineType> = machine_types
512        .into_iter()
513        .filter(|m| m.available_in.is_empty() || m.available_in.contains(&region.to_string()))
514        .collect();
515
516    if available_types.is_empty() {
517        println!(
518            "\n{} No machine types available in {} region.",
519            "⚠".yellow(),
520            region
521        );
522        return None;
523    }
524
525    let default_machine = get_default_machine_type(provider);
526    let default_index = available_types
527        .iter()
528        .position(|m| m.id == default_machine)
529        .unwrap_or(0);
530
531    let options: Vec<DynamicMachineTypeOption> = available_types
532        .into_iter()
533        .map(|m| DynamicMachineTypeOption { machine: m })
534        .collect();
535
536    let selection = Select::new("Select machine type:", options)
537        .with_render_config(wizard_render_config())
538        .with_starting_cursor(default_index)
539        .with_help_message("Sorted by price · Real-time pricing shown")
540        .prompt();
541
542    match selection {
543        Ok(selected) => {
544            let price_info = if selected.machine.price_monthly > 0.0 {
545                format!(" · €{:.2}/mo", selected.machine.price_monthly)
546            } else {
547                String::new()
548            };
549            println!(
550                "\n{} Selected: {} ({} vCPU, {:.0} GB){}",
551                "✓".green(),
552                selected.machine.name.cyan(),
553                selected.machine.cores,
554                selected.machine.memory_gb,
555                price_info.green()
556            );
557            Some(selected.machine.id)
558        }
559        Err(InquireError::OperationCanceled) => None,
560        Err(InquireError::OperationInterrupted) => None,
561        Err(_) => None,
562    }
563}
564
565/// Select machine type using static data (fallback)
566fn select_machine_type_static(provider: &CloudProvider) -> Option<String> {
567    let machine_types = get_machine_types_for_provider(provider);
568    if machine_types.is_empty() {
569        println!(
570            "\n{} No machine types available for this provider.",
571            "⚠".yellow()
572        );
573        return None;
574    }
575
576    let default_machine = get_default_machine_type(provider);
577    let default_index = machine_types
578        .iter()
579        .position(|m| m.id == default_machine)
580        .unwrap_or(0);
581
582    // Convert static machine types to dynamic format for consistent display
583    let options: Vec<DynamicMachineTypeOption> = machine_types
584        .iter()
585        .map(|m| DynamicMachineTypeOption {
586            machine: DynamicMachineType {
587                id: m.id.to_string(),
588                name: m.name.to_string(),
589                cores: m.cpu.parse().unwrap_or(2),
590                memory_gb: m.memory.replace(" GB", "").parse().unwrap_or(4.0),
591                disk_gb: 40,
592                price_monthly: 0.0,
593                price_hourly: 0.0,
594                available_in: vec![],
595            },
596        })
597        .collect();
598
599    let selection = Select::new("Select machine type:", options)
600        .with_render_config(wizard_render_config())
601        .with_starting_cursor(default_index)
602        .with_help_message("Smaller = cheaper, Larger = more resources")
603        .prompt();
604
605    match selection {
606        Ok(selected) => {
607            println!(
608                "\n{} Selected: {} ({} vCPU, {:.0} GB)",
609                "✓".green(),
610                selected.machine.name.cyan(),
611                selected.machine.cores,
612                selected.machine.memory_gb
613            );
614            Some(selected.machine.id)
615        }
616        Err(InquireError::OperationCanceled) => None,
617        Err(InquireError::OperationInterrupted) => None,
618        Err(_) => None,
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn test_dynamic_region_option_display() {
628        let region = DynamicCloudRegion {
629            id: "nbg1".to_string(),
630            name: "Nuremberg".to_string(),
631            location: "Germany".to_string(),
632            network_zone: "eu-central".to_string(),
633            available_server_types: vec!["cx22".to_string(), "cx32".to_string()],
634        };
635        let option = DynamicRegionOption { region };
636        let display = format!("{}", option);
637        assert!(display.contains("nbg1"));
638        assert!(display.contains("Nuremberg"));
639        assert!(display.contains("2 types available"));
640    }
641
642    #[test]
643    fn test_dynamic_machine_type_option_display() {
644        let machine = DynamicMachineType {
645            id: "cx22".to_string(),
646            name: "CX22".to_string(),
647            cores: 2,
648            memory_gb: 4.0,
649            disk_gb: 40,
650            price_monthly: 5.95,
651            price_hourly: 0.008,
652            available_in: vec!["nbg1".to_string()],
653        };
654        let option = DynamicMachineTypeOption { machine };
655        let display = format!("{}", option);
656        assert!(display.contains("CX22"));
657        assert!(display.contains("2 vCPU"));
658        assert!(display.contains("4 GB"));
659        assert!(display.contains("€5.95/mo"));
660    }
661
662    #[test]
663    fn test_dynamic_machine_type_option_display_no_price() {
664        let machine = DynamicMachineType {
665            id: "cx22".to_string(),
666            name: "CX22".to_string(),
667            cores: 2,
668            memory_gb: 4.0,
669            disk_gb: 40,
670            price_monthly: 0.0,
671            price_hourly: 0.0,
672            available_in: vec![],
673        };
674        let option = DynamicMachineTypeOption { machine };
675        let display = format!("{}", option);
676        assert!(display.contains("CX22"));
677        assert!(!display.contains("€"));
678    }
679
680    #[test]
681    fn test_infrastructure_selection_result_variants() {
682        let selected = InfrastructureSelectionResult::Selected {
683            region: "nbg1".to_string(),
684            machine_type: "cx22".to_string(),
685            cpu: None,
686            memory: None,
687        };
688        matches!(selected, InfrastructureSelectionResult::Selected { .. });
689
690        let selected_with_resources = InfrastructureSelectionResult::Selected {
691            region: "eastus".to_string(),
692            machine_type: "0.5-cpu-1.0Gi-mem".to_string(),
693            cpu: Some("0.5".to_string()),
694            memory: Some("1.0Gi".to_string()),
695        };
696        matches!(
697            selected_with_resources,
698            InfrastructureSelectionResult::Selected { .. }
699        );
700
701        let _ = InfrastructureSelectionResult::Back;
702        let _ = InfrastructureSelectionResult::Cancelled;
703    }
704}