1use 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#[derive(Debug, Clone)]
22pub struct DeploymentInfo {
23 pub config_id: String,
25 pub task_id: String,
27 pub service_name: String,
29}
30
31#[derive(Debug)]
33pub enum WizardResult {
34 Deployed(DeploymentInfo),
36 Success(WizardDeploymentConfig),
38 StartAgent(String),
40 Cancelled,
42 Error(String),
44}
45
46pub 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 let repository = match select_repository(client, project_id, project_path).await {
71 RepositorySelectionResult::Selected(repo) => repo,
72 RepositorySelectionResult::ConnectNew(available) => {
73 println!("{} Connecting repository...", "→".cyan());
75
76 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 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 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 let provider_status = provider_statuses
164 .iter()
165 .find(|s| s.provider == provider)
166 .expect("Selected provider must exist in statuses");
167
168 let target = match select_target(provider_status) {
170 TargetSelectionResult::Selected(t) => t,
171 TargetSelectionResult::Back => {
172 return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
174 }
175 TargetSelectionResult::Cancelled => return WizardResult::Cancelled,
176 };
177
178 let (cluster_id, region, machine_type) = if target == DeploymentTarget::CloudRunner {
180 match select_infrastructure(&provider, 3) {
182 InfrastructureSelectionResult::Selected {
183 region,
184 machine_type,
185 } => (None, Some(region), Some(machine_type)),
186 InfrastructureSelectionResult::Back => {
187 return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
189 }
190 InfrastructureSelectionResult::Cancelled => return WizardResult::Cancelled,
191 }
192 } else {
193 match select_cluster(&provider_status.clusters) {
195 ClusterSelectionResult::Selected(c) => (Some(c.id), None, None),
196 ClusterSelectionResult::Back => {
197 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 let registry_id = loop {
207 match select_registry(&provider_status.registries) {
208 RegistrySelectionResult::Selected(r) => break Some(r.id),
209 RegistrySelectionResult::ProvisionNew => {
210 let (prov_cluster_id, prov_cluster_name, prov_region) =
212 if let Some(ref cid) = cluster_id {
213 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 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 match provision_registry(
237 client,
238 project_id,
239 &prov_cluster_id,
240 &prov_cluster_name,
241 provider.clone(),
242 &prov_region,
243 None, )
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 continue;
257 }
258 }
259 }
260 RegistrySelectionResult::Back => {
261 return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
263 }
264 RegistrySelectionResult::Cancelled => return WizardResult::Cancelled,
265 }
266 };
267
268 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 return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
281 }
282 DockerfileSelectionResult::Cancelled => return WizardResult::Cancelled,
283 };
284
285 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() } else {
297 format!("{}/{}", build_context, dockerfile_name) };
299
300 log::debug!(
301 "Dockerfile path: {}, build_context: {}, dockerfile_name: {}",
302 dockerfile_path,
303 build_context,
304 dockerfile_name
305 );
306
307 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 return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
325 }
326 ConfigFormResult::Cancelled => return WizardResult::Cancelled,
327 };
328
329 display_summary(&config);
331
332 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 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 dockerfile_path: config.dockerfile_path.clone(),
362 dockerfile: config.dockerfile_path.clone(), build_context: config.build_context.clone(),
364 context: config.build_context.clone(), 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 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 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, };
428
429 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
488fn 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}