Skip to main content

syncable_cli/wizard/
environment_creation.rs

1//! Environment creation wizard for deployment targets
2//!
3//! Interactive wizard that guides users through creating a new environment
4//! with target type selection (Kubernetes or Cloud Runner).
5
6use crate::platform::api::client::PlatformApiClient;
7use crate::platform::api::types::{CloudProvider, ClusterSummary, Environment};
8use crate::wizard::cloud_provider_data::{get_default_region, get_regions_for_provider};
9use crate::wizard::provider_selection::get_provider_deployment_statuses;
10use crate::wizard::render::{display_step_header, wizard_render_config};
11use colored::Colorize;
12use inquire::{InquireError, MultiSelect, Select, Text};
13use std::collections::HashMap;
14
15/// Environment type for the API
16/// "cluster" = Kubernetes cluster
17/// "cloud" = Cloud Runner (serverless)
18#[derive(Debug, Clone, PartialEq, Eq)]
19enum EnvironmentType {
20    Cluster,
21    Cloud,
22}
23
24impl EnvironmentType {
25    fn as_str(&self) -> &'static str {
26        match self {
27            EnvironmentType::Cluster => "cluster",
28            EnvironmentType::Cloud => "cloud",
29        }
30    }
31
32    fn display_name(&self) -> &'static str {
33        match self {
34            EnvironmentType::Cluster => "Kubernetes",
35            EnvironmentType::Cloud => "Cloud Runner",
36        }
37    }
38}
39
40/// Result of environment creation wizard
41#[derive(Debug)]
42pub enum EnvironmentCreationResult {
43    /// Environment created successfully
44    Created(Environment),
45    /// User cancelled the wizard
46    Cancelled,
47    /// An error occurred
48    Error(String),
49}
50
51/// Run the environment creation wizard
52///
53/// Guides user through:
54/// 1. Choosing environment name
55/// 2. Selecting target type (Kubernetes or Cloud Runner)
56/// 3. If Kubernetes: selecting a cluster
57pub async fn create_environment_wizard(
58    client: &PlatformApiClient,
59    project_id: &str,
60) -> EnvironmentCreationResult {
61    display_step_header(
62        1,
63        "Create Environment",
64        "Set up a new deployment environment for your project.",
65    );
66
67    // Step 1: Get environment name
68    let name = match Text::new("Environment name:")
69        .with_placeholder("e.g., production, staging, development")
70        .with_help_message("Choose a descriptive name for this environment")
71        .prompt()
72    {
73        Ok(name) => {
74            if name.trim().is_empty() {
75                println!("\n{}", "Environment name cannot be empty.".red());
76                return EnvironmentCreationResult::Cancelled;
77            }
78            name.trim().to_string()
79        }
80        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
81            println!("\n{}", "Wizard cancelled.".dimmed());
82            return EnvironmentCreationResult::Cancelled;
83        }
84        Err(e) => {
85            return EnvironmentCreationResult::Error(format!("Input error: {}", e));
86        }
87    };
88
89    // Step 2: Select target type
90    display_step_header(
91        2,
92        "Select Target Type",
93        "Choose how this environment will deploy services.",
94    );
95
96    let target_options = vec![
97        format!(
98            "{}  {}",
99            "Cloud Runner".cyan(),
100            "Fully managed, auto-scaling containers".dimmed()
101        ),
102        format!(
103            "{}  {}",
104            "Kubernetes".cyan(),
105            "Deploy to your own K8s cluster".dimmed()
106        ),
107    ];
108
109    let target_selection = Select::new("Select target type:", target_options)
110        .with_render_config(wizard_render_config())
111        .with_help_message("Cloud Runner: serverless, Kubernetes: full control")
112        .prompt();
113
114    let env_type = match target_selection {
115        Ok(answer) => {
116            if answer.contains("Cloud Runner") {
117                EnvironmentType::Cloud
118            } else {
119                EnvironmentType::Cluster
120            }
121        }
122        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
123            println!("\n{}", "Wizard cancelled.".dimmed());
124            return EnvironmentCreationResult::Cancelled;
125        }
126        Err(e) => {
127            return EnvironmentCreationResult::Error(format!("Selection error: {}", e));
128        }
129    };
130
131    println!(
132        "\n{} Target: {}",
133        "✓".green(),
134        env_type.display_name().bold()
135    );
136
137    // Step 3: If Kubernetes (cluster), select cluster
138    let cluster_id = if env_type == EnvironmentType::Cluster {
139        match select_cluster_for_env(client, project_id).await {
140            ClusterSelectionResult::Selected(id) => Some(id),
141            ClusterSelectionResult::NoClusters => {
142                println!(
143                    "\n{}",
144                    "No Kubernetes clusters available. Please provision a cluster first.".red()
145                );
146                return EnvironmentCreationResult::Cancelled;
147            }
148            ClusterSelectionResult::Cancelled => {
149                return EnvironmentCreationResult::Cancelled;
150            }
151            ClusterSelectionResult::Error(e) => {
152                return EnvironmentCreationResult::Error(e);
153            }
154        }
155    } else {
156        None
157    };
158
159    // Step 4 (Cloud Runner only): Optional provider region defaults
160    let provider_regions = if env_type == EnvironmentType::Cloud {
161        select_provider_regions()
162    } else {
163        None
164    };
165
166    // Create the environment via API
167    println!("\n{}", "Creating environment...".dimmed());
168
169    match client
170        .create_environment(
171            project_id,
172            &name,
173            env_type.as_str(),
174            cluster_id.as_deref(),
175            provider_regions.as_ref(),
176        )
177        .await
178    {
179        Ok(env) => {
180            println!(
181                "\n{} Environment {} created successfully!",
182                "✓".green().bold(),
183                env.name.bold()
184            );
185            println!("  ID: {}", env.id.dimmed());
186            println!("  Type: {}", env.environment_type);
187            if let Some(cid) = &env.cluster_id {
188                println!("  Cluster: {}", cid);
189            }
190            EnvironmentCreationResult::Created(env)
191        }
192        Err(e) => EnvironmentCreationResult::Error(format!("Failed to create environment: {}", e)),
193    }
194}
195
196/// Result of cluster selection
197enum ClusterSelectionResult {
198    Selected(String),
199    NoClusters,
200    Cancelled,
201    Error(String),
202}
203
204/// Select a Kubernetes cluster from available clusters
205async fn select_cluster_for_env(
206    client: &PlatformApiClient,
207    project_id: &str,
208) -> ClusterSelectionResult {
209    display_step_header(
210        3,
211        "Select Cluster",
212        "Choose a Kubernetes cluster for this environment.",
213    );
214
215    // Get available clusters
216    let clusters: Vec<ClusterSummary> =
217        match get_available_clusters_for_project(client, project_id).await {
218            Ok(c) => c,
219            Err(e) => return ClusterSelectionResult::Error(e),
220        };
221
222    if clusters.is_empty() {
223        return ClusterSelectionResult::NoClusters;
224    }
225
226    // Build options
227    let options: Vec<String> = clusters
228        .iter()
229        .map(|c| {
230            let health = if c.is_healthy {
231                "healthy".green()
232            } else {
233                "unhealthy".red()
234            };
235            format!("{} ({}) - {}", c.name.bold(), c.region.dimmed(), health)
236        })
237        .collect();
238
239    let selection = Select::new("Select cluster:", options.clone())
240        .with_render_config(wizard_render_config())
241        .with_help_message("Choose the cluster to deploy to")
242        .prompt();
243
244    match selection {
245        Ok(answer) => {
246            // Find the selected cluster by matching the name at the start
247            let selected_name = answer.split(" (").next().unwrap_or("");
248            if let Some(cluster) = clusters.iter().find(|c| c.name == selected_name) {
249                println!("\n{} Selected: {}", "✓".green(), cluster.name.bold());
250                ClusterSelectionResult::Selected(cluster.id.clone())
251            } else {
252                ClusterSelectionResult::Error("Failed to match selected cluster".to_string())
253            }
254        }
255        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
256            println!("\n{}", "Wizard cancelled.".dimmed());
257            ClusterSelectionResult::Cancelled
258        }
259        Err(e) => ClusterSelectionResult::Error(format!("Selection error: {}", e)),
260    }
261}
262
263/// Get available clusters from all connected providers for a project
264async fn get_available_clusters_for_project(
265    client: &PlatformApiClient,
266    project_id: &str,
267) -> Result<Vec<ClusterSummary>, String> {
268    // Get provider deployment statuses which include cluster info
269    let statuses = get_provider_deployment_statuses(client, project_id)
270        .await
271        .map_err(|e| format!("Failed to get provider statuses: {}", e))?;
272
273    // Collect all clusters from connected providers
274    let mut all_clusters = Vec::new();
275    for status in statuses {
276        if status.is_connected {
277            all_clusters.extend(status.clusters);
278        }
279    }
280
281    Ok(all_clusters)
282}
283
284/// Interactive provider region selection for Cloud Runner environments
285///
286/// Asks user which providers they want to set default regions for,
287/// then presents region list per provider.
288fn select_provider_regions() -> Option<HashMap<String, String>> {
289    display_step_header(
290        4,
291        "Provider Regions (Optional)",
292        "Set default regions for each cloud provider. Press Esc to skip.",
293    );
294
295    let available_providers = [
296        ("GCP", CloudProvider::Gcp),
297        ("Hetzner", CloudProvider::Hetzner),
298        ("Azure", CloudProvider::Azure),
299    ];
300
301    let provider_labels: Vec<String> = available_providers
302        .iter()
303        .map(|(label, _)| label.to_string())
304        .collect();
305
306    let selected = match MultiSelect::new(
307        "Select providers to set default regions for:",
308        provider_labels,
309    )
310    .with_render_config(wizard_render_config())
311    .with_help_message("Space to select, Enter to confirm, Esc to skip")
312    .prompt()
313    {
314        Ok(s) if !s.is_empty() => s,
315        _ => return None,
316    };
317
318    let mut regions = HashMap::new();
319
320    for provider_label in &selected {
321        let (_, provider) = available_providers
322            .iter()
323            .find(|(label, _)| label == provider_label)
324            .unwrap();
325
326        let provider_regions = get_regions_for_provider(provider);
327        let default_region = get_default_region(provider);
328
329        if provider_regions.is_empty() {
330            // For providers with dynamic regions (Hetzner), use a text input
331            let region = match Text::new(&format!("{} region:", provider_label))
332                .with_default(default_region)
333                .with_render_config(wizard_render_config())
334                .prompt()
335            {
336                Ok(r) => r,
337                Err(_) => continue,
338            };
339            regions.insert(provider.as_str().to_string(), region);
340        } else {
341            let region_labels: Vec<String> = provider_regions
342                .iter()
343                .map(|r| format!("{} - {} ({})", r.id, r.name, r.location))
344                .collect();
345
346            let default_idx = provider_regions
347                .iter()
348                .position(|r| r.id == default_region)
349                .unwrap_or(0);
350
351            let region = match Select::new(&format!("{} region:", provider_label), region_labels)
352                .with_render_config(wizard_render_config())
353                .with_starting_cursor(default_idx)
354                .prompt()
355            {
356                Ok(r) => {
357                    // Extract region ID from the display string (before first " - ")
358                    r.split(" - ").next().unwrap_or(default_region).to_string()
359                }
360                Err(_) => continue,
361            };
362            regions.insert(provider.as_str().to_string(), region);
363        }
364    }
365
366    if regions.is_empty() {
367        None
368    } else {
369        println!("\n{} Provider regions configured:", "✓".green());
370        for (provider, region) in &regions {
371            println!("  {}: {}", provider, region.bold());
372        }
373        Some(regions)
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_environment_creation_result_variants() {
383        let created = EnvironmentCreationResult::Created(Environment {
384            id: "env-1".to_string(),
385            name: "test".to_string(),
386            project_id: "proj-1".to_string(),
387            environment_type: "cloud".to_string(),
388            cluster_id: None,
389            namespace: None,
390            description: None,
391            is_active: true,
392            created_at: None,
393            updated_at: None,
394            provider_regions: None,
395        });
396        assert!(matches!(created, EnvironmentCreationResult::Created(_)));
397
398        let cancelled = EnvironmentCreationResult::Cancelled;
399        assert!(matches!(cancelled, EnvironmentCreationResult::Cancelled));
400
401        let error = EnvironmentCreationResult::Error("test error".to_string());
402        assert!(matches!(error, EnvironmentCreationResult::Error(_)));
403    }
404
405    #[test]
406    fn test_environment_type_as_str() {
407        assert_eq!(EnvironmentType::Cluster.as_str(), "cluster");
408        assert_eq!(EnvironmentType::Cloud.as_str(), "cloud");
409    }
410
411    #[test]
412    fn test_environment_type_display_name() {
413        assert_eq!(EnvironmentType::Cluster.display_name(), "Kubernetes");
414        assert_eq!(EnvironmentType::Cloud.display_name(), "Cloud Runner");
415    }
416}