1use 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#[derive(Debug, Clone)]
25pub struct DeploymentInfo {
26 pub config_id: String,
28 pub task_id: String,
30 pub service_name: String,
32}
33
34#[derive(Debug)]
36pub enum WizardResult {
37 Deployed(DeploymentInfo),
39 Success(WizardDeploymentConfig),
41 StartAgent(String),
43 Cancelled,
45 Error(String),
47}
48
49pub 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 let repository = match select_repository(client, project_id, project_path).await {
74 RepositorySelectionResult::Selected(repo) => repo,
75 RepositorySelectionResult::ConnectNew(available) => {
76 println!("{} Connecting repository...", "→".cyan());
78
79 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 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 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 let provider_status = provider_statuses
177 .iter()
178 .find(|s| s.provider == provider)
179 .expect("Selected provider must exist in statuses");
180
181 let target = match select_target(provider_status) {
183 TargetSelectionResult::Selected(t) => t,
184 TargetSelectionResult::Back => {
185 return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
187 }
188 TargetSelectionResult::Cancelled => return WizardResult::Cancelled,
189 };
190
191 let (cluster_id, region, machine_type, cpu, memory) = if target == DeploymentTarget::CloudRunner
193 {
194 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 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 match select_cluster(&provider_status.clusters) {
213 ClusterSelectionResult::Selected(c) => (Some(c.id), None, None, None, None),
214 ClusterSelectionResult::Back => {
215 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 let registry_id = loop {
225 match select_registry(&provider_status.registries) {
226 RegistrySelectionResult::Selected(r) => break Some(r.id),
227 RegistrySelectionResult::ProvisionNew => {
228 let (prov_cluster_id, prov_cluster_name, prov_region) =
230 if let Some(ref cid) = cluster_id {
231 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 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 match provision_registry(
255 client,
256 project_id,
257 &prov_cluster_id,
258 &prov_cluster_name,
259 provider.clone(),
260 &prov_region,
261 None, )
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 continue;
275 }
276 }
277 }
278 RegistrySelectionResult::Back => {
279 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 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 return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
300 }
301 DockerfileSelectionResult::Cancelled => return WizardResult::Cancelled,
302 };
303
304 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() } else {
316 format!("{}/{}", build_context, dockerfile_name) };
318
319 log::debug!(
320 "Dockerfile path: {}, build_context: {}, dockerfile_name: {}",
321 dockerfile_path,
322 build_context,
323 dockerfile_name
324 );
325
326 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 return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
346 }
347 ConfigFormResult::Cancelled => return WizardResult::Cancelled,
348 };
349
350 let secrets = collect_env_vars(project_path);
352 let mut config = config;
353 config.secrets = secrets;
354
355 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 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 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 if !config.secrets.iter().any(|s| s.key == ep_var.key) {
384 config.secrets.push(ep_var);
385 }
386 }
387 }
388
389 display_summary(&config);
391
392 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 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 dockerfile_path: config.dockerfile_path.clone(),
422 dockerfile: config.dockerfile_path.clone(), build_context: config.build_context.clone(),
424 context: config.build_context.clone(), 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 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 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 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, };
525
526 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
585fn 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}