use crate::commands::deploy::config::{DeploymentConfig, DeploymentTarget};
use crate::commands::deploy::state::DeploymentState;
use crate::commands::deploy::templates;
use anyhow::{Context, Result, anyhow};
use bollard::API_DEFAULT_VERSION;
use bollard::Docker;
use bollard::container::LogOutput;
use bollard::models::{ContainerCreateBody, ContainerStateStatusEnum, HostConfig, PortBinding};
use futures_util::stream::StreamExt;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
pub async fn deploy(
project_root: &Path,
config: &DeploymentConfig,
force_rebuild: bool,
) -> Result<DeploymentState> {
println!("Starting Docker deployment...");
if config.target != DeploymentTarget::Docker {
return Err(anyhow!("Invalid deployment target for Docker"));
}
let project_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audb-app");
let image_name = format!("{}:latest", project_name);
let container_name = format!("{}-container", project_name);
let port = config.port.unwrap_or(8080);
let workspace_root = find_workspace_root(project_root)?;
let is_workspace_member = workspace_root != project_root;
if is_workspace_member {
println!("Detected workspace project - will copy entire workspace for Docker build");
}
let docker = Docker::connect_with_local_defaults()
.context("Failed to connect to Docker daemon. Is Docker running?")?;
docker
.ping()
.await
.context("Docker daemon is not responding")?;
println!("Connected to Docker daemon");
println!("Building release binary...");
build_release_binary(project_root)?;
if !is_workspace_member {
let dockerfile_path = project_root.join("Dockerfile");
if force_rebuild || !dockerfile_path.exists() {
println!("Generating Dockerfile...");
let dockerfile_content = templates::generate_dockerfile(project_name, config);
fs::write(&dockerfile_path, dockerfile_content)
.context("Failed to write Dockerfile")?;
println!("Dockerfile created at {}", dockerfile_path.display());
}
let dockerignore_path = project_root.join(".dockerignore");
if !dockerignore_path.exists() {
let dockerignore_content = templates::generate_dockerignore();
fs::write(&dockerignore_path, dockerignore_content)
.context("Failed to write .dockerignore")?;
}
}
println!("Building Docker image '{}'...", image_name);
if is_workspace_member {
build_docker_image_workspace(&docker, project_root, &workspace_root, &image_name).await?;
} else {
build_docker_image(&docker, project_root, &image_name).await?;
}
if let Ok(existing_state) = DeploymentState::load(project_root) {
if existing_state.target == DeploymentTarget::Docker {
println!(
"Stopping existing container '{}'...",
existing_state.service_label
);
let _ = stop_container_by_id(&docker, &existing_state.service_label).await;
}
}
println!("Creating container '{}'...", container_name);
let container_id = create_container(
&docker,
&image_name,
&container_name,
port,
&config.environment,
&config.volumes,
)
.await?;
println!("Starting container '{}'...", container_id);
start_container(&docker, &container_id).await?;
if let Some(health_check) = &config.healthcheck {
println!("Waiting for health check...");
perform_health_check(health_check, port).await?;
println!("Health check passed");
}
let state = DeploymentState {
target: config.target,
deployed_at: chrono::Utc::now(),
service_label: container_id.clone(),
project_name: project_name.to_string(),
};
state.save(project_root)?;
println!("\nDeployment successful!");
println!("Container ID: {}", container_id);
println!("Container name: {}", container_name);
println!("Port: {}", port);
println!("\nView logs with: au deploy logs");
println!("Stop with: au deploy stop");
Ok(state)
}
fn build_release_binary(project_root: &Path) -> Result<()> {
let target = if cfg!(target_os = "macos") {
if cfg!(target_arch = "aarch64") {
Some("aarch64-unknown-linux-gnu")
} else {
Some("x86_64-unknown-linux-gnu")
}
} else {
None
};
if let Some(target_triple) = target {
let arch = if target_triple.starts_with("aarch64") {
"ARM64"
} else {
"x86_64"
};
println!("Detected macOS - cross-compiling for Linux ({})...", arch);
let cross_check = Command::new("cross").arg("--version").output();
let use_cross = cross_check.is_ok();
if use_cross {
println!("Using 'cross' for cross-compilation...");
let status = Command::new("cross")
.arg("build")
.arg("--release")
.arg("--target")
.arg(target_triple)
.env("OPENSSL_STATIC", "1")
.env("OPENSSL_VENDORED", "1")
.current_dir(project_root)
.status()
.context("Failed to run cross build")?;
if !status.success() {
return Err(anyhow!("Cross build failed"));
}
} else {
println!("'cross' not found. Install it with: cargo install cross");
println!("Attempting native cargo build with target...");
let status = Command::new("cargo")
.arg("build")
.arg("--release")
.arg("--target")
.arg(target_triple)
.current_dir(project_root)
.status()
.context("Failed to run cargo build")?;
if !status.success() {
return Err(anyhow!(
"Cargo build failed. Install 'cross' for easier cross-compilation: cargo install cross"
));
}
}
} else {
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!("Cargo build failed"));
}
}
Ok(())
}
fn find_workspace_root(project_root: &Path) -> Result<PathBuf> {
let mut current = project_root;
let mut workspace_root = None;
loop {
let cargo_toml = current.join("Cargo.toml");
if cargo_toml.exists() {
if let Ok(content) = fs::read_to_string(&cargo_toml) {
if content.contains("[workspace]") {
workspace_root = Some(current.to_path_buf());
break;
}
}
}
match current.parent() {
Some(parent) => current = parent,
None => return Ok(project_root.to_path_buf()), }
}
if let Some(ws_root) = workspace_root {
if let Some(parent) = ws_root.parent() {
if let Ok(entries) = fs::read_dir(parent) {
let mut workspace_count = 0;
for entry in entries.flatten() {
if entry.path().is_dir() {
let cargo_toml = entry.path().join("Cargo.toml");
if cargo_toml.exists() {
if let Ok(content) = fs::read_to_string(&cargo_toml) {
if content.contains("[workspace]") {
workspace_count += 1;
}
}
}
}
}
if workspace_count > 1 {
println!(
"Detected monorepo with {} sibling workspaces",
workspace_count
);
return Ok(parent.to_path_buf());
}
}
}
Ok(ws_root)
} else {
Ok(project_root.to_path_buf())
}
}
async fn build_docker_image(docker: &Docker, project_root: &Path, image_name: &str) -> Result<()> {
let tar_path = project_root.join(".audb").join("build-context.tar");
fs::create_dir_all(tar_path.parent().unwrap())?;
create_build_context_tar(project_root, &tar_path)?;
let tar_data = fs::read(&tar_path).context("Failed to read build context tar")?;
use bytes::Bytes;
use http_body_util::{Either, Full};
let body = Full::new(Bytes::from(tar_data));
let mut stream = docker.build_image(
bollard::query_parameters::BuildImageOptions {
t: Some(image_name.to_string()),
dockerfile: "Dockerfile".to_string(),
rm: true,
..Default::default()
},
None,
Some(Either::Left(body)),
);
while let Some(msg) = stream.next().await {
let info = msg.context("Docker build error")?;
if let Some(stream) = info.stream {
print!("{}", stream);
}
if let Some(error) = info.error {
return Err(anyhow!("Docker build failed: {}", error));
}
if let Some(status) = info.status {
if !status.is_empty() {
println!("{}", status);
}
}
}
let _ = fs::remove_file(tar_path);
println!("Docker image '{}' built successfully", image_name);
Ok(())
}
async fn build_docker_image_workspace(
docker: &Docker,
project_root: &Path,
workspace_root: &Path,
image_name: &str,
) -> Result<()> {
let rel_project_path = project_root
.strip_prefix(workspace_root)
.context("Failed to calculate relative path")?;
let dockerfile_content = generate_workspace_dockerfile(
project_root.file_name().unwrap().to_str().unwrap(),
rel_project_path.to_str().unwrap(),
);
let dockerfile_path = workspace_root.join("Dockerfile.workspace");
fs::write(&dockerfile_path, dockerfile_content)?;
let tar_path = project_root.join(".audb").join("build-context.tar");
fs::create_dir_all(tar_path.parent().unwrap())?;
println!(
"Creating workspace build context from: {}",
workspace_root.display()
);
create_workspace_build_context_tar(workspace_root, &tar_path)?;
let tar_data = fs::read(&tar_path).context("Failed to read build context tar")?;
use bytes::Bytes;
use http_body_util::{Either, Full};
let body = Full::new(Bytes::from(tar_data));
let mut stream = docker.build_image(
bollard::query_parameters::BuildImageOptions {
t: Some(image_name.to_string()),
dockerfile: "Dockerfile.workspace".to_string(),
rm: true,
..Default::default()
},
None,
Some(Either::Left(body)),
);
while let Some(msg) = stream.next().await {
let info = msg.context("Docker build error")?;
if let Some(stream) = info.stream {
print!("{}", stream);
}
if let Some(error) = info.error {
return Err(anyhow!("Docker build failed: {}", error));
}
if let Some(status) = info.status {
if !status.is_empty() {
println!("{}", status);
}
}
}
let _ = fs::remove_file(tar_path);
let _ = fs::remove_file(dockerfile_path);
println!("Docker image '{}' built successfully", image_name);
Ok(())
}
fn generate_workspace_dockerfile(project_name: &str, rel_project_path: &str) -> String {
let (binary_path, platform) = if cfg!(target_os = "macos") {
if cfg!(target_arch = "aarch64") {
(
format!(
"audb/target/aarch64-unknown-linux-gnu/release/{}",
project_name
),
"linux/arm64",
)
} else {
(
format!(
"audb/target/x86_64-unknown-linux-gnu/release/{}",
project_name
),
"linux/amd64",
)
}
} else {
(
format!("audb/target/release/{}", project_name),
"linux/amd64",
)
};
format!(
r#"# Runtime-only Dockerfile for workspace project: {project_name}
# Generated by AuDB
# Uses pre-built binary from host
FROM --platform={platform} debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && \
apt-get install -y ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Create app user
RUN useradd -m -u 1000 app
WORKDIR /app
# Copy pre-built binary from workspace target directory
COPY {binary_path} /app/{project_name}
RUN chmod +x /app/{project_name}
# Create data directory
RUN mkdir -p /app/data && chown -R app:app /app
# Switch to app user
USER app
# Expose port
EXPOSE 8080
# Set environment
ENV RUST_LOG=info
# Run the application
CMD ["/app/{project_name}"]
"#,
project_name = project_name,
binary_path = binary_path,
platform = platform
)
}
fn create_workspace_build_context_tar(workspace_root: &Path, tar_path: &Path) -> Result<()> {
use std::process::Command;
let required_dirs = ["audb", "manifold", "hyperQL", "tessera"];
let mut tar_cmd = Command::new("tar");
tar_cmd
.arg("czf")
.arg(tar_path)
.arg("--exclude=target/debug")
.arg("--exclude=target/release")
.arg("--exclude=.git")
.arg("--exclude=.audb")
.arg("--exclude=node_modules")
.arg("--exclude=.venv")
.arg("--exclude=.DS_Store")
.arg("--exclude=*.tar")
.arg("--exclude=*.tar.gz")
.arg("--no-xattrs") .arg("-C")
.arg(workspace_root);
for dir in &required_dirs {
let dir_path = workspace_root.join(dir);
if dir_path.exists() {
tar_cmd.arg(dir);
}
}
let cross_target_dir = workspace_root.join("target/x86_64-unknown-linux-gnu/release");
if cross_target_dir.exists() {
tar_cmd.arg("target/x86_64-unknown-linux-gnu/release");
}
tar_cmd.arg("Dockerfile.workspace");
let status = tar_cmd
.status()
.context("Failed to create workspace tar archive")?;
if !status.success() {
return Err(anyhow!("Failed to create workspace build context tar"));
}
println!("Workspace context created ({})", humanize_size(tar_path)?);
Ok(())
}
fn create_build_context_tar(project_root: &Path, tar_path: &Path) -> Result<()> {
use std::process::Command;
let status = Command::new("tar")
.arg("czf")
.arg(tar_path)
.arg("--exclude=target")
.arg("--exclude=.git")
.arg("--exclude=.audb")
.arg("--exclude=node_modules")
.arg("--no-xattrs") .arg("-C")
.arg(project_root)
.arg(".")
.status()
.context("Failed to create tar archive")?;
if !status.success() {
return Err(anyhow!("Failed to create build context tar"));
}
Ok(())
}
fn humanize_size(path: &Path) -> Result<String> {
let metadata = fs::metadata(path)?;
let bytes = metadata.len();
if bytes < 1024 {
Ok(format!("{} B", bytes))
} else if bytes < 1024 * 1024 {
Ok(format!("{:.1} KB", bytes as f64 / 1024.0))
} else if bytes < 1024 * 1024 * 1024 {
Ok(format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)))
} else {
Ok(format!(
"{:.1} GB",
bytes as f64 / (1024.0 * 1024.0 * 1024.0)
))
}
}
async fn create_container(
docker: &Docker,
image_name: &str,
container_name: &str,
port: u16,
env_vars: &HashMap<String, String>,
volumes: &HashMap<String, String>,
) -> Result<String> {
let env: Vec<String> = env_vars
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
let mut port_bindings = HashMap::new();
let container_port = format!("{}/tcp", port);
port_bindings.insert(
container_port,
Some(vec![PortBinding {
host_ip: Some("0.0.0.0".to_string()),
host_port: Some(port.to_string()),
}]),
);
let bindings: Vec<String> = volumes
.iter()
.map(|(src, dst)| format!("{}:{}", src, dst))
.collect();
let host_config = HostConfig {
port_bindings: Some(port_bindings),
binds: if bindings.is_empty() {
None
} else {
Some(bindings)
},
restart_policy: Some(bollard::models::RestartPolicy {
name: Some(bollard::models::RestartPolicyNameEnum::UNLESS_STOPPED),
maximum_retry_count: None,
}),
..Default::default()
};
let config = ContainerCreateBody {
image: Some(image_name.to_string()),
env: if env.is_empty() { None } else { Some(env) },
host_config: Some(host_config),
..Default::default()
};
let response = docker
.create_container(
Some(bollard::query_parameters::CreateContainerOptions {
name: Some(container_name.to_string()),
..Default::default()
}),
config,
)
.await
.context("Failed to create container")?;
Ok(response.id)
}
async fn start_container(docker: &Docker, container_id: &str) -> Result<()> {
docker
.start_container(
container_id,
None::<bollard::query_parameters::StartContainerOptions>,
)
.await
.context("Failed to start container")?;
Ok(())
}
pub async fn stop(project_root: &Path) -> Result<()> {
let state = DeploymentState::load(project_root).context("No deployment state found")?;
if state.target != DeploymentTarget::Docker {
return Err(anyhow!("Not a Docker deployment"));
}
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
println!("Stopping container '{}'...", state.service_label);
stop_container_by_id(&docker, &state.service_label).await?;
println!("Container stopped and removed");
let state_path = project_root.join(".audb").join("deploy.toml");
if state_path.exists() {
fs::remove_file(&state_path).context("Failed to remove state file")?;
}
Ok(())
}
async fn stop_container_by_id(docker: &Docker, container_id: &str) -> Result<()> {
let _ = docker
.stop_container(
container_id,
None::<bollard::query_parameters::StopContainerOptions>,
)
.await
.context("Failed to stop container");
docker
.remove_container(
container_id,
Some(bollard::query_parameters::RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.context("Failed to remove container")?;
Ok(())
}
pub async fn status(project_root: &Path) -> Result<()> {
let state = DeploymentState::load(project_root).context("No deployment state found")?;
if state.target != DeploymentTarget::Docker {
return Err(anyhow!("Not a Docker deployment"));
}
println!("Service: {}", state.service_label);
println!("Project: {}", state.project_name);
println!(
"Deployed at: {}",
state.deployed_at.format("%Y-%m-%d %H:%M:%S UTC")
);
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
let container = docker
.inspect_container(
&state.service_label,
None::<bollard::query_parameters::InspectContainerOptions>,
)
.await
.context("Failed to inspect container")?;
if let Some(name) = container.name {
println!("Container: {}", name);
}
if let Some(config) = container.config {
if let Some(image) = config.image {
println!("Image: {}", image);
}
}
if let Some(container_state) = container.state {
let status = container_state
.status
.unwrap_or(ContainerStateStatusEnum::EMPTY);
println!("Status: {:?}", status);
if let Some(running) = container_state.running {
println!("Running: {}", running);
}
if let Some(started_at) = container_state.started_at {
println!("Started at: {}", started_at);
}
}
Ok(())
}
pub async fn logs(project_root: &Path, follow: bool, tail: Option<String>) -> Result<()> {
let state = DeploymentState::load(project_root).context("No deployment state found")?;
if state.target != DeploymentTarget::Docker {
return Err(anyhow!("Not a Docker deployment"));
}
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
println!("Fetching logs for container '{}'...\n", state.project_name);
let options = bollard::query_parameters::LogsOptions {
follow: follow,
stdout: true,
stderr: true,
tail: tail.unwrap_or_else(|| "100".to_string()),
..Default::default()
};
let mut stream = docker.logs(&state.service_label, Some(options));
while let Some(log) = stream.next().await {
match log {
Ok(LogOutput::StdOut { message }) => {
print!("{}", String::from_utf8_lossy(&message));
}
Ok(LogOutput::StdErr { message }) => {
eprint!("{}", String::from_utf8_lossy(&message));
}
Ok(_) => {}
Err(e) => {
eprintln!("Error reading logs: {}", e);
break;
}
}
}
Ok(())
}
pub async fn restart(project_root: &Path) -> Result<()> {
let state = DeploymentState::load(project_root).context("No deployment state found")?;
if state.target != DeploymentTarget::Docker {
return Err(anyhow!("Not a Docker deployment"));
}
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
println!("Restarting container '{}'...", state.service_label);
docker
.restart_container(
&state.service_label,
None::<bollard::query_parameters::RestartContainerOptions>,
)
.await
.context("Failed to restart container")?;
println!("Container restarted successfully");
Ok(())
}
async fn perform_health_check(
health_check: &crate::commands::deploy::config::HealthCheckConfig,
port: u16,
) -> Result<()> {
use std::time::Duration;
use tokio::time::sleep;
let url = format!("http://localhost:{}{}", port, health_check.endpoint);
let client = reqwest::Client::new();
let timeout_secs: u64 = health_check
.timeout
.trim_end_matches('s')
.parse()
.unwrap_or(60);
let interval_secs: u64 = health_check
.interval
.trim_end_matches('s')
.parse()
.unwrap_or(5);
let mut attempts = 0;
let max_attempts = timeout_secs / interval_secs;
loop {
attempts += 1;
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
return Ok(());
} else {
println!(
"Health check returned status: {} (attempt {}/{})",
response.status(),
attempts,
max_attempts
);
}
}
Err(e) => {
println!(
"Health check failed: {} (attempt {}/{})",
e, attempts, max_attempts
);
}
}
if attempts >= max_attempts {
return Err(anyhow!(
"Health check timed out after {} attempts",
attempts
));
}
sleep(Duration::from_secs(interval_secs)).await;
}
}