nuwax_cli/docker_service/
manager.rs1use crate::docker_service::architecture::{Architecture, detect_architecture};
2use crate::docker_service::directory_permissions::DirectoryPermissionManager;
3use crate::docker_service::error::{DockerServiceError, DockerServiceResult};
4use crate::docker_service::health_check::{HealthChecker, HealthReport};
5use crate::docker_service::image_loader::{ImageLoader, LoadResult, TagResult};
6use crate::docker_service::port_manager::PortManager;
7use crate::docker_service::script_permissions::ScriptPermissionManager;
8
9use client_core::config::AppConfig;
10use client_core::constants::timeout;
11use client_core::container::DockerManager;
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::Duration;
15use tracing::{error, info, warn};
16
17pub struct DockerServiceManager {
19 #[allow(dead_code)]
20 config: Arc<AppConfig>,
21 docker_manager: Arc<DockerManager>,
22 work_dir: PathBuf,
23 architecture: Architecture,
24 image_loader: ImageLoader,
25 health_checker: HealthChecker,
26 port_manager: PortManager,
27 script_permission_manager: ScriptPermissionManager,
28 directory_permission_manager: DirectoryPermissionManager,
29}
30
31impl DockerServiceManager {
32 pub fn new(
34 config: Arc<AppConfig>,
35 docker_manager: Arc<DockerManager>,
36 work_dir: PathBuf,
37 ) -> Self {
38 let architecture = detect_architecture();
39
40 let image_loader = ImageLoader::new(docker_manager.clone(), work_dir.clone())
42 .expect("Failed to create image loader");
43 let health_checker = HealthChecker::new(docker_manager.clone());
44
45 Self {
46 config,
47 docker_manager,
48 work_dir: work_dir.clone(),
49 architecture,
50 image_loader,
51 health_checker,
52 port_manager: PortManager::new(),
53 script_permission_manager: ScriptPermissionManager::new(work_dir.clone()),
54 directory_permission_manager: DirectoryPermissionManager::new(work_dir.clone()),
55 }
56 }
57
58 pub fn get_architecture(&self) -> Architecture {
60 self.architecture
61 }
62
63 pub fn get_work_dir(&self) -> &PathBuf {
65 &self.work_dir
66 }
67
68 pub async fn deploy_services(&mut self) -> DockerServiceResult<()> {
70 info!("Starting Docker service deployment...");
71
72 self.check_environment().await?;
74
75 self.docker_manager
77 .ensure_host_volumes_exist()
78 .await
79 .map_err(|err| DockerServiceError::DirectorySetup(err.to_string()))?;
80
81 self.directory_permission_manager
83 .ensure_mysql_config_safe()?;
84
85 self.script_permission_manager
87 .check_and_fix_script_permissions()
88 .await?;
89
90 let load_result = self.load_images().await?;
92
93 self.setup_image_tags_with_ducker_validation(&load_result.image_mappings)
95 .await?;
96
97 self.start_services().await?;
99
100 info!("Docker service deployment completed");
101 Ok(())
102 }
103
104 pub async fn check_environment(&self) -> DockerServiceResult<()> {
106 info!("Checking Docker environment...");
107
108 if !self.work_dir.exists() {
119 return Err(DockerServiceError::EnvironmentCheck(format!(
120 "{}",
121 t!(
122 "docker_service_manager.work_dir_not_exists",
123 path = self.work_dir.display()
124 )
125 )));
126 }
127
128 let images_dir = self
130 .work_dir
131 .join(client_core::constants::docker::IMAGES_DIR_NAME);
132 if !images_dir.exists() {
133 return Err(DockerServiceError::EnvironmentCheck(format!(
134 "{}",
135 t!(
136 "docker_service_manager.images_dir_not_exists",
137 path = images_dir.display()
138 )
139 )));
140 }
141
142 let compose_file = self
144 .work_dir
145 .join(client_core::constants::docker::COMPOSE_FILE_NAME);
146 if !compose_file.exists() {
147 return Err(DockerServiceError::EnvironmentCheck(format!(
148 "{}",
149 t!(
150 "docker_service_manager.compose_file_not_exists",
151 path = compose_file.display()
152 )
153 )));
154 }
155
156 let runtime_env = self.docker_manager.get_runtime_environment();
158 if runtime_env.needs_special_handling() {
159 info!(
160 " Environment: {env} - special handling required",
161 env = runtime_env.summary()
162 );
163 } else {
164 info!(" Environment: {env}", env = runtime_env.summary());
165 }
166
167 info!("Environment check passed");
168 Ok(())
169 }
170
171 pub async fn ensure_compose_mount_directories(&self) -> DockerServiceResult<()> {
173 info!("🔍 Checking and creating mount directories from docker-compose.yml...");
174
175 let runtime_env = self.docker_manager.get_runtime_environment();
177
178 if runtime_env.needs_special_handling() {
179 info!("⚠️ Windows Podman Desktop environment detected");
180 info!(" Podman Desktop does not auto-create mount directories, creating proactively");
181 }
182
183 self.docker_manager
185 .ensure_host_volumes_exist()
186 .await
187 .map_err(|err| DockerServiceError::DirectorySetup(err.to_string()))?;
188
189 info!("✅ Mount directory check completed");
190 Ok(())
191 }
192
193 pub async fn load_images(&self) -> DockerServiceResult<LoadResult> {
195 info!("Starting Docker image loading...");
196 let result = self.image_loader.load_all_images().await?;
197
198 if !result.is_all_successful() {
199 warn!(
200 "Some image loading failed: success {success}, failed {failed}",
201 success = result.success_count(),
202 failed = result.failure_count()
203 );
204 }
205
206 Ok(result)
207 }
208
209 pub async fn setup_image_tags_with_mappings(
211 &self,
212 image_mappings: &[(String, String)],
213 ) -> DockerServiceResult<TagResult> {
214 info!("Starting image tag setup...");
215 let result = self
216 .image_loader
217 .setup_image_tags_with_mappings(image_mappings)
218 .await?;
219
220 if !result.is_all_successful() {
221 warn!(
222 "Some tag setup failed: success {success}, failed {failed}",
223 success = result.success_count(),
224 failed = result.failure_count()
225 );
226 }
227
228 Ok(result)
229 }
230
231 pub async fn setup_image_tags_with_ducker_validation(
233 &self,
234 image_mappings: &[(String, String)],
235 ) -> DockerServiceResult<TagResult> {
236 info!("Starting validated image tag setup...");
237 let result = self
238 .image_loader
239 .setup_image_tags_with_validation(image_mappings)
240 .await?;
241
242 if !result.is_all_successful() {
243 warn!(
244 "Some tag setup failed: success {success}, failed {failed}",
245 success = result.success_count(),
246 failed = result.failure_count()
247 );
248 }
249
250 Ok(result)
251 }
252
253 pub async fn list_docker_images_with_ducker(&self) -> DockerServiceResult<Vec<String>> {
255 info!("Using ducker to list images...");
256 self.image_loader.list_images_with_ducker().await
257 }
258
259 pub async fn start_services(&mut self) -> DockerServiceResult<()> {
261 info!("Starting Docker Compose services...");
262
263 self.script_permission_manager
265 .check_and_fix_script_permissions()
266 .await?;
267
268 self.docker_manager
270 .ensure_host_volumes_exist()
271 .await
272 .map_err(|err| DockerServiceError::DirectorySetup(err.to_string()))?;
273
274 self.directory_permission_manager
276 .ensure_mysql_config_safe()?;
277
278 self.check_port_conflicts().await?;
280
281 let result = self.docker_manager.start_services().await;
283
284 match result {
285 Ok(_) => {
286 info!("Waiting for services to become ready...");
288 let check_interval = Duration::from_secs(timeout::HEALTH_CHECK_INTERVAL);
289
290 match self
297 .health_checker
298 .wait_for_services_ready(check_interval)
299 .await
300 {
301 Ok(report) => {
302 info!("All services started successfully!");
303 self.print_service_status(&report).await;
304 }
305 Err(e) => {
306 warn!(
307 "Wait for services failed or timed out: {error}",
308 error = e.to_string()
309 );
310
311 if let Ok(report) = self.health_checker.health_check().await {
313 self.print_service_status_with_failures(&report).await;
314 }
315 }
316 }
317
318 Ok(())
319 }
320 Err(e) => {
321 error!("Docker Compose start command failed, checking container status...");
322 error!("Error detail: {error}", error = format!("{e:?}"));
323
324 match self.health_checker.health_check().await {
326 Ok(report) => {
327 if report.get_running_count() > 0 {
328 info!(
329 "🔍 {running}/{total} containers are running, entering health-check phase",
330 running = report.get_running_count(),
331 total = report.get_total_count()
332 );
333
334 let check_interval =
336 Duration::from_secs(timeout::HEALTH_CHECK_INTERVAL);
337
338 match self
339 .health_checker
340 .wait_for_services_ready(check_interval)
341 .await
342 {
343 Ok(final_report) => {
344 info!("🎉 Some services eventually started successfully!");
345
346 self.print_service_status(&final_report).await;
356 return Ok(()); }
358 Err(_health_error) => {
359 warn!(
360 "⏰ Health check timed out, but some services are still running"
361 );
362
363 self.print_service_status_with_failures(&report).await;
379 info!(
380 "You can inspect logs: nuwax-cli docker-service logs [service]"
381 );
382 return Ok(()); }
384 }
385 } else {
386 error!("No running containers found");
387 self.print_detailed_error_analysis(&report, &e.to_string())
388 .await;
389 }
390 }
391 Err(e) => {
392 error!("❌ Failed to get container status details");
393 error!("Error detail: {error}", error = format!("{e:?}"));
394 }
395 }
396
397 Err(DockerServiceError::ServiceManagement(e.to_string()))
398 }
399 }
400 }
401
402 pub async fn stop_services(&self) -> DockerServiceResult<()> {
404 info!("Stopping Docker Compose services...");
405
406 let result = self.docker_manager.stop_services().await;
408
409 match result {
410 Ok(_) => {
411 info!("Services stopped successfully");
412 Ok(())
413 }
414 Err(e) => {
415 error!("Failed to stop services: {error}", error = e.to_string());
416 Err(DockerServiceError::ServiceManagement(e.to_string()))
417 }
418 }
419 }
420
421 pub async fn restart_services(&mut self) -> DockerServiceResult<()> {
423 info!("Restarting Docker Compose services...");
424
425 let result = self.docker_manager.restart_services().await;
427
428 match result {
429 Ok(_) => {
430 info!("Waiting for services to become ready after restart...");
431 let check_interval = Duration::from_secs(timeout::HEALTH_CHECK_INTERVAL);
432
433 match self
434 .health_checker
435 .wait_for_services_ready(check_interval)
436 .await
437 {
438 Ok(report) => {
439 info!("All services restarted successfully!");
440 self.print_service_status(&report).await;
441 }
442 Err(e) => {
443 warn!(
444 "Wait for services after restart failed or timed out: {error}",
445 error = e.to_string()
446 );
447 if let Ok(report) = self.health_checker.health_check().await {
448 self.print_service_status_with_failures(&report).await;
449 }
450 }
451 }
452
453 Ok(())
454 }
455 Err(e) => {
456 error!("Failed to restart services: {error}", error = e.to_string());
457 Err(DockerServiceError::ServiceManagement(e.to_string()))
458 }
459 }
460 }
461
462 pub async fn restart_container(&self, container_name: &str) -> DockerServiceResult<()> {
464 info!("Restarting container: {name}", name = container_name);
465
466 let result = self.docker_manager.restart_service(container_name).await;
468
469 match result {
470 Ok(_) => {
471 info!(
472 "Container {name} restarted successfully",
473 name = container_name
474 );
475 Ok(())
476 }
477 Err(e) => {
478 error!(
479 "Container {name} restart failed: {error}",
480 name = container_name,
481 error = e.to_string()
482 );
483 Err(DockerServiceError::ServiceManagement(e.to_string()))
484 }
485 }
486 }
487
488 pub async fn health_check(&self) -> DockerServiceResult<HealthReport> {
490 self.health_checker.health_check().await
491 }
492
493 pub async fn get_status_summary(&self) -> DockerServiceResult<String> {
495 self.health_checker.get_status_summary().await
496 }
497
498 async fn print_service_status(&self, report: &HealthReport) {
500 info!("=== Service Status Overview ===");
501 info!(
502 "Overall status: {status}",
503 status = report.finalize().display_name()
504 );
505 info!(
506 "Running containers: {running}/{total}",
507 running = report.get_running_count(),
508 total = report.get_total_count()
509 );
510
511 if !report.containers.is_empty() {
512 info!("Container details:");
513 for container in &report.containers {
514 info!(
515 " • {name} - {status} ({image})",
516 name = container.name,
517 status = container.status.display_name(),
518 image = container.image
519 );
520 }
521 }
522
523 if !report.errors.is_empty() {
524 warn!("Errors:");
525 for error in &report.errors {
526 warn!(" • {error}", error = error);
527 }
528 }
529
530 if report.finalize().is_healthy() {
532 info!("=== Service Access Info ===");
533 use client_core::constants::docker::ports;
534 info!(
535 "• Frontend: http://localhost:{port}",
536 port = ports::DEFAULT_FRONTEND_PORT
537 );
538 info!(
539 "• Backend API: http://localhost:{port}",
540 port = ports::DEFAULT_BACKEND_PORT
541 );
542 info!("• Service management complete. Ready to use.");
543 }
544 }
545
546 async fn print_service_status_with_failures(&self, report: &HealthReport) {
548 info!("=== Service Status Details ===");
549 info!(
550 "Overall status: {status}",
551 status = report.finalize().display_name()
552 );
553 info!(
554 "Health summary: {running}/{total} containers healthy",
555 running = report.get_running_count(),
556 total = report.get_total_count()
557 );
558
559 let running_containers: Vec<_> = report
561 .containers
562 .iter()
563 .filter(|c| c.status.is_healthy())
564 .collect();
565 let failed_containers: Vec<_> = report
566 .containers
567 .iter()
568 .filter(|c| !c.status.is_healthy() && !c.status.is_transitioning())
569 .collect();
570 let starting_containers: Vec<_> = report
571 .containers
572 .iter()
573 .filter(|c| c.status.is_transitioning())
574 .collect();
575
576 if !running_containers.is_empty() {
577 info!("✅ Running containers:");
578 for container in running_containers {
579 info!(
580 " • {name} ({image})",
581 name = container.name,
582 image = container.image
583 );
584 }
585 }
586
587 if !starting_containers.is_empty() {
588 warn!("🔄 Starting containers:");
589 for container in starting_containers {
590 warn!(
591 " • {name} - {status}",
592 name = container.name,
593 status = container.status.display_name()
594 );
595 }
596 }
597
598 if !failed_containers.is_empty() {
599 error!("❌ Failed containers:");
600 for container in failed_containers {
601 error!(
602 " • {name} - {status} ({image})",
603 name = container.name,
604 status = container.status.display_name(),
605 image = container.image
606 );
607
608 self.print_container_troubleshooting(&container.name, &container.image)
610 .await;
611 }
612 }
613
614 if report.get_running_count() > 0 {
616 info!("=== Available Service Access Info ===");
617 use client_core::constants::docker::ports;
618
619 let has_frontend = report
620 .containers
621 .iter()
622 .any(|c| c.status.is_healthy() && c.name.contains("frontend"));
623 let has_backend = report
624 .containers
625 .iter()
626 .any(|c| c.status.is_healthy() && c.name.contains("backend"));
627
628 if has_frontend {
629 info!(
630 "• Frontend: http://localhost:{port}",
631 port = ports::DEFAULT_FRONTEND_PORT
632 );
633 }
634 if has_backend {
635 info!(
636 "• Backend API: http://localhost:{port}",
637 port = ports::DEFAULT_BACKEND_PORT
638 );
639 }
640 let failed_count = report
641 .containers
642 .iter()
643 .filter(|c| !c.status.is_healthy() && !c.status.is_transitioning())
644 .count();
645
646 if failed_count == 0 {
647 info!("• All services are running normally!");
648 } else {
649 warn!("• Some services failed, but available services remain usable");
650 }
651 }
652 }
653
654 async fn print_detailed_error_analysis(&self, report: &HealthReport, original_error: &str) {
656 error!("=== Startup Failure Analysis ===");
657
658 let failed_containers: Vec<_> = report
660 .containers
661 .iter()
662 .filter(|c| !c.status.is_healthy())
663 .collect();
664
665 if failed_containers.is_empty() {
666 error!("❌ Failed to get container status details");
667 error!("❌ Original error: {error}", error = original_error);
668 return;
669 }
670
671 error!(
672 "❌ Failed containers: {failed}/{total}",
673 failed = failed_containers.len(),
674 total = report.get_total_count()
675 );
676
677 for container in failed_containers {
678 error!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
679 error!("Container: {name}", name = container.name);
680 error!("Image: {image}", image = container.image);
681 error!(
682 "Current status: {status}",
683 status = container.status.display_name()
684 );
685
686 self.print_container_troubleshooting(&container.name, &container.image)
688 .await;
689 }
690
691 self.analyze_docker_error(original_error).await;
693 }
694
695 async fn print_container_troubleshooting(&self, container_name: &str, image_name: &str) {
697 if container_name.contains("video-analysis-worker") {
698 warn!("💡 Analysis:");
699 warn!(
700 " - This container requires NVIDIA GPU support, which may be unavailable on this system"
701 );
702 warn!(" - Architecture mismatch detected (amd64 vs arm64)");
703 warn!("💡 Suggested fix:");
704 warn!(" - On Mac ARM64, disable this service or use an ARM64 image");
705 warn!(" - Comment out this service in docker-compose.yml");
706 warn!(" - Or update image version in .env to an ARM64 variant");
707 } else if image_name.contains("amd64") {
708 warn!("💡 Analysis:");
709 warn!(" - Architecture mismatch: image is amd64 but system is arm64");
710 warn!("💡 Suggested fix:");
711 warn!(" - Use an arm64 image");
712 warn!(" - Or add --platform linux/amd64 when running container");
713 } else if container_name.contains("mysql") || container_name.contains("redis") {
714 warn!("💡 Analysis:");
715 warn!(
716 " - Database startup failed, likely due to port conflict or data directory permissions"
717 );
718 warn!("💡 Suggested fix:");
719 warn!(" - Check whether port 3306(MySQL) or 6379(Redis) is occupied");
720 warn!(" - Check directory permissions: ./data/mysql or ./data/redis");
721 } else if container_name.contains("backend") || container_name.contains("entrypoint") {
722 warn!("💡 Analysis:");
723 warn!(" - Container startup script may be missing execute permission");
724 warn!("💡 Suggested fix:");
725 warn!(" - Check permissions for scripts like docker-entrypoint.sh");
726 warn!(" - Run: chmod +x config/docker-entrypoint.sh");
727 warn!(
728 " - View logs: docker-compose logs {name}",
729 name = container_name
730 );
731 } else {
732 warn!("💡 Suggestion:");
733 warn!(
734 " - View logs: docker-compose logs {name}",
735 name = container_name
736 );
737 warn!(" - Verify images were pulled successfully");
738 warn!(" - Verify environment variables");
739 }
740 }
741
742 async fn analyze_docker_error(&self, error_message: &str) {
744 error!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
745 error!("🔍 Error analysis:");
746
747 let mut has_issues = false;
748
749 if error_message.contains("nvidia") {
750 error!(" ❌ NVIDIA GPU driver issue");
751 error!(" 💡 NVIDIA GPU may be unsupported or driver not installed");
752 error!(" 💡 Consider disabling services that require GPU");
753 has_issues = true;
754 }
755
756 if error_message.contains("platform")
757 && error_message.contains("amd64")
758 && error_message.contains("arm64")
759 {
760 error!(" ❌ Container architecture mismatch");
761 error!(" 💡 amd64 image cannot run natively on arm64 system");
762 error!(" 💡 Use image that matches your architecture");
763 has_issues = true;
764 }
765
766 if error_message.contains("Permission denied") && error_message.contains("entrypoint") {
767 error!(" ❌ Script permission issue");
768 error!(" 💡 Startup script lacks execute permission");
769 error!(" 💡 Add execute permission with chmod +x");
770 has_issues = true;
771 }
772
773 if error_message.contains("port") || error_message.contains("bind") {
774 error!(" ❌ Port bind failed");
775 error!(" 💡 There may be a port conflict");
776 error!(" 💡 Check current port occupancy");
777 has_issues = true;
778 }
779
780 if !has_issues {
781 error!(" ❓ Unrecognized error type, key lines:");
782 let key_lines: Vec<&str> = error_message
784 .lines()
785 .filter(|line| {
786 line.contains("Error")
787 || line.contains("failed")
788 || line.contains("denied")
789 || line.contains("not found")
790 || line.contains("connection")
791 || line.trim().starts_with("Container")
792 })
793 .take(5)
794 .collect();
795
796 if !key_lines.is_empty() {
797 for line in key_lines {
798 error!(" {line}", line = line.trim());
799 }
800 } else {
801 for line in error_message.lines().take(3) {
803 if !line.trim().is_empty() {
804 error!(" {line}", line = line.trim());
805 }
806 }
807 }
808 }
809
810 error!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
811 }
812
813 async fn check_port_conflicts(&mut self) -> DockerServiceResult<()> {
815 let compose_file = self.docker_manager.get_compose_file();
816 let env_file = self.docker_manager.get_env_file();
817 if !compose_file.exists() {
818 warn!("docker-compose.yml not found, skipping port conflict check");
819 return Ok(());
820 }
821
822 info!("🔍 Starting smart port-conflict check...");
823
824 match self
825 .port_manager
826 .smart_check_compose_port_conflicts(compose_file, env_file)
827 .await
828 {
829 Ok(report) => {
830 if report.has_conflicts {
831 warn!("⚠️ Port conflict detected, proceeding with smart handling");
832 self.port_manager.print_smart_conflict_report(&report);
833
834 warn!("💡 Note: Docker may handle port binding automatically");
837 warn!(
838 " - If occupied by related service, container may reuse existing binding"
839 );
840 warn!(" - If occupied by unrelated service, startup may fail");
841 warn!(" - Check startup result and resolve conflicts manually if needed");
842 } else {
843 info!("✅ Port check passed, no conflict found");
844 if report.total_checked > 0 {
845 info!(
846 "Checked {total} port mappings in total",
847 total = report.total_checked
848 );
849 }
850 }
851 }
852 Err(e) => {
853 warn!(
854 "Port check failed: {error}, continuing startup",
855 error = e.to_string()
856 );
857 }
859 }
860
861 Ok(())
862 }
863}