Skip to main content

syncable_cli/wizard/
orchestrator.rs

1//! Wizard orchestration - ties all steps together
2
3use crate::analyzer::discover_dockerfiles_for_deployment;
4use crate::platform::api::PlatformApiClient;
5use crate::platform::api::types::{
6    CloudProvider, CloudRunnerConfigInput, ConnectRepositoryRequest, CreateDeploymentConfigRequest,
7    DeploymentTarget, ProjectRepository, TriggerDeploymentRequest, WizardDeploymentConfig,
8    build_cloud_runner_config_v2,
9};
10use crate::wizard::{
11    ClusterSelectionResult, ConfigFormResult, DockerfileSelectionResult,
12    InfrastructureSelectionResult, ProviderSelectionResult, RegistryProvisioningResult,
13    RegistrySelectionResult, RepositorySelectionResult, TargetSelectionResult, collect_config,
14    collect_env_vars, collect_service_endpoint_env_vars, filter_endpoints_for_provider,
15    get_available_endpoints, get_provider_deployment_statuses, provision_registry, select_cluster,
16    select_dockerfile, select_infrastructure, select_provider, select_registry, select_repository,
17    select_target,
18};
19use colored::Colorize;
20use inquire::{Confirm, InquireError};
21use std::path::Path;
22
23/// Deployment result with task ID for tracking
24#[derive(Debug, Clone)]
25pub struct DeploymentInfo {
26    /// The deployment config ID
27    pub config_id: String,
28    /// Backstage task ID for tracking progress
29    pub task_id: String,
30    /// Service name that was deployed
31    pub service_name: String,
32}
33
34/// Result of running the wizard
35#[derive(Debug)]
36pub enum WizardResult {
37    /// Wizard completed and deployment triggered
38    Deployed(DeploymentInfo),
39    /// Wizard completed successfully (config created but not deployed)
40    Success(WizardDeploymentConfig),
41    /// User wants to start agent to create Dockerfile
42    StartAgent(String),
43    /// User cancelled the wizard
44    Cancelled,
45    /// An error occurred
46    Error(String),
47}
48
49/// Run the deployment wizard
50pub async fn run_wizard(
51    client: &PlatformApiClient,
52    project_id: &str,
53    environment_id: &str,
54    project_path: &Path,
55) -> WizardResult {
56    println!();
57    println!(
58        "{}",
59        "═══════════════════════════════════════════════════════════════".bright_cyan()
60    );
61    println!(
62        "{}",
63        "                    Deployment Wizard                          "
64            .bright_cyan()
65            .bold()
66    );
67    println!(
68        "{}",
69        "═══════════════════════════════════════════════════════════════".bright_cyan()
70    );
71
72    // Step 0: Repository selection (auto-detect or ask)
73    let repository = match select_repository(client, project_id, project_path).await {
74        RepositorySelectionResult::Selected(repo) => repo,
75        RepositorySelectionResult::ConnectNew(available) => {
76            // Connect the repository first
77            println!("{} Connecting repository...", "→".cyan());
78
79            // Extract owner from full_name if not provided
80            let owner = available.owner.clone().unwrap_or_else(|| {
81                available
82                    .full_name
83                    .split('/')
84                    .next()
85                    .unwrap_or("")
86                    .to_string()
87            });
88
89            let connect_request = ConnectRepositoryRequest {
90                project_id: project_id.to_string(),
91                repository_id: available.id,
92                repository_name: available.name.clone(),
93                repository_full_name: available.full_name.clone(),
94                repository_owner: owner.clone(),
95                repository_private: available.private,
96                default_branch: available
97                    .default_branch
98                    .clone()
99                    .or(Some("main".to_string())),
100                connection_type: Some("app".to_string()),
101                github_installation_id: available.installation_id,
102                repository_type: Some("application".to_string()),
103            };
104            match client.connect_repository(&connect_request).await {
105                Ok(response) => {
106                    println!("{} Repository connected!", "✓".green());
107                    // Construct ProjectRepository from the response and available info
108                    ProjectRepository {
109                        id: response.id,
110                        project_id: response.project_id,
111                        repository_id: response.repository_id,
112                        repository_name: available.name,
113                        repository_full_name: response.repository_full_name,
114                        repository_owner: owner,
115                        repository_private: available.private,
116                        default_branch: available.default_branch,
117                        is_active: response.is_active,
118                        connection_type: Some("app".to_string()),
119                        repository_type: Some("application".to_string()),
120                        is_primary_git_ops: None,
121                        github_installation_id: available.installation_id,
122                        user_id: None,
123                        created_at: None,
124                        updated_at: None,
125                    }
126                }
127                Err(e) => {
128                    return WizardResult::Error(format!("Failed to connect repository: {}", e));
129                }
130            }
131        }
132        RepositorySelectionResult::NeedsGitHubApp {
133            installation_url,
134            org_name,
135        } => {
136            println!(
137                "\n{} Please install the Syncable GitHub App for organization '{}' first.",
138                "⚠".yellow(),
139                org_name.cyan()
140            );
141            println!("Installation URL: {}", installation_url);
142            return WizardResult::Cancelled;
143        }
144        RepositorySelectionResult::NoInstallations { installation_url } => {
145            println!(
146                "\n{} No GitHub App installations found. Please install the app first.",
147                "⚠".yellow()
148            );
149            println!("Installation URL: {}", installation_url);
150            return WizardResult::Cancelled;
151        }
152        RepositorySelectionResult::NoRepositories => {
153            return WizardResult::Error(
154                "No repositories available. Please install the Syncable GitHub App first."
155                    .to_string(),
156            );
157        }
158        RepositorySelectionResult::Cancelled => return WizardResult::Cancelled,
159        RepositorySelectionResult::Error(e) => return WizardResult::Error(e),
160    };
161
162    // Step 1: Provider selection
163    let provider_statuses = match get_provider_deployment_statuses(client, project_id).await {
164        Ok(s) => s,
165        Err(e) => {
166            return WizardResult::Error(format!("Failed to fetch provider status: {}", e));
167        }
168    };
169
170    let provider = match select_provider(&provider_statuses) {
171        ProviderSelectionResult::Selected(p) => p,
172        ProviderSelectionResult::Cancelled => return WizardResult::Cancelled,
173    };
174
175    // Get status for selected provider
176    let provider_status = provider_statuses
177        .iter()
178        .find(|s| s.provider == provider)
179        .expect("Selected provider must exist in statuses");
180
181    // Step 2: Target selection (with back navigation)
182    let target = match select_target(provider_status) {
183        TargetSelectionResult::Selected(t) => t,
184        TargetSelectionResult::Back => {
185            // Restart from provider selection
186            return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
187        }
188        TargetSelectionResult::Cancelled => return WizardResult::Cancelled,
189    };
190
191    // Step 3: Infrastructure selection for Cloud Runner OR Cluster selection for K8s
192    let (cluster_id, region, machine_type, cpu, memory) = if target == DeploymentTarget::CloudRunner
193    {
194        // Cloud Runner: Select region and machine type
195        // Pass client and project_id for dynamic Hetzner availability fetching
196        match select_infrastructure(&provider, 3, Some(client), Some(project_id)).await {
197            InfrastructureSelectionResult::Selected {
198                region,
199                machine_type,
200                cpu,
201                memory,
202            } => (None, Some(region), Some(machine_type), cpu, memory),
203            InfrastructureSelectionResult::Back => {
204                // Go back (restart wizard for simplicity)
205                return Box::pin(run_wizard(client, project_id, environment_id, project_path))
206                    .await;
207            }
208            InfrastructureSelectionResult::Cancelled => return WizardResult::Cancelled,
209        }
210    } else {
211        // Kubernetes: Select cluster
212        match select_cluster(&provider_status.clusters) {
213            ClusterSelectionResult::Selected(c) => (Some(c.id), None, None, None, None),
214            ClusterSelectionResult::Back => {
215                // Go back to target selection (restart wizard for simplicity)
216                return Box::pin(run_wizard(client, project_id, environment_id, project_path))
217                    .await;
218            }
219            ClusterSelectionResult::Cancelled => return WizardResult::Cancelled,
220        }
221    };
222
223    // Step 4: Registry selection
224    let registry_id = loop {
225        match select_registry(&provider_status.registries) {
226            RegistrySelectionResult::Selected(r) => break Some(r.id),
227            RegistrySelectionResult::ProvisionNew => {
228                // Get cluster info for provisioning
229                let (prov_cluster_id, prov_cluster_name, prov_region) =
230                    if let Some(ref cid) = cluster_id {
231                        // Use selected cluster
232                        let cluster = provider_status
233                            .clusters
234                            .iter()
235                            .find(|c| c.id == *cid)
236                            .expect("Selected cluster must exist");
237                        (cid.clone(), cluster.name.clone(), cluster.region.clone())
238                    } else {
239                        // For Cloud Runner, use first available cluster for registry provisioning
240                        if let Some(cluster) = provider_status.clusters.first() {
241                            (
242                                cluster.id.clone(),
243                                cluster.name.clone(),
244                                cluster.region.clone(),
245                            )
246                        } else {
247                            return WizardResult::Error(
248                                "No cluster available for registry provisioning".to_string(),
249                            );
250                        }
251                    };
252
253                // Provision the registry
254                match provision_registry(
255                    client,
256                    project_id,
257                    &prov_cluster_id,
258                    &prov_cluster_name,
259                    provider.clone(),
260                    &prov_region,
261                    None, // GCP project ID resolved by backend
262                )
263                .await
264                {
265                    RegistryProvisioningResult::Success(registry) => {
266                        break Some(registry.id);
267                    }
268                    RegistryProvisioningResult::Cancelled => {
269                        return WizardResult::Cancelled;
270                    }
271                    RegistryProvisioningResult::Error(e) => {
272                        eprintln!("{} {}", "Registry provisioning failed:".red(), e);
273                        // Allow retry - loop back to selection
274                        continue;
275                    }
276                }
277            }
278            RegistrySelectionResult::Back => {
279                // Go back (restart wizard for simplicity)
280                return Box::pin(run_wizard(client, project_id, environment_id, project_path))
281                    .await;
282            }
283            RegistrySelectionResult::Cancelled => return WizardResult::Cancelled,
284        }
285    };
286
287    // Step 5: Dockerfile selection
288    let dockerfiles = discover_dockerfiles_for_deployment(project_path).unwrap_or_default();
289    let (selected_dockerfile, build_context) = match select_dockerfile(&dockerfiles, project_path) {
290        DockerfileSelectionResult::Selected {
291            dockerfile,
292            build_context,
293        } => (dockerfile, build_context),
294        DockerfileSelectionResult::StartAgent(prompt) => {
295            return WizardResult::StartAgent(prompt);
296        }
297        DockerfileSelectionResult::Back => {
298            // Go back (restart wizard for simplicity)
299            return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
300        }
301        DockerfileSelectionResult::Cancelled => return WizardResult::Cancelled,
302    };
303
304    // Construct dockerfile path from build_context and filename
305    // This is more robust than strip_prefix which can have path matching edge cases
306    // Docker's -f flag expects path relative to repo root (where docker is invoked)
307    let dockerfile_name = selected_dockerfile
308        .path
309        .file_name()
310        .map(|n| n.to_string_lossy().to_string())
311        .unwrap_or_else(|| "Dockerfile".to_string());
312
313    let dockerfile_path = if build_context == "." || build_context.is_empty() {
314        dockerfile_name.clone() // Dockerfile at repo root
315    } else {
316        format!("{}/{}", build_context, dockerfile_name) // e.g., "services/foo/Dockerfile"
317    };
318
319    log::debug!(
320        "Dockerfile path: {}, build_context: {}, dockerfile_name: {}",
321        dockerfile_path,
322        build_context,
323        dockerfile_name
324    );
325
326    // Step 6: Config form
327    let config = match collect_config(
328        provider.clone(),
329        target.clone(),
330        cluster_id.clone(),
331        registry_id.clone(),
332        environment_id,
333        &dockerfile_path,
334        &build_context,
335        &selected_dockerfile,
336        region.clone(),
337        machine_type.clone(),
338        cpu.clone(),
339        memory.clone(),
340        6,
341    ) {
342        ConfigFormResult::Completed(config) => config,
343        ConfigFormResult::Back => {
344            // Restart wizard
345            return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
346        }
347        ConfigFormResult::Cancelled => return WizardResult::Cancelled,
348    };
349
350    // Step 6.5: Environment variables (optional)
351    let secrets = collect_env_vars(project_path);
352    let mut config = config;
353    config.secrets = secrets;
354
355    // Step 6.6: Offer deployed service endpoints as env vars
356    let available_endpoints = match client.list_deployments(project_id, Some(50)).await {
357        Ok(paginated) => {
358            log::debug!(
359                "Fetched {} deployment record(s) for endpoint discovery",
360                paginated.data.len()
361            );
362            get_available_endpoints(&paginated.data)
363        }
364        Err(e) => {
365            log::debug!("Could not fetch deployments for endpoint injection: {}", e);
366            Vec::new()
367        }
368    };
369
370    // Exclude the service being deployed
371    let service_being_deployed = config.service_name.as_deref().unwrap_or("");
372    let available_endpoints: Vec<_> = available_endpoints
373        .into_iter()
374        .filter(|ep| ep.service_name != service_being_deployed)
375        .collect();
376    // Only show private endpoints from the same cloud provider
377    let available_endpoints = filter_endpoints_for_provider(available_endpoints, provider.as_str());
378
379    if !available_endpoints.is_empty() {
380        let endpoint_vars = collect_service_endpoint_env_vars(&available_endpoints);
381        for ep_var in endpoint_vars {
382            // Don't override vars already set by .env or manual entry
383            if !config.secrets.iter().any(|s| s.key == ep_var.key) {
384                config.secrets.push(ep_var);
385            }
386        }
387    }
388
389    // Show summary
390    display_summary(&config);
391
392    // Step 7: Confirm and deploy
393    println!();
394    let should_deploy = match Confirm::new("Deploy now?")
395        .with_default(true)
396        .with_help_message("This will create the deployment configuration and start the deployment")
397        .prompt()
398    {
399        Ok(v) => v,
400        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
401            return WizardResult::Cancelled;
402        }
403        Err(_) => return WizardResult::Cancelled,
404    };
405
406    if !should_deploy {
407        println!("{}", "Deployment skipped. Configuration saved.".dimmed());
408        return WizardResult::Success(config);
409    }
410
411    // Create deployment configuration
412    println!();
413    println!("{}", "Creating deployment configuration...".dimmed());
414
415    let deploy_request = CreateDeploymentConfigRequest {
416        project_id: project_id.to_string(),
417        service_name: config.service_name.clone().unwrap_or_default(),
418        repository_id: repository.repository_id,
419        repository_full_name: repository.repository_full_name.clone(),
420        // Send both field name variants for backend compatibility
421        dockerfile_path: config.dockerfile_path.clone(),
422        dockerfile: config.dockerfile_path.clone(), // Alias
423        build_context: config.build_context.clone(),
424        context: config.build_context.clone(), // Alias
425        port: config.port.unwrap_or(8080) as i32,
426        branch: config.branch.clone().unwrap_or_else(|| "main".to_string()),
427        target_type: target.as_str().to_string(),
428        cloud_provider: provider.as_str().to_string(),
429        environment_id: environment_id.to_string(),
430        cluster_id: cluster_id.clone(),
431        registry_id: registry_id.clone(),
432        auto_deploy_enabled: config.auto_deploy,
433        is_public: Some(config.is_public),
434        secrets: if config.secrets.is_empty() {
435            None
436        } else {
437            Some(config.secrets.clone())
438        },
439        cloud_runner_config: if target == DeploymentTarget::CloudRunner {
440            // Fetch provider credential for GCP project ID / Azure subscription ID
441            let (gcp_project_id, subscription_id) = match provider {
442                CloudProvider::Gcp | CloudProvider::Azure => {
443                    match client
444                        .check_provider_connection(&provider, project_id)
445                        .await
446                    {
447                        Ok(Some(cred)) => match provider {
448                            CloudProvider::Gcp => (cred.provider_account_id, None),
449                            CloudProvider::Azure => (None, cred.provider_account_id),
450                            _ => (None, None),
451                        },
452                        _ => (None, None),
453                    }
454                }
455                _ => (None, None),
456            };
457
458            let config_input = CloudRunnerConfigInput {
459                provider: Some(provider.clone()),
460                region: region.clone(),
461                server_type: if provider == CloudProvider::Hetzner {
462                    machine_type.clone()
463                } else {
464                    None
465                },
466                gcp_project_id,
467                cpu: config.cpu.clone(),
468                memory: config.memory.clone(),
469                allow_unauthenticated: Some(config.is_public),
470                subscription_id,
471                is_public: Some(config.is_public),
472                health_check_path: config.health_check_path.clone(),
473                ..Default::default()
474            };
475            Some(build_cloud_runner_config_v2(&config_input))
476        } else {
477            None
478        },
479    };
480
481    // Debug output - show key fields being sent
482    log::debug!("CreateDeploymentConfigRequest fields:");
483    log::debug!("  projectId: {}", deploy_request.project_id);
484    log::debug!("  serviceName: {}", deploy_request.service_name);
485    log::debug!("  environmentId: {}", deploy_request.environment_id);
486    log::debug!("  repositoryId: {}", deploy_request.repository_id);
487    log::debug!(
488        "  repositoryFullName: {}",
489        deploy_request.repository_full_name
490    );
491    log::debug!("  dockerfilePath: {:?}", deploy_request.dockerfile_path);
492    log::debug!("  buildContext: {:?}", deploy_request.build_context);
493    log::debug!("  targetType: {}", deploy_request.target_type);
494    log::debug!("  cloudProvider: {}", deploy_request.cloud_provider);
495    log::debug!("  port: {}", deploy_request.port);
496    log::debug!("  branch: {}", deploy_request.branch);
497    if let Some(ref config) = deploy_request.cloud_runner_config {
498        log::debug!("  cloudRunnerConfig: {}", config);
499    }
500
501    let deployment_config = match client.create_deployment_config(&deploy_request).await {
502        Ok(config) => config,
503        Err(e) => {
504            return WizardResult::Error(format!("Failed to create deployment config: {}", e));
505        }
506    };
507
508    println!(
509        "{} Deployment configuration created: {}",
510        "✓".green(),
511        deployment_config.id.dimmed()
512    );
513    log::debug!("  Config ID: {}", deployment_config.id);
514    log::debug!("  Service Name: {}", deployment_config.service_name);
515    log::debug!("  Environment ID: {}", deployment_config.environment_id);
516
517    // Trigger deployment
518    println!("{}", "Triggering deployment...".dimmed());
519
520    let trigger_request = TriggerDeploymentRequest {
521        project_id: project_id.to_string(),
522        config_id: deployment_config.id.clone(),
523        commit_sha: None, // Use latest from branch
524    };
525
526    // Debug: Show trigger request
527    log::debug!(
528        "Trigger request: projectId={}, configId={}",
529        trigger_request.project_id,
530        trigger_request.config_id
531    );
532
533    match client.trigger_deployment(&trigger_request).await {
534        Ok(response) => {
535            log::info!(
536                "Deployment triggered successfully: taskId={}, status={}, message={}",
537                response.backstage_task_id,
538                response.status,
539                response.message
540            );
541
542            println!();
543            println!(
544                "{}",
545                "═══════════════════════════════════════════════════════════════".bright_green()
546            );
547            println!("{}  Deployment started!", "✓".bright_green().bold());
548            println!(
549                "{}",
550                "═══════════════════════════════════════════════════════════════".bright_green()
551            );
552            println!();
553            println!(
554                "  Service:  {}",
555                config.service_name.as_deref().unwrap_or("").cyan()
556            );
557            println!("  Task ID:  {}", response.backstage_task_id.dimmed());
558            println!("  Status:   {}", response.status.yellow());
559            println!();
560            println!(
561                "{}",
562                "Track progress: sync-ctl deploy status <task-id>".dimmed()
563            );
564            println!();
565
566            WizardResult::Deployed(DeploymentInfo {
567                config_id: deployment_config.id,
568                task_id: response.backstage_task_id,
569                service_name: config.service_name.unwrap_or_default(),
570            })
571        }
572        Err(e) => {
573            log::error!("Failed to trigger deployment: {}", e);
574            eprintln!(
575                "\n{} {} {}\n",
576                "✗".red().bold(),
577                "Deployment trigger failed:".red().bold(),
578                e
579            );
580            WizardResult::Error(format!("Failed to trigger deployment: {}", e))
581        }
582    }
583}
584
585/// Display a summary of the deployment configuration
586fn display_summary(config: &WizardDeploymentConfig) {
587    println!();
588    println!(
589        "{}",
590        "─────────────────────────────────────────────────────────────────".dimmed()
591    );
592    println!("{}", " Deployment Summary ".bright_green().bold());
593    println!(
594        "{}",
595        "─────────────────────────────────────────────────────────────────".dimmed()
596    );
597
598    if let Some(ref name) = config.service_name {
599        println!("  Service:      {}", name.cyan());
600    }
601    if let Some(ref target) = config.target {
602        println!("  Target:       {}", target.display_name());
603    }
604    if let Some(ref provider) = config.provider {
605        println!("  Provider:     {:?}", provider);
606    }
607    if let Some(ref region) = config.region {
608        println!("  Region:       {}", region.cyan());
609    }
610    if let Some(ref cpu) = config.cpu {
611        if let Some(ref mem) = config.memory {
612            println!("  Resources:    {} vCPU / {}", cpu.cyan(), mem.cyan());
613        }
614    } else if let Some(ref machine) = config.machine_type {
615        println!("  Machine:      {}", machine.cyan());
616    }
617    if let Some(ref branch) = config.branch {
618        println!("  Branch:       {}", branch);
619    }
620    if let Some(port) = config.port {
621        println!("  Port:         {}", port);
622    }
623    println!(
624        "  Public:       {}",
625        if config.is_public {
626            "Yes".green()
627        } else {
628            "No".yellow()
629        }
630    );
631    if let Some(ref health) = config.health_check_path {
632        println!("  Health check: {}", health.cyan());
633    }
634    println!(
635        "  Auto-deploy:  {}",
636        if config.auto_deploy {
637            "Yes".green()
638        } else {
639            "No".yellow()
640        }
641    );
642    if !config.secrets.is_empty() {
643        let secret_count = config.secrets.iter().filter(|s| s.is_secret).count();
644        let env_count = config.secrets.len() - secret_count;
645        println!(
646            "  Env vars:     {} env, {} secret",
647            env_count.to_string().cyan(),
648            secret_count.to_string().yellow()
649        );
650    }
651
652    println!(
653        "{}",
654        "─────────────────────────────────────────────────────────────────".dimmed()
655    );
656    println!();
657}