rolling_deployer/
deployment_manager.rs1use 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 fn extract_service_name(container: &crate::types::Container) -> String {
23 if let Some(labels) = &container.labels {
25 if let Some(service) = labels.get("com.docker.compose.service") {
26 return service.clone();
27 }
28 }
29 if let Some(name) = container.names.get(0) {
31 let name = name.trim_start_matches('/');
32 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 let dash_parts: Vec<&str> = name.split('-').collect();
39 if dash_parts.len() >= 2 {
40 if dash_parts.last().unwrap().parse::<u32>().is_ok() {
42 return dash_parts[dash_parts.len() - 2].to_string();
43 }
44 }
45 return name.to_string();
47 }
48 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 for vol in vols.iter_mut() {
66 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 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 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 !replaced {
104 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 let symlink_path = self
135 .git
136 .clone_repository_to_versioned_path(&config.repo_url, tag, &config.clone_path)
137 .await?;
138
139 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 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 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 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 if !compose_dir.exists() {
198 return Err(format!(
199 "Compose directory does not exist: {}",
200 compose_dir.display()
201 )
202 .into());
203 }
204
205 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 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 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 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 let target_config_path = format!("{}/traefik-config-{}", config.clone_path, tag);
291
292 if !std::path::Path::new(&target_config_path).exists() {
293 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 self.rolling_deploy(tag, swarm).await?;
304
305 println!("Rollback completed successfully!");
306 Ok(())
307 }
308}