Skip to main content

rolling_deployer/
deployment_manager.rs

1use crate::{config::Config, docker_client::DockerClient, git_client::GitClient};
2use serde_yaml::Value;
3use std::path::Path;
4
5pub struct DeploymentManager {
6    docker: DockerClient,
7    git: GitClient,
8    config: Config,
9}
10
11impl DeploymentManager {
12    pub fn new(config: Config) -> Self {
13        Self {
14            docker: DockerClient::new(config.socket_path.clone()),
15            git: GitClient,
16            config,
17        }
18    }
19
20    /// Robustly extract the service name from a container.
21    /// Prefer the Docker Compose label if present, otherwise parse the container name.
22    fn extract_service_name(container: &crate::types::Container) -> String {
23        // Try Docker Compose label first
24        if let Some(labels) = &container.labels {
25            if let Some(service) = labels.get("com.docker.compose.service") {
26                return service.clone();
27            }
28        }
29        // Fallback: parse from container name
30        if let Some(name) = container.names.get(0) {
31            let name = name.trim_start_matches('/');
32            // Try underscore split (compose v2 default: <project>_<service>_<index>)
33            let underscore_parts: Vec<&str> = name.split('_').collect();
34            if underscore_parts.len() >= 3 {
35                return underscore_parts[underscore_parts.len() - 2].to_string();
36            }
37            // Try dash split (older compose: <something>-<service>-<index>)
38            let dash_parts: Vec<&str> = name.split('-').collect();
39            if dash_parts.len() >= 2 {
40                // If last part is a number, use the one before it
41                if dash_parts.last().unwrap().parse::<u32>().is_ok() {
42                    return dash_parts[dash_parts.len() - 2].to_string();
43                }
44            }
45            // Fallback: just return the name
46            return name.to_string();
47        }
48        // If all else fails, empty string
49        String::new()
50    }
51
52    fn update_compose_file_volume_source(
53        compose_file: &str,
54        symlink_path: &str,
55        mount_path: &str,
56    ) -> Result<(), Box<dyn std::error::Error>> {
57        let content = std::fs::read_to_string(compose_file)?;
58        let mut doc: Value = serde_yaml::from_str(&content)?;
59        let mut replaced = false;
60
61        if let Some(services) = doc.get_mut("services").and_then(Value::as_mapping_mut) {
62            for (_svc_name, svc) in services.iter_mut() {
63                if let Some(vols) = svc.get_mut("volumes").and_then(Value::as_sequence_mut) {
64                    // Try to find and update an existing mapping
65                    for vol in vols.iter_mut() {
66                        // Handle string form: "host:container[:mode]"
67                        if let Some(s) = vol.as_str() {
68                            let parts: Vec<&str> = s.split(':').collect();
69                            if parts.len() >= 2 {
70                                let target = parts[1];
71                                if target == mount_path && !replaced {
72                                    // preserve mode if present
73                                    let mut new_vol = format!("{}:{}", symlink_path, mount_path);
74                                    if parts.len() > 2 {
75                                        new_vol.push(':');
76                                        new_vol.push_str(parts[2]);
77                                    }
78                                    *vol = Value::String(new_vol);
79                                    replaced = true;
80                                }
81                            }
82                        }
83                        // Handle map form (YAML 1.2): {type: bind, source: ..., target: ...}
84                        else if let Some(map) = vol.as_mapping_mut() {
85                            if let Some(target) = map
86                                .get(&Value::String("target".to_string()))
87                                .and_then(Value::as_str)
88                            {
89                                if target == mount_path && !replaced {
90                                    map.insert(
91                                        Value::String("source".to_string()),
92                                        Value::String(symlink_path.to_string()),
93                                    );
94                                    replaced = true;
95                                }
96                            }
97                        }
98                        if replaced {
99                            break;
100                        }
101                    }
102                    // If not found, add a new mapping
103                    if !replaced {
104                        // Default to rw mode
105                        let new_vol = format!("{}:{}:rw", symlink_path, mount_path);
106                        vols.push(Value::String(new_vol));
107                        replaced = true;
108                    }
109                }
110                if replaced {
111                    break;
112                }
113            }
114        }
115        if replaced {
116            let updated = serde_yaml::to_string(&doc)?;
117            std::fs::write(compose_file, updated)?;
118        }
119        Ok(())
120    }
121
122    pub async fn rolling_deploy(
123        &self,
124        tag: &str,
125        swarm: bool,
126    ) -> Result<(), Box<dyn std::error::Error>> {
127        let config = &self.config;
128        println!(
129            "Starting rolling deployment for project '{}' with tag '{}'",
130            config.name, tag
131        );
132
133        // 1. Clone the new configuration to a versioned directory
134        let symlink_path = self
135            .git
136            .clone_repository_to_versioned_path(&config.repo_url, tag, &config.clone_path)
137            .await?;
138
139        // 1.5. Update the compose file to use the new config path as the volume source
140        // NOTE: You must add serde_yaml = "*" to Cargo.toml
141        Self::update_compose_file_volume_source(
142            &config.compose_file,
143            &symlink_path,
144            &config.mount_path,
145        )?;
146
147        if swarm {
148            let service = &config.name;
149            println!(
150                "Swarm mode: updating service '{}' mount to new config path.",
151                service
152            );
153            let add_arg = format!("type=bind,src={},dst={}", symlink_path, config.mount_path);
154            let status = std::process::Command::new("docker")
155                .args([
156                    "service",
157                    "update",
158                    "--mount-rm",
159                    &config.mount_path,
160                    "--mount-add",
161                    &add_arg,
162                    service,
163                ])
164                .status()?;
165            if !status.success() {
166                return Err(format!("docker service update failed for service {}", service).into());
167            }
168            println!("Successfully updated service '{}' in Swarm mode.", service);
169        } else {
170            // 2. Find running Traefik containers for this project
171            let running_containers = self
172                .docker
173                .get_running_containers_by_image_substring(&config.name)
174                .await?;
175
176            if running_containers.is_empty() {
177                return Err(
178                    format!("No running containers found for project '{}'", config.name).into(),
179                );
180            }
181
182            println!(
183                "Found {} running Traefik containers",
184                running_containers.len()
185            );
186
187            // 3. For each running container, recreate the service
188            for (_index, container) in running_containers.iter().enumerate() {
189                let service_name = Self::extract_service_name(container);
190                println!("Rolling service: {}", service_name);
191
192                // Determine the absolute path to the compose file
193                let compose_file_abs = std::fs::canonicalize(&config.compose_file)?;
194                let compose_dir = compose_file_abs.parent().unwrap_or_else(|| Path::new("."));
195
196                // Check if the directory exists
197                if !compose_dir.exists() {
198                    return Err(format!(
199                        "Compose directory does not exist: {}",
200                        compose_dir.display()
201                    )
202                    .into());
203                }
204
205                // Run docker compose up -d --force-recreate <service> in the compose file's directory
206                let status = std::process::Command::new("docker")
207                    .args([
208                        "compose",
209                        "-f",
210                        compose_file_abs.to_str().unwrap(),
211                        "up",
212                        "-d",
213                        "--force-recreate",
214                        service_name.as_str(),
215                    ])
216                    .current_dir(compose_dir)
217                    .status()?;
218
219                if !status.success() {
220                    return Err(
221                        format!("docker compose up failed for service {}", service_name).into(),
222                    );
223                }
224
225                println!("Successfully rolled {} to new version", service_name);
226            }
227        }
228
229        // 4. Clean up old config directories (keep last 3 versions)
230        self.cleanup_old_configs(&config.clone_path, 3).await?;
231
232        println!("Rolling deployment completed successfully!");
233        Ok(())
234    }
235
236    async fn cleanup_old_configs(
237        &self,
238        base_path: &str,
239        keep_versions: usize,
240    ) -> Result<(), Box<dyn std::error::Error>> {
241        let mut config_dirs = Vec::new();
242
243        if let Ok(entries) = std::fs::read_dir(base_path) {
244            for entry in entries {
245                if let Ok(entry) = entry {
246                    let path = entry.path();
247                    if path.is_dir() {
248                        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
249                            if name.starts_with("traefik-config-") {
250                                config_dirs.push(path);
251                            }
252                        }
253                    }
254                }
255            }
256        }
257
258        // Sort by creation time (newest first)
259        config_dirs.sort_by_key(|path| {
260            std::fs::metadata(path)
261                .and_then(|m| m.created())
262                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
263        });
264        config_dirs.reverse();
265
266        // Remove old versions beyond the keep limit
267        for old_config in config_dirs.iter().skip(keep_versions) {
268            println!("Cleaning up old config: {:?}", old_config);
269            if let Err(e) = std::fs::remove_dir_all(old_config) {
270                eprintln!("Failed to remove old config {:?}: {}", old_config, e);
271            }
272        }
273
274        Ok(())
275    }
276
277    pub async fn rollback(
278        &self,
279        project_name: &str,
280        tag: &str,
281        config: &Config,
282        swarm: bool,
283    ) -> Result<(), Box<dyn std::error::Error>> {
284        println!(
285            "Starting rollback of project '{}' to tag '{}'",
286            project_name, tag
287        );
288
289        // Check if the target version already exists
290        let target_config_path = format!("{}/traefik-config-{}", config.clone_path, tag);
291
292        if !std::path::Path::new(&target_config_path).exists() {
293            // If the config doesn't exist locally, clone it
294            println!("Target config not found locally, cloning...");
295            self.git
296                .clone_repository_to_versioned_path(&config.repo_url, tag, &config.clone_path)
297                .await?;
298        } else {
299            println!("Using existing config at {}", target_config_path);
300        }
301
302        // Perform rolling deployment to the target tag
303        self.rolling_deploy(tag, swarm).await?;
304
305        println!("Rollback completed successfully!");
306        Ok(())
307    }
308}