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