pub mod config;
pub mod docker;
pub mod launchd;
pub mod state;
pub mod templates;
pub use config::{DeploymentConfig, DeploymentTarget};
pub use state::DeploymentState;
use anyhow::{Context, Result};
use audb::model::Project;
use audb::parser::GoldParser;
use console::{Term, style};
use std::collections::HashMap;
use std::path::Path;
use walkdir::WalkDir;
#[derive(Debug)]
pub struct DeployOptions {
pub target: Option<String>,
pub port: Option<u16>,
pub env: Vec<String>,
pub volume: Vec<String>,
pub force_rebuild: bool,
pub compose: bool,
}
#[derive(Debug)]
pub enum DeployCommand {
Start,
Status,
Stop,
Logs,
Restart,
}
pub async fn run(command: DeployCommand, options: DeployOptions) -> Result<()> {
match command {
DeployCommand::Start => start_deployment(options).await,
DeployCommand::Status => show_status().await,
DeployCommand::Stop => stop_deployment().await,
DeployCommand::Logs => show_logs(false, None).await,
DeployCommand::Restart => restart_deployment().await,
}
}
async fn start_deployment(options: DeployOptions) -> Result<()> {
let term = Term::stdout();
term.write_line("")?;
term.write_line(&format!(
"{} {}",
style("Starting deployment").bold().cyan(),
style("(this may take a few minutes)").dim()
))?;
term.write_line("")?;
let project_root = std::env::current_dir().context("Failed to get current directory")?;
term.write_line(&format!("{} Validating gold files...", style("1/5").dim()))?;
let gold_dir = project_root.join("gold");
if gold_dir.exists() {
if let Err(e) = crate::commands::check::run("./gold") {
term.write_line(&format!("{} Validation failed: {}", style("✗").red(), e))?;
term.write_line("")?;
return Err(e.context("Gold file validation failed. Fix errors before deploying."));
}
term.write_line(&format!(" {} Gold files validated", style("✓").green()))?;
} else {
term.write_line(&format!(
" {} No gold directory found, skipping validation",
style("⚠").yellow()
))?;
}
term.write_line(&format!("{} Generating code...", style("2/5").dim()))?;
if gold_dir.exists() {
if let Err(e) = crate::commands::generate::run("./gold", "./src/generated") {
term.write_line(&format!(
"{} Code generation failed: {}",
style("✗").red(),
e
))?;
term.write_line("")?;
return Err(e.context("Code generation failed. Fix errors before deploying."));
}
term.write_line(&format!(" {} Code generated", style("✓").green()))?;
} else {
term.write_line(&format!(
" {} No gold directory found, skipping generation",
style("⚠").yellow()
))?;
}
term.write_line(&format!(
"{} Loading deployment configuration...",
style("3/5").dim()
))?;
let mut config = load_deployment_config(&project_root)?;
apply_cli_overrides(&mut config, &options)?;
term.write_line(&format!(
"{} Deploying to {}...",
style("4/5").dim(),
match &config.target {
DeploymentTarget::Docker => "Docker",
DeploymentTarget::Systemd => "Systemd",
DeploymentTarget::Daemon => "Daemon",
DeploymentTarget::Local => "Local",
}
))?;
let _state = match &config.target {
DeploymentTarget::Docker => {
if options.compose {
deploy_with_compose(&project_root, &config).await?
} else {
docker::deploy(&project_root, &config, options.force_rebuild).await?
}
}
DeploymentTarget::Systemd => {
term.write_line(&format!(
"{} Systemd deployment not yet implemented",
style("⚠").yellow()
))?;
return Ok(());
}
DeploymentTarget::Daemon => {
#[cfg(target_os = "macos")]
{
launchd::deploy(&project_root, &config, options.force_rebuild).await?
}
#[cfg(not(target_os = "macos"))]
{
term.write_line(&format!(
"{} Daemon deployment not yet implemented for this platform",
style("⚠").yellow()
))?;
return Ok(());
}
}
DeploymentTarget::Local => deploy_local(&project_root, &config).await?,
};
term.write_line(&format!("{} Deployment complete!", style("5/5").dim()))?;
term.write_line("")?;
term.write_line(&format!(
"{} Deployment successful!",
style("✓").green().bold()
))?;
term.write_line("")?;
Ok(())
}
fn load_deployment_config(project_root: &Path) -> Result<DeploymentConfig> {
let gold_dir = project_root.join("gold");
if !gold_dir.exists() {
return Ok(default_deployment_config());
}
let mut gold_files = Vec::new();
for entry in WalkDir::new(&gold_dir)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("au") {
match GoldParser::parse_file(path) {
Ok(file) => gold_files.push(file),
Err(e) => {
eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
}
}
}
}
if gold_files.is_empty() {
return Ok(default_deployment_config());
}
let project = match Project::from_gold_files(gold_files) {
Ok(p) => p,
Err(e) => {
eprintln!("Warning: Failed to build project model: {}", e);
eprintln!("Using default deployment configuration");
return Ok(default_deployment_config());
}
};
if let Some(deployment) = project.deployment {
let target = match deployment.target.as_str() {
"docker" => DeploymentTarget::Docker,
"systemd" => DeploymentTarget::Systemd,
"daemon" => DeploymentTarget::Daemon,
"local" => DeploymentTarget::Local,
_ => {
eprintln!(
"Warning: Unknown deployment target '{}', using docker",
deployment.target
);
DeploymentTarget::Docker
}
};
let healthcheck =
deployment
.healthcheck
.map(|hc| crate::commands::deploy::config::HealthCheckConfig {
endpoint: hc.endpoint,
interval: hc.interval,
timeout: hc.timeout,
retries: hc.retries,
});
Ok(DeploymentConfig {
target,
persist: deployment.persist,
port: deployment.port,
environment: deployment.environment,
volumes: deployment.volumes,
healthcheck,
restart: deployment.restart,
})
} else {
Ok(default_deployment_config())
}
}
fn default_deployment_config() -> DeploymentConfig {
DeploymentConfig {
target: DeploymentTarget::Docker,
persist: true,
port: Some(8080),
environment: HashMap::new(),
volumes: HashMap::new(),
healthcheck: Some(crate::commands::deploy::config::HealthCheckConfig {
endpoint: "/health".to_string(),
interval: "5s".to_string(),
timeout: "60s".to_string(),
retries: 3,
}),
restart: "unless-stopped".to_string(),
}
}
async fn show_status() -> Result<()> {
let project_root = std::env::current_dir().context("Failed to get current directory")?;
let state = DeploymentState::load(&project_root).context("No deployment state found")?;
match state.target {
DeploymentTarget::Docker => docker::status(&project_root).await,
DeploymentTarget::Daemon => {
#[cfg(target_os = "macos")]
{
launchd::status(&project_root).await
}
#[cfg(not(target_os = "macos"))]
{
Err(anyhow::anyhow!(
"Daemon deployment not supported on this platform"
))
}
}
DeploymentTarget::Systemd => Err(anyhow::anyhow!("Systemd deployment not yet implemented")),
DeploymentTarget::Local => {
println!("Local deployment - check process manually");
Ok(())
}
}
}
async fn stop_deployment() -> Result<()> {
let project_root = std::env::current_dir().context("Failed to get current directory")?;
let state = DeploymentState::load(&project_root).context("No deployment state found")?;
match state.target {
DeploymentTarget::Docker => docker::stop(&project_root).await,
DeploymentTarget::Daemon => {
#[cfg(target_os = "macos")]
{
launchd::stop(&project_root).await
}
#[cfg(not(target_os = "macos"))]
{
Err(anyhow::anyhow!(
"Daemon deployment not supported on this platform"
))
}
}
DeploymentTarget::Systemd => Err(anyhow::anyhow!("Systemd deployment not yet implemented")),
DeploymentTarget::Local => Err(anyhow::anyhow!("Local deployment - stop process manually")),
}
}
async fn show_logs(follow: bool, tail: Option<String>) -> Result<()> {
let project_root = std::env::current_dir().context("Failed to get current directory")?;
let state = DeploymentState::load(&project_root).context("No deployment state found")?;
match state.target {
DeploymentTarget::Docker => docker::logs(&project_root, follow, tail).await,
DeploymentTarget::Daemon => {
#[cfg(target_os = "macos")]
{
launchd::logs(&project_root, follow, tail).await
}
#[cfg(not(target_os = "macos"))]
{
Err(anyhow::anyhow!(
"Daemon deployment not supported on this platform"
))
}
}
DeploymentTarget::Systemd => Err(anyhow::anyhow!("Systemd deployment not yet implemented")),
DeploymentTarget::Local => Err(anyhow::anyhow!("Local deployment - check logs manually")),
}
}
async fn restart_deployment() -> Result<()> {
let project_root = std::env::current_dir().context("Failed to get current directory")?;
let state = DeploymentState::load(&project_root).context("No deployment state found")?;
match state.target {
DeploymentTarget::Docker => docker::restart(&project_root).await,
DeploymentTarget::Daemon => {
#[cfg(target_os = "macos")]
{
launchd::restart(&project_root).await
}
#[cfg(not(target_os = "macos"))]
{
Err(anyhow::anyhow!(
"Daemon deployment not supported on this platform"
))
}
}
DeploymentTarget::Systemd => Err(anyhow::anyhow!("Systemd deployment not yet implemented")),
DeploymentTarget::Local => Err(anyhow::anyhow!(
"Local deployment - restart process manually"
)),
}
}
fn apply_cli_overrides(config: &mut DeploymentConfig, options: &DeployOptions) -> Result<()> {
if let Some(ref target_str) = options.target {
config.target = match target_str.as_str() {
"docker" => DeploymentTarget::Docker,
"systemd" => DeploymentTarget::Systemd,
"daemon" => DeploymentTarget::Daemon,
"local" => DeploymentTarget::Local,
_ => return Err(anyhow::anyhow!("Unknown deployment target: {}", target_str)),
};
}
if let Some(port) = options.port {
config.port = Some(port);
}
for env_pair in &options.env {
if let Some((key, value)) = env_pair.split_once('=') {
config
.environment
.insert(key.to_string(), value.to_string());
} else {
return Err(anyhow::anyhow!(
"Invalid environment variable format: '{}'. Use KEY=VALUE",
env_pair
));
}
}
for volume_pair in &options.volume {
if let Some((host, container)) = volume_pair.split_once(':') {
config
.volumes
.insert(host.to_string(), container.to_string());
} else {
return Err(anyhow::anyhow!(
"Invalid volume format: '{}'. Use HOST:CONTAINER",
volume_pair
));
}
}
Ok(())
}
async fn deploy_with_compose(
project_root: &Path,
config: &DeploymentConfig,
) -> Result<DeploymentState> {
use console::{Term, style};
use std::process::Command;
let term = Term::stdout();
term.write_line("Generating docker-compose.yml...")?;
let compose_content = templates::generate_docker_compose(
project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audb-app"),
config,
);
let compose_path = project_root.join("docker-compose.yml");
std::fs::write(&compose_path, compose_content).context("Failed to write docker-compose.yml")?;
term.write_line(&format!(
" {} docker-compose.yml created",
style("✓").green()
))?;
term.write_line("Starting with docker-compose...")?;
let status = Command::new("docker-compose")
.arg("up")
.arg("-d")
.arg("--build")
.current_dir(project_root)
.status()
.context("Failed to run docker-compose. Is it installed?")?;
if !status.success() {
return Err(anyhow::anyhow!("docker-compose up failed"));
}
term.write_line(&format!(" {} Services started", style("✓").green()))?;
let project_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audb-app");
let state = DeploymentState {
target: config.target,
deployed_at: chrono::Utc::now(),
service_label: format!("{}_app_1", project_name),
project_name: project_name.to_string(),
};
state.save(project_root)?;
Ok(state)
}
async fn deploy_local(project_root: &Path, config: &DeploymentConfig) -> Result<DeploymentState> {
use console::{Term, style};
use std::process::Command;
let term = Term::stdout();
term.write_line("Building project...")?;
let status = Command::new("cargo")
.arg("build")
.arg("--release")
.current_dir(project_root)
.status()
.context("Failed to run cargo build")?;
if !status.success() {
return Err(anyhow::anyhow!("cargo build failed"));
}
term.write_line(&format!(" {} Build successful", style("✓").green()))?;
let binary_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("app");
let workspace_root = project_root
.parent()
.and_then(|p| p.parent())
.unwrap_or(project_root);
let binary_path = workspace_root
.join("target")
.join("release")
.join(binary_name);
if !binary_path.exists() {
return Err(anyhow::anyhow!(
"Binary not found at {}",
binary_path.display()
));
}
term.write_line("")?;
term.write_line(&format!(
"{} Starting server on port {}...",
style("✓").green().bold(),
config.port.unwrap_or(8080)
))?;
term.write_line("")?;
let mut child = Command::new(&binary_path)
.current_dir(project_root)
.spawn()
.context("Failed to start server")?;
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
match child.try_wait() {
Ok(Some(status)) => {
return Err(anyhow::anyhow!(
"Server exited immediately with status: {}",
status
));
}
Ok(None) => {
term.write_line(&format!(
"{} Server started successfully (PID: {})",
style("✓").green(),
child.id()
))?;
term.write_line("")?;
term.write_line(&format!(
"Access your API at: http://localhost:{}",
config.port.unwrap_or(8080)
))?;
term.write_line("")?;
term.write_line(&format!(
"{}",
style("Press Ctrl+C to stop the server").dim()
))?;
term.write_line("")?;
let status = child.wait().context("Failed to wait for server process")?;
if !status.success() {
return Err(anyhow::anyhow!("Server exited with status: {}", status));
}
}
Err(e) => {
return Err(anyhow::anyhow!("Failed to check server status: {}", e));
}
}
let project_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audb-app");
let state = DeploymentState {
target: config.target,
deployed_at: chrono::Utc::now(),
service_label: format!("local-{}", project_name),
project_name: project_name.to_string(),
};
state.save(project_root)?;
Ok(state)
}