use crate::analyzer::discover_dockerfiles_for_deployment;
use crate::platform::api::PlatformApiClient;
use crate::platform::api::types::{
CloudProvider, CloudRunnerConfigInput, ConnectRepositoryRequest, CreateDeploymentConfigRequest,
DeploymentTarget, ProjectRepository, TriggerDeploymentRequest, WizardDeploymentConfig,
build_cloud_runner_config_v2,
};
use crate::wizard::{
ClusterSelectionResult, ConfigFormResult, DockerfileSelectionResult,
InfrastructureSelectionResult, ProviderSelectionResult, RegistryProvisioningResult,
RegistrySelectionResult, RepositorySelectionResult, TargetSelectionResult, collect_config,
collect_env_vars, collect_service_endpoint_env_vars, filter_endpoints_for_provider,
get_available_endpoints, get_provider_deployment_statuses, provision_registry, select_cluster,
select_dockerfile, select_infrastructure, select_provider, select_registry, select_repository,
select_target,
};
use colored::Colorize;
use inquire::{Confirm, InquireError};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct DeploymentInfo {
pub config_id: String,
pub task_id: String,
pub service_name: String,
}
#[derive(Debug)]
pub enum WizardResult {
Deployed(DeploymentInfo),
Success(WizardDeploymentConfig),
StartAgent(String),
Cancelled,
Error(String),
}
pub async fn run_wizard(
client: &PlatformApiClient,
project_id: &str,
environment_id: &str,
project_path: &Path,
) -> WizardResult {
println!();
println!(
"{}",
"═══════════════════════════════════════════════════════════════".bright_cyan()
);
println!(
"{}",
" Deployment Wizard "
.bright_cyan()
.bold()
);
println!(
"{}",
"═══════════════════════════════════════════════════════════════".bright_cyan()
);
let repository = match select_repository(client, project_id, project_path).await {
RepositorySelectionResult::Selected(repo) => repo,
RepositorySelectionResult::ConnectNew(available) => {
println!("{} Connecting repository...", "→".cyan());
let owner = available.owner.clone().unwrap_or_else(|| {
available
.full_name
.split('/')
.next()
.unwrap_or("")
.to_string()
});
let connect_request = ConnectRepositoryRequest {
project_id: project_id.to_string(),
repository_id: available.id,
repository_name: available.name.clone(),
repository_full_name: available.full_name.clone(),
repository_owner: owner.clone(),
repository_private: available.private,
default_branch: available
.default_branch
.clone()
.or(Some("main".to_string())),
connection_type: Some("app".to_string()),
github_installation_id: available.installation_id,
repository_type: Some("application".to_string()),
};
match client.connect_repository(&connect_request).await {
Ok(response) => {
println!("{} Repository connected!", "✓".green());
ProjectRepository {
id: response.id,
project_id: response.project_id,
repository_id: response.repository_id,
repository_name: available.name,
repository_full_name: response.repository_full_name,
repository_owner: owner,
repository_private: available.private,
default_branch: available.default_branch,
is_active: response.is_active,
connection_type: Some("app".to_string()),
repository_type: Some("application".to_string()),
is_primary_git_ops: None,
github_installation_id: available.installation_id,
user_id: None,
created_at: None,
updated_at: None,
}
}
Err(e) => {
return WizardResult::Error(format!("Failed to connect repository: {}", e));
}
}
}
RepositorySelectionResult::NeedsGitHubApp {
installation_url,
org_name,
} => {
println!(
"\n{} Please install the Syncable GitHub App for organization '{}' first.",
"⚠".yellow(),
org_name.cyan()
);
println!("Installation URL: {}", installation_url);
return WizardResult::Cancelled;
}
RepositorySelectionResult::NoInstallations { installation_url } => {
println!(
"\n{} No GitHub App installations found. Please install the app first.",
"⚠".yellow()
);
println!("Installation URL: {}", installation_url);
return WizardResult::Cancelled;
}
RepositorySelectionResult::NoRepositories => {
return WizardResult::Error(
"No repositories available. Please install the Syncable GitHub App first."
.to_string(),
);
}
RepositorySelectionResult::Cancelled => return WizardResult::Cancelled,
RepositorySelectionResult::Error(e) => return WizardResult::Error(e),
};
let provider_statuses = match get_provider_deployment_statuses(client, project_id).await {
Ok(s) => s,
Err(e) => {
return WizardResult::Error(format!("Failed to fetch provider status: {}", e));
}
};
let provider = match select_provider(&provider_statuses) {
ProviderSelectionResult::Selected(p) => p,
ProviderSelectionResult::Cancelled => return WizardResult::Cancelled,
};
let provider_status = provider_statuses
.iter()
.find(|s| s.provider == provider)
.expect("Selected provider must exist in statuses");
let target = match select_target(provider_status) {
TargetSelectionResult::Selected(t) => t,
TargetSelectionResult::Back => {
return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
}
TargetSelectionResult::Cancelled => return WizardResult::Cancelled,
};
let (cluster_id, region, machine_type, cpu, memory) = if target == DeploymentTarget::CloudRunner
{
match select_infrastructure(&provider, 3, Some(client), Some(project_id)).await {
InfrastructureSelectionResult::Selected {
region,
machine_type,
cpu,
memory,
} => (None, Some(region), Some(machine_type), cpu, memory),
InfrastructureSelectionResult::Back => {
return Box::pin(run_wizard(client, project_id, environment_id, project_path))
.await;
}
InfrastructureSelectionResult::Cancelled => return WizardResult::Cancelled,
}
} else {
match select_cluster(&provider_status.clusters) {
ClusterSelectionResult::Selected(c) => (Some(c.id), None, None, None, None),
ClusterSelectionResult::Back => {
return Box::pin(run_wizard(client, project_id, environment_id, project_path))
.await;
}
ClusterSelectionResult::Cancelled => return WizardResult::Cancelled,
}
};
let registry_id = loop {
match select_registry(&provider_status.registries) {
RegistrySelectionResult::Selected(r) => break Some(r.id),
RegistrySelectionResult::ProvisionNew => {
let (prov_cluster_id, prov_cluster_name, prov_region) =
if let Some(ref cid) = cluster_id {
let cluster = provider_status
.clusters
.iter()
.find(|c| c.id == *cid)
.expect("Selected cluster must exist");
(cid.clone(), cluster.name.clone(), cluster.region.clone())
} else {
if let Some(cluster) = provider_status.clusters.first() {
(
cluster.id.clone(),
cluster.name.clone(),
cluster.region.clone(),
)
} else {
return WizardResult::Error(
"No cluster available for registry provisioning".to_string(),
);
}
};
match provision_registry(
client,
project_id,
&prov_cluster_id,
&prov_cluster_name,
provider.clone(),
&prov_region,
None, )
.await
{
RegistryProvisioningResult::Success(registry) => {
break Some(registry.id);
}
RegistryProvisioningResult::Cancelled => {
return WizardResult::Cancelled;
}
RegistryProvisioningResult::Error(e) => {
eprintln!("{} {}", "Registry provisioning failed:".red(), e);
continue;
}
}
}
RegistrySelectionResult::Back => {
return Box::pin(run_wizard(client, project_id, environment_id, project_path))
.await;
}
RegistrySelectionResult::Cancelled => return WizardResult::Cancelled,
}
};
let dockerfiles = discover_dockerfiles_for_deployment(project_path).unwrap_or_default();
let (selected_dockerfile, build_context) = match select_dockerfile(&dockerfiles, project_path) {
DockerfileSelectionResult::Selected {
dockerfile,
build_context,
} => (dockerfile, build_context),
DockerfileSelectionResult::StartAgent(prompt) => {
return WizardResult::StartAgent(prompt);
}
DockerfileSelectionResult::Back => {
return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
}
DockerfileSelectionResult::Cancelled => return WizardResult::Cancelled,
};
let dockerfile_name = selected_dockerfile
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Dockerfile".to_string());
let dockerfile_path = if build_context == "." || build_context.is_empty() {
dockerfile_name.clone() } else {
format!("{}/{}", build_context, dockerfile_name) };
log::debug!(
"Dockerfile path: {}, build_context: {}, dockerfile_name: {}",
dockerfile_path,
build_context,
dockerfile_name
);
let config = match collect_config(
provider.clone(),
target.clone(),
cluster_id.clone(),
registry_id.clone(),
environment_id,
&dockerfile_path,
&build_context,
&selected_dockerfile,
region.clone(),
machine_type.clone(),
cpu.clone(),
memory.clone(),
6,
) {
ConfigFormResult::Completed(config) => config,
ConfigFormResult::Back => {
return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await;
}
ConfigFormResult::Cancelled => return WizardResult::Cancelled,
};
let secrets = collect_env_vars(project_path);
let mut config = config;
config.secrets = secrets;
let available_endpoints = match client.list_deployments(project_id, Some(50)).await {
Ok(paginated) => {
log::debug!(
"Fetched {} deployment record(s) for endpoint discovery",
paginated.data.len()
);
get_available_endpoints(&paginated.data)
}
Err(e) => {
log::debug!("Could not fetch deployments for endpoint injection: {}", e);
Vec::new()
}
};
let service_being_deployed = config.service_name.as_deref().unwrap_or("");
let available_endpoints: Vec<_> = available_endpoints
.into_iter()
.filter(|ep| ep.service_name != service_being_deployed)
.collect();
let available_endpoints = filter_endpoints_for_provider(available_endpoints, provider.as_str());
if !available_endpoints.is_empty() {
let endpoint_vars = collect_service_endpoint_env_vars(&available_endpoints);
for ep_var in endpoint_vars {
if !config.secrets.iter().any(|s| s.key == ep_var.key) {
config.secrets.push(ep_var);
}
}
}
display_summary(&config);
println!();
let should_deploy = match Confirm::new("Deploy now?")
.with_default(true)
.with_help_message("This will create the deployment configuration and start the deployment")
.prompt()
{
Ok(v) => v,
Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
return WizardResult::Cancelled;
}
Err(_) => return WizardResult::Cancelled,
};
if !should_deploy {
println!("{}", "Deployment skipped. Configuration saved.".dimmed());
return WizardResult::Success(config);
}
println!();
println!("{}", "Creating deployment configuration...".dimmed());
let deploy_request = CreateDeploymentConfigRequest {
project_id: project_id.to_string(),
service_name: config.service_name.clone().unwrap_or_default(),
repository_id: repository.repository_id,
repository_full_name: repository.repository_full_name.clone(),
dockerfile_path: config.dockerfile_path.clone(),
dockerfile: config.dockerfile_path.clone(), build_context: config.build_context.clone(),
context: config.build_context.clone(), port: config.port.unwrap_or(8080) as i32,
branch: config.branch.clone().unwrap_or_else(|| "main".to_string()),
target_type: target.as_str().to_string(),
cloud_provider: provider.as_str().to_string(),
environment_id: environment_id.to_string(),
cluster_id: cluster_id.clone(),
registry_id: registry_id.clone(),
auto_deploy_enabled: config.auto_deploy,
is_public: Some(config.is_public),
secrets: if config.secrets.is_empty() {
None
} else {
Some(config.secrets.clone())
},
cloud_runner_config: if target == DeploymentTarget::CloudRunner {
let (gcp_project_id, subscription_id) = match provider {
CloudProvider::Gcp | CloudProvider::Azure => {
match client
.check_provider_connection(&provider, project_id)
.await
{
Ok(Some(cred)) => match provider {
CloudProvider::Gcp => (cred.provider_account_id, None),
CloudProvider::Azure => (None, cred.provider_account_id),
_ => (None, None),
},
_ => (None, None),
}
}
_ => (None, None),
};
let config_input = CloudRunnerConfigInput {
provider: Some(provider.clone()),
region: region.clone(),
server_type: if provider == CloudProvider::Hetzner {
machine_type.clone()
} else {
None
},
gcp_project_id,
cpu: config.cpu.clone(),
memory: config.memory.clone(),
allow_unauthenticated: Some(config.is_public),
subscription_id,
is_public: Some(config.is_public),
health_check_path: config.health_check_path.clone(),
..Default::default()
};
Some(build_cloud_runner_config_v2(&config_input))
} else {
None
},
};
log::debug!("CreateDeploymentConfigRequest fields:");
log::debug!(" projectId: {}", deploy_request.project_id);
log::debug!(" serviceName: {}", deploy_request.service_name);
log::debug!(" environmentId: {}", deploy_request.environment_id);
log::debug!(" repositoryId: {}", deploy_request.repository_id);
log::debug!(
" repositoryFullName: {}",
deploy_request.repository_full_name
);
log::debug!(" dockerfilePath: {:?}", deploy_request.dockerfile_path);
log::debug!(" buildContext: {:?}", deploy_request.build_context);
log::debug!(" targetType: {}", deploy_request.target_type);
log::debug!(" cloudProvider: {}", deploy_request.cloud_provider);
log::debug!(" port: {}", deploy_request.port);
log::debug!(" branch: {}", deploy_request.branch);
if let Some(ref config) = deploy_request.cloud_runner_config {
log::debug!(" cloudRunnerConfig: {}", config);
}
let deployment_config = match client.create_deployment_config(&deploy_request).await {
Ok(config) => config,
Err(e) => {
return WizardResult::Error(format!("Failed to create deployment config: {}", e));
}
};
println!(
"{} Deployment configuration created: {}",
"✓".green(),
deployment_config.id.dimmed()
);
log::debug!(" Config ID: {}", deployment_config.id);
log::debug!(" Service Name: {}", deployment_config.service_name);
log::debug!(" Environment ID: {}", deployment_config.environment_id);
println!("{}", "Triggering deployment...".dimmed());
let trigger_request = TriggerDeploymentRequest {
project_id: project_id.to_string(),
config_id: deployment_config.id.clone(),
commit_sha: None, };
log::debug!("Trigger request: configId={}", trigger_request.config_id);
match client.trigger_deployment(&trigger_request).await {
Ok(response) => {
log::info!(
"Deployment triggered successfully: taskId={}, status={}, message={}",
response.backstage_task_id,
response.status,
response.message
);
println!();
println!(
"{}",
"═══════════════════════════════════════════════════════════════".bright_green()
);
println!("{} Deployment started!", "✓".bright_green().bold());
println!(
"{}",
"═══════════════════════════════════════════════════════════════".bright_green()
);
println!();
println!(
" Service: {}",
config.service_name.as_deref().unwrap_or("").cyan()
);
println!(" Task ID: {}", response.backstage_task_id.dimmed());
println!(" Status: {}", response.status.yellow());
println!();
println!(
"{}",
"Track progress: sync-ctl deploy status <task-id>".dimmed()
);
println!();
WizardResult::Deployed(DeploymentInfo {
config_id: deployment_config.id,
task_id: response.backstage_task_id,
service_name: config.service_name.unwrap_or_default(),
})
}
Err(e) => {
log::error!("Failed to trigger deployment: {}", e);
eprintln!(
"\n{} {} {}\n",
"✗".red().bold(),
"Deployment trigger failed:".red().bold(),
e
);
WizardResult::Error(format!("Failed to trigger deployment: {}", e))
}
}
}
fn display_summary(config: &WizardDeploymentConfig) {
println!();
println!(
"{}",
"─────────────────────────────────────────────────────────────────".dimmed()
);
println!("{}", " Deployment Summary ".bright_green().bold());
println!(
"{}",
"─────────────────────────────────────────────────────────────────".dimmed()
);
if let Some(ref name) = config.service_name {
println!(" Service: {}", name.cyan());
}
if let Some(ref target) = config.target {
println!(" Target: {}", target.display_name());
}
if let Some(ref provider) = config.provider {
println!(" Provider: {:?}", provider);
}
if let Some(ref region) = config.region {
println!(" Region: {}", region.cyan());
}
if let Some(ref cpu) = config.cpu {
if let Some(ref mem) = config.memory {
println!(" Resources: {} vCPU / {}", cpu.cyan(), mem.cyan());
}
} else if let Some(ref machine) = config.machine_type {
println!(" Machine: {}", machine.cyan());
}
if let Some(ref branch) = config.branch {
println!(" Branch: {}", branch);
}
if let Some(port) = config.port {
println!(" Port: {}", port);
}
println!(
" Public: {}",
if config.is_public {
"Yes".green()
} else {
"No".yellow()
}
);
if let Some(ref health) = config.health_check_path {
println!(" Health check: {}", health.cyan());
}
println!(
" Auto-deploy: {}",
if config.auto_deploy {
"Yes".green()
} else {
"No".yellow()
}
);
if !config.secrets.is_empty() {
let secret_count = config.secrets.iter().filter(|s| s.is_secret).count();
let env_count = config.secrets.len() - secret_count;
println!(
" Env vars: {} env, {} secret",
env_count.to_string().cyan(),
secret_count.to_string().yellow()
);
}
println!(
"{}",
"─────────────────────────────────────────────────────────────────".dimmed()
);
println!();
}