use colored::Colorize;
use inquire::{Confirm, Select, Text};
use lmrc_config_validator::{
ApplicationEntry, DnsConfig, DnsRecord, GitLabConfig, InfrastructureConfig, K3sConfig,
LmrcConfig, NetworkConfig, PostgresConfig, PostgresDeploymentMode, PostgresStandaloneConfig,
ProjectConfig, ProviderConfig, ServerGroup, ServerRole, SetupMode, providers,
};
use std::collections::HashMap;
use crate::error::Result;
pub struct InteractivePrompt;
impl InteractivePrompt {
pub fn run() -> Result<LmrcConfig> {
println!(
"\n{}\n",
"Welcome to LMRC Stack Project Generator!".green().bold()
);
println!("Let's configure your infrastructure project.\n");
let project = Self::prompt_project()?;
let providers = Self::prompt_providers()?;
let apps = Self::prompt_applications()?;
let infrastructure = Self::prompt_infrastructure(&providers)?;
Ok(LmrcConfig {
project,
providers,
apps,
infrastructure,
})
}
fn prompt_project() -> Result<ProjectConfig> {
println!("{}", "=== Project Information ===".cyan().bold());
let name = Text::new("Project name:")
.with_help_message("Alphanumeric with hyphens/underscores only")
.prompt()?;
let description = Text::new("Project description:")
.with_default("My LMRC Stack infrastructure project")
.prompt()?;
Ok(ProjectConfig { name, description })
}
fn prompt_providers() -> Result<ProviderConfig> {
println!("\n{}", "=== Provider Selection ===".cyan().bold());
let server = Select::new(
"Select server provider:",
providers::SERVER_PROVIDERS.to_vec(),
)
.prompt()?
.to_string();
let kubernetes = Select::new(
"Select Kubernetes distribution:",
providers::KUBERNETES_PROVIDERS.to_vec(),
)
.with_help_message("Distribution to install (future: eks, gke, aks)")
.prompt()?
.to_string();
let database = Select::new(
"Select database provider:",
providers::DATABASE_PROVIDERS.to_vec(),
)
.prompt()?
.to_string();
let dns = Select::new("Select DNS provider:", providers::DNS_PROVIDERS.to_vec())
.prompt()?
.to_string();
let git = Select::new("Select Git provider:", providers::GIT_PROVIDERS.to_vec())
.prompt()?
.to_string();
Ok(ProviderConfig {
server,
kubernetes,
database,
queue: "rabbitmq".to_string(),
dns,
git,
})
}
fn prompt_applications() -> Result<lmrc_config_validator::AppsConfig> {
println!("\n{}", "=== Applications ===".cyan().bold());
let mut applications = Vec::new();
loop {
let app_name = Text::new("Application name:")
.with_help_message("Leave empty to finish adding applications")
.prompt()?;
if app_name.trim().is_empty() {
if applications.is_empty() {
println!("{}", "At least one application is required!".yellow());
continue;
}
break;
}
let app_type_options: Vec<&str> = vec!["gateway", "api", "migrator", "basic"];
let app_type_str = Select::new("Application type:", app_type_options)
.prompt()?;
let app_type = match app_type_str {
"gateway" => Some(lmrc_config_validator::AppType::Gateway),
"api" => Some(lmrc_config_validator::AppType::Api),
"migrator" => Some(lmrc_config_validator::AppType::Migrator),
"basic" => Some(lmrc_config_validator::AppType::Basic),
_ => None,
};
applications.push(ApplicationEntry {
name: app_name.clone(),
app_type,
docker: Some(lmrc_config_validator::DockerConfig {
dockerfile: format!("apps/{}/Dockerfile", app_name),
context: format!("apps/{}", app_name),
tags: vec!["latest".to_string()],
}),
deployment: Some(lmrc_config_validator::DeploymentConfig {
replicas: 1,
port: 8080,
cpu_request: Some("100m".to_string()),
memory_request: Some("128Mi".to_string()),
cpu_limit: Some("1".to_string()),
memory_limit: Some("512Mi".to_string()),
env: vec![],
}),
});
let add_more = Confirm::new("Add another application?")
.with_default(false)
.prompt()?;
if !add_more {
break;
}
}
Ok(lmrc_config_validator::AppsConfig { applications })
}
fn prompt_infrastructure(providers: &ProviderConfig) -> Result<InfrastructureConfig> {
println!("\n{}", "=== Infrastructure Configuration ===".cyan().bold());
let setup_mode = Self::prompt_setup_mode()?;
let servers = if providers.server == "hetzner" {
println!("\n{}", "Server Groups Configuration:".yellow());
Self::prompt_server_groups(setup_mode)?
} else {
vec![]
};
let network = Some(NetworkConfig {
enable_private_network: false,
private_network: None,
firewall_rules: vec![],
});
let k3s = if providers.kubernetes == "k3s" {
println!("\n{}", "K3s Configuration:".yellow());
Some(Self::prompt_k3s(&servers)?)
} else {
None
};
let postgres = if providers.database == "postgres" {
println!("\n{}", "PostgreSQL Configuration:".yellow());
Some(Self::prompt_postgres(&servers)?)
} else {
None
};
let dns = if providers.dns == "cloudflare" {
println!("\n{}", "DNS Configuration:".yellow());
Some(Self::prompt_dns(&servers)?)
} else {
None
};
let gitlab = if providers.git == "gitlab" {
println!("\n{}", "GitLab Configuration:".yellow());
Some(Self::prompt_gitlab()?)
} else {
None
};
Ok(InfrastructureConfig {
provider: providers.server.clone(),
network,
servers,
load_balancer: None,
k3s,
postgres,
rabbitmq: None,
vault: None,
dns,
gitlab,
})
}
fn prompt_setup_mode() -> Result<SetupMode> {
println!("\n{}", "Setup Mode Selection:".yellow());
let modes = SetupMode::all();
let mode_options: Vec<String> = modes
.iter()
.map(|m| format!("{} - {}", m.display_name(), m.description()))
.collect();
let selected = Select::new("Choose your setup mode:", mode_options.clone())
.with_help_message("This determines the infrastructure topology and complexity")
.prompt()?;
let selected_index = mode_options
.iter()
.position(|opt| opt == &selected)
.unwrap_or(0);
Ok(modes[selected_index])
}
fn prompt_server_groups(mode: SetupMode) -> Result<Vec<ServerGroup>> {
match mode {
SetupMode::Quick => Self::prompt_quick_mode(),
SetupMode::Standard => Self::prompt_standard_mode(),
SetupMode::Advanced => Self::prompt_advanced_mode(),
}
}
fn prompt_quick_mode() -> Result<Vec<ServerGroup>> {
println!("\n{}", "Quick Mode: Single K3s server".cyan());
println!("Configuring a single all-in-one K3s server for development/testing.\n");
let server_type = Select::new(
"Server type:",
vec![
"cx22", "cx32", "cx42", "cx52", "cpx21", "cpx31", "cpx41",
],
)
.with_help_message("See https://www.hetzner.com/cloud for details")
.prompt()?
.to_string();
let location = Select::new(
"Server location:",
vec!["nbg1", "fsn1", "hel1", "ash", "hil"],
)
.with_help_message("nbg1=Nuremberg, fsn1=Falkenstein, hel1=Helsinki")
.prompt()?
.to_string();
Ok(vec![ServerGroup {
name: "k3s-server".to_string(),
role: ServerRole::K3sControl,
server_type,
location,
count: 1,
labels: HashMap::new(),
ssh_keys: vec![],
image: Some("ubuntu-22.04".to_string()),
}])
}
fn prompt_standard_mode() -> Result<Vec<ServerGroup>> {
println!(
"\n{}",
"Standard Mode: Multi-server HA production setup".cyan()
);
println!("Configuring control plane, workers, and database server.\n");
let location = Select::new(
"Server location (applies to all servers):",
vec!["nbg1", "fsn1", "hel1", "ash", "hil"],
)
.with_help_message("nbg1=Nuremberg, fsn1=Falkenstein, hel1=Helsinki")
.prompt()?
.to_string();
println!("\n{}", "Control Plane Configuration:".green());
let control_count = Text::new("Number of control plane nodes (1-3):")
.with_default("1")
.prompt()?
.parse::<u32>()
.unwrap_or(1)
.clamp(1, 3);
let control_type = Select::new(
"Control plane server type:",
vec!["cx22", "cx32", "cx42", "cpx21", "cpx31"],
)
.with_help_message("Recommended: cx22 for dev, cpx21 for production")
.prompt()?
.to_string();
println!("\n{}", "Worker Nodes Configuration:".green());
let worker_count = Text::new("Number of worker nodes (0-5):")
.with_default("2")
.prompt()?
.parse::<u32>()
.unwrap_or(2)
.clamp(0, 5);
let worker_type = if worker_count > 0 {
Select::new(
"Worker server type:",
vec!["cx22", "cx32", "cx42", "cpx21", "cpx31"],
)
.with_help_message("Size based on your workload requirements")
.prompt()?
.to_string()
} else {
"cx22".to_string()
};
println!("\n{}", "Database Server Configuration:".green());
let db_type = Select::new(
"Database server type:",
vec!["cx22", "cx32", "cx42", "cx52"],
)
.with_help_message("Dedicated PostgreSQL server")
.prompt()?
.to_string();
let mut groups = vec![
ServerGroup {
name: "k3s-control".to_string(),
role: ServerRole::K3sControl,
server_type: control_type,
location: location.clone(),
count: control_count,
labels: HashMap::new(),
ssh_keys: vec![],
image: Some("ubuntu-22.04".to_string()),
},
ServerGroup {
name: "postgres-server".to_string(),
role: ServerRole::Postgres,
server_type: db_type,
location: location.clone(),
count: 1,
labels: HashMap::new(),
ssh_keys: vec![],
image: Some("ubuntu-22.04".to_string()),
},
];
if worker_count > 0 {
groups.insert(
1,
ServerGroup {
name: "k3s-workers".to_string(),
role: ServerRole::K3sWorker,
server_type: worker_type,
location,
count: worker_count,
labels: HashMap::new(),
ssh_keys: vec![],
image: Some("ubuntu-22.04".to_string()),
},
);
}
Ok(groups)
}
fn prompt_advanced_mode() -> Result<Vec<ServerGroup>> {
println!("\n{}", "Advanced Mode: Custom topology".cyan());
println!("Configure custom server groups with any role combination.\n");
let mut groups = Vec::new();
loop {
let group_name = Text::new("Server group name:")
.with_help_message("Leave empty to finish adding server groups")
.prompt()?;
if group_name.trim().is_empty() {
if groups.is_empty() {
println!("{}", "At least one server group is required!".yellow());
continue;
}
break;
}
if groups.iter().any(|g: &ServerGroup| g.name == group_name) {
println!("{}", "Server group name must be unique!".red());
continue;
}
let role = Self::prompt_server_role()?;
let server_type = Select::new(
"Server type:",
vec![
"cx11", "cx21", "cx31", "cx41", "cx51", "cpx11", "cpx21", "cpx31",
],
)
.with_help_message("See https://www.hetzner.com/cloud for details")
.prompt()?
.to_string();
let location = Select::new(
"Server location:",
vec!["nbg1", "fsn1", "hel1", "ash", "hil"],
)
.with_help_message("nbg1=Nuremberg, fsn1=Falkenstein, hel1=Helsinki")
.prompt()?
.to_string();
let count = Text::new("Number of servers:")
.with_default("1")
.prompt()?
.parse::<u32>()
.unwrap_or(1)
.clamp(1, 50);
groups.push(ServerGroup {
name: group_name,
role,
server_type,
location,
count,
labels: HashMap::new(),
ssh_keys: vec![],
image: Some("ubuntu-22.04".to_string()),
});
let add_more = Confirm::new("Add another server group?")
.with_default(true)
.prompt()?;
if !add_more {
break;
}
}
Ok(groups)
}
fn prompt_server_role() -> Result<ServerRole> {
let roles = ServerRole::all();
let role_options: Vec<String> = roles
.iter()
.map(|r| format!("{} - {}", r.display_name(), r.description()))
.collect();
let selected = Select::new("Select server role:", role_options.clone())
.with_help_message("Choose the primary purpose of this server group")
.prompt()?;
let selected_index = role_options
.iter()
.position(|opt| opt == &selected)
.unwrap_or(0);
Ok(roles[selected_index].clone())
}
fn prompt_k3s(servers: &[ServerGroup]) -> Result<K3sConfig> {
let enable_traefik = Confirm::new("Enable Traefik ingress controller?")
.with_default(true)
.prompt()?;
let enable_metrics_server = Confirm::new("Enable metrics server?")
.with_default(true)
.prompt()?;
let control_plane_servers: Vec<String> = servers
.iter()
.filter(|s| s.role == ServerRole::K3sControl)
.map(|s| s.name.clone())
.collect();
let worker_servers: Vec<String> = servers
.iter()
.filter(|s| s.role == ServerRole::K3sWorker)
.map(|s| s.name.clone())
.collect();
let mut deploy_on = control_plane_servers.clone();
deploy_on.extend(worker_servers.clone());
Ok(K3sConfig {
version: "v1.28.5+k3s1".to_string(),
deploy_on,
control_plane_servers,
worker_servers,
enable_traefik,
enable_metrics_server,
server_flags: vec![],
agent_flags: vec![],
})
}
fn prompt_postgres(servers: &[ServerGroup]) -> Result<PostgresConfig> {
let version = Select::new("PostgreSQL version:", vec!["16", "15", "14", "13"])
.with_help_message("Latest stable version recommended")
.prompt()?
.to_string();
let database_name = Text::new("Database name:").with_default("myapp").prompt()?;
let deploy_on = servers
.iter()
.find(|s| s.role == ServerRole::Postgres)
.or_else(|| servers.first())
.map(|s| s.name.clone())
.unwrap_or_else(|| "k3s-server".to_string());
Ok(PostgresConfig {
version,
database_name,
deployment_mode: PostgresDeploymentMode::Standalone,
standalone: Some(PostgresStandaloneConfig {
deploy_on,
data_dir: Some("/var/lib/postgresql/data".to_string()),
max_connections: Some(100),
shared_buffers: Some("256MB".to_string()),
}),
in_cluster: None,
})
}
fn prompt_dns(servers: &[ServerGroup]) -> Result<DnsConfig> {
let domain = Text::new("Primary domain:")
.with_help_message("e.g., example.com")
.prompt()?;
let proxied = Confirm::new("Enable Cloudflare proxy?")
.with_default(true)
.with_help_message("Provides DDoS protection and CDN")
.prompt()?;
let target = servers
.first()
.map(|s| s.name.clone())
.unwrap_or_else(|| "k3s-nodes".to_string());
let records = vec![
DnsRecord {
record_type: "A".to_string(),
name: "@".to_string(),
target: target.clone(),
ttl: 300,
proxied,
},
DnsRecord {
record_type: "A".to_string(),
name: "*".to_string(),
target,
ttl: 300,
proxied,
},
];
Ok(DnsConfig {
provider: "cloudflare".to_string(),
domain,
records,
})
}
fn prompt_gitlab() -> Result<GitLabConfig> {
let url = Text::new("GitLab instance URL:")
.with_default("https://gitlab.com")
.prompt()?;
let namespace = Text::new("Project namespace/group:")
.with_help_message("e.g., mycompany or myusername")
.prompt()?;
Ok(GitLabConfig { url, namespace })
}
}