syncable_cli/wizard/
orchestrator.rs

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