rolling_deployer/
cli.rs

1use crate::config::Config;
2use crate::deployment_manager::DeploymentManager;
3use clap::Parser;
4use std::collections::HashMap;
5use tracing::{debug, error, info};
6use tracing_subscriber;
7
8#[derive(Parser)]
9pub struct CLI {
10    #[arg(value_name = "TAG", index = 1)]
11    pub tag: String,
12    #[arg(short, long)]
13    pub name: Option<String>,
14    #[arg(short, long, default_value = "/var/run/docker.sock")]
15    pub socket_path: String,
16    #[arg(short, long)]
17    pub repo_url: Option<String>,
18    #[arg(short, long, help = "Path to clone the config repo into")]
19    pub clone_path: Option<String>,
20    #[arg(
21        long,
22        help = "Target path in the container to mount the config (e.g. /etc/traefik/dynamic)"
23    )]
24    pub mount_path: Option<String>,
25    #[arg(short, long, action = clap::ArgAction::Count, help = "Increase verbosity (-v, -vv, etc.)")]
26    pub verbose: u8,
27    #[arg(long, default_value = "docker-compose.yml")]
28    pub compose_file: String,
29    #[arg(
30        short = 'e',
31        long = "env-file",
32        default_value = ".env",
33        help = "Path to .env file"
34    )]
35    pub env_file: String,
36    #[arg(long, help = "Use Docker Swarm mode")]
37    pub swarm: bool,
38}
39
40// Main application logic
41pub async fn deploy(mut cli: CLI) {
42    // Allow tests to skip real deployment logic
43    if std::env::var("SKIP_DEPLOY").ok().as_deref() == Some("1") {
44        tracing::info!("Skipping real deployment for test");
45        return;
46    }
47
48    // Load .env file if present and fill missing CLI fields
49    let env_content = match std::fs::read_to_string(&cli.env_file) {
50        Ok(content) => {
51            let mut env_content = HashMap::new();
52            for line in content.lines() {
53                let line = line.trim();
54                if line.is_empty() || line.starts_with('#') {
55                    continue;
56                }
57                if let Some((key, value)) = line.split_once('=') {
58                    env_content.insert(key.trim().to_string(), value.trim().to_string());
59                }
60            }
61            env_content
62        }
63        Err(e) => {
64            error!("Error reading .env file: {}", e);
65            HashMap::new()
66        }
67    };
68
69    debug!("env_content: {:?}", env_content);
70
71    // Use the helper for Option<String> fields
72    let name = extract_env_var_from_cli_or_env(&cli.name, &env_content, "NAME", "");
73    let repo_url = extract_env_var_from_cli_or_env(&cli.repo_url, &env_content, "REPO_URL", "");
74    let clone_path =
75        extract_env_var_from_cli_or_env(&cli.clone_path, &env_content, "CLONE_PATH", "/opt/dev");
76    let mount_path =
77        extract_env_var_from_cli_or_env(&cli.mount_path, &env_content, "MOUNT_PATH", "");
78
79    // For String fields with a default, use env_content if the value is still the default
80    let socket_path = if cli.socket_path == "/var/run/docker.sock" {
81        env_content
82            .get("SOCKET_PATH")
83            .cloned()
84            .unwrap_or_else(|| cli.socket_path.clone())
85    } else {
86        cli.socket_path.clone()
87    };
88    let compose_file = if cli.compose_file == "docker-compose.yml" {
89        env_content
90            .get("COMPOSE_FILE")
91            .cloned()
92            .unwrap_or_else(|| cli.compose_file.clone())
93    } else {
94        cli.compose_file.clone()
95    };
96
97    // Update CLI struct for downstream config loading
98    cli.name = if name.is_empty() { None } else { Some(name) };
99    cli.repo_url = if repo_url.is_empty() {
100        None
101    } else {
102        Some(repo_url)
103    };
104    cli.clone_path = if clone_path.is_empty() {
105        None
106    } else {
107        Some(clone_path)
108    };
109    cli.mount_path = if mount_path.is_empty() {
110        None
111    } else {
112        Some(mount_path)
113    };
114    cli.socket_path = socket_path;
115    cli.compose_file = compose_file;
116
117    // Ensure mount_path is set from CLI or .env
118    if cli.mount_path.is_none() {
119        error!("MOUNT_PATH must be set via --mount-path or in the .env file");
120        eprintln!("MOUNT_PATH must be set via --mount-path or in the .env file");
121        return;
122    }
123    tracing::debug!("Mount path from CLI: {:?}", cli.mount_path);
124
125    // Load configuration from CLI args and/or .env file
126    let config = match Config::from_env_and_cli(&cli) {
127        Ok(config) => {
128            // Set up logging based on config.verbose
129            let filter = match cli.verbose {
130                0 => "warn",
131                1 => "info",
132                2 => "debug",
133                _ => "trace",
134            };
135            tracing_subscriber::fmt()
136                .with_env_filter(tracing_subscriber::EnvFilter::new(filter))
137                .init();
138            info!("Configuration loaded:");
139            info!("  Repository: {}", config.repo_url);
140            info!("  Clone path: {}", config.clone_path);
141            info!("  Mount path: {}", config.mount_path);
142            config
143        }
144        Err(e) => {
145            error!("Configuration error: {}", e);
146            info!("");
147            Config::show_configuration_help();
148            return;
149        }
150    };
151
152    let deployment_manager = DeploymentManager::new(config.clone());
153
154    info!(
155        "Starting deployment for project '{}' with tag '{}'",
156        config.name, cli.tag
157    );
158
159    match deployment_manager.rolling_deploy(&cli.tag, cli.swarm).await {
160        Ok(()) => info!("Rolling deployment successful!"),
161        Err(e) => error!("Rolling deployment failed: {}", e),
162    }
163}
164
165// Extracts a value from the CLI or .env file, preferring the CLI value if present
166fn extract_env_var_from_cli_or_env<V: ToString + PartialEq + Default>(
167    val: &Option<V>,
168    env_content: &HashMap<String, String>,
169    key: &str,
170    default_value: &str,
171) -> String {
172    let val_str = val.as_ref().unwrap_or(&V::default()).to_string();
173    if val_str != V::default().to_string() && val_str != default_value {
174        // If the CLI value is set and not the default, use it
175        val_str
176    } else if let Some(env_val) = env_content.get(key) {
177        // Otherwise, use the value from the env_content HashMap if present
178        env_val.clone()
179    } else {
180        // Otherwise, return None (caller can use default if needed)
181        default_value.to_string()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use std::sync::Once;
189    use tokio::runtime::Runtime;
190
191    static INIT: Once = Once::new();
192
193    fn setup() {
194        INIT.call_once(|| {
195            // Set up any global state, env vars, etc. if needed
196        });
197    }
198
199    #[test]
200    fn test_deploy_missing_name() {
201        setup();
202        let cli = CLI {
203            tag: "v1.0.0".to_string(),
204            name: None,
205            socket_path: "/tmp/docker.sock".to_string(),
206            repo_url: Some("https://example.com/repo.git".to_string()),
207            clone_path: Some("/tmp/mount".to_string()),
208            mount_path: None,
209            verbose: 0,
210            compose_file: "docker-compose.yml".to_string(),
211            env_file: ".env".to_string(),
212            swarm: false,
213        };
214        let rt = Runtime::new().unwrap();
215        rt.block_on(async {
216            deploy(cli).await;
217        });
218        // This test just ensures no panic and covers the error path for missing name
219    }
220
221    #[test]
222    fn test_extract_env_var_from_cli_or_env_cli_value() {
223        let env_content = std::collections::HashMap::new();
224        let cli_val = Some("cli_val".to_string());
225        let result =
226            super::extract_env_var_from_cli_or_env(&cli_val, &env_content, "KEY", "default");
227        assert_eq!(result, "cli_val");
228    }
229
230    #[test]
231    fn test_extract_env_var_from_cli_or_env_env_value() {
232        let mut env_content = std::collections::HashMap::new();
233        env_content.insert("KEY".to_string(), "env_val".to_string());
234        let cli_val: Option<String> = None;
235        let result =
236            super::extract_env_var_from_cli_or_env(&cli_val, &env_content, "KEY", "default");
237        assert_eq!(result, "env_val");
238    }
239
240    #[test]
241    fn test_extract_env_var_from_cli_or_env_both_values() {
242        let mut env_content = std::collections::HashMap::new();
243        env_content.insert("KEY".to_string(), "env_val".to_string());
244        let cli_val = Some("cli_val".to_string());
245        let result =
246            super::extract_env_var_from_cli_or_env(&cli_val, &env_content, "KEY", "default");
247        assert_eq!(result, "cli_val");
248    }
249
250    #[test]
251    fn test_extract_env_var_from_cli_or_env_default() {
252        let env_content = std::collections::HashMap::new();
253        let cli_val: Option<String> = None;
254        let result =
255            super::extract_env_var_from_cli_or_env(&cli_val, &env_content, "KEY", "default");
256        assert_eq!(result, "default");
257    }
258}