Coverage for frappe_manager / site_manager / modules / bench_docker.py: 18%
239 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-02 18:13 +0530
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-02 18:13 +0530
1"""
2BenchDockerOps - Docker and Compose Operations Module
4This module handles all Docker and docker-compose operations for a bench.
5Extracted from the monolithic Bench class for better separation of concerns.
6"""
8import sys
9from collections.abc import Iterable, Iterator
10from pathlib import Path
11from typing import Any, Literal, cast
13from frappe_manager.docker import DockerClient, DockerException
14from frappe_manager.docker.compose_file import ComposeFile
15from frappe_manager.docker.subprocess_output import SubprocessOutput
16from frappe_manager.logger.contextual import ContextualLogger
17from frappe_manager.output_manager import OutputHandler
18from frappe_manager.output_manager.rich_output import RichOutputHandler
19from frappe_manager.site_manager import NON_BASH_SUPPORTED_SERVICES
20from frappe_manager.site_manager.bench_config import BenchConfig
21from frappe_manager.utils.docker import host_run_cp
22from frappe_manager.utils.helpers import get_container_name_prefix, get_current_fm_version
25class BenchDockerOps:
26 """Handles all Docker and compose operations for a bench."""
28 def __init__(
29 self,
30 logger: ContextualLogger,
31 docker_client: DockerClient,
32 compose_file_manager: ComposeFile,
33 config: BenchConfig,
34 path: Path,
35 output_handler: OutputHandler | None = None,
36 ):
37 """
38 Initialize BenchDockerOps.
40 Args:
41 logger: Contextual logger for audit/debug logging
42 docker_client: Docker client for operations
43 compose_file_manager: Compose file manager
44 config: Bench configuration
45 path: Path to bench directory
46 output_handler: Handler for output operations
47 """
48 self.logger = logger.child(component="docker")
49 self.docker_client = docker_client
50 self.compose_file_manager = compose_file_manager
51 self.config = config
52 self.path = path
53 self.output = output_handler or RichOutputHandler()
55 def _is_service_running(self, service: str) -> bool:
56 """Check if a specific service is running."""
57 try:
58 all_statuses = self.docker_client.compose.get_all_services_status()
59 return any(status["Service"] == service and status["State"] == "running" for status in all_statuses)
60 except DockerException:
61 return False
63 def is_running(self) -> bool:
64 """Check if all bench services are running."""
65 try:
66 services = self.compose_file_manager.get_services_list()
67 containers = self.compose_file_manager.get_container_names().values()
68 all_statuses = self.docker_client.compose.get_all_services_status()
69 running_statuses = {
70 status["Service"]: status["State"] for status in all_statuses if status.get("Name") in containers
71 }
72 return all(running_statuses.get(s) == "running" for s in services)
73 except DockerException:
74 return False
76 def get_services_running_status(self) -> dict:
77 """Get the running status of all services."""
78 try:
79 services = self.compose_file_manager.get_services_list()
80 containers = self.compose_file_manager.get_container_names().values()
81 all_statuses = self.docker_client.compose.get_all_services_status()
82 return {status["Service"]: status["State"] for status in all_statuses if status.get("Name") in containers}
83 except DockerException:
84 return {}
86 def generate_compose(self, inputs: dict) -> None:
87 """
88 Generate the compose file for the bench based on the given inputs.
90 Args:
91 inputs: Dictionary containing environment, labels, users, etc.
92 """
93 # Extract inputs
94 environments = inputs.get("environment")
95 labels = inputs.get("labels")
96 users = None
98 if "user" in inputs:
99 users = {}
100 for container_name, user_data in inputs["user"].items():
101 users[container_name] = (user_data["uid"], user_data["gid"])
103 network_aliases = [self.config.name]
104 if self.config.alias_domains:
105 network_aliases.extend(self.config.alias_domains)
107 self.compose_file_manager.set_network_alias("nginx", "site-network", network_aliases)
109 # Use configure_bench method to set all configurations atomically
110 self.compose_file_manager.configure_bench(
111 prefix=get_container_name_prefix(network_aliases[0]),
112 version=get_current_fm_version(),
113 envs=environments,
114 labels=labels,
115 users=users,
116 network_name="site-network",
117 auto_save=False,
118 )
120 restart_policy = inputs.get("restart_policy", "no")
121 self.compose_file_manager.set_all_services_restart(restart_policy)
122 self.compose_file_manager.write_to_file()
124 def create_compose_dirs(self) -> bool:
125 """
126 Create the necessary directories for the Compose setup.
128 Returns:
129 True if directories are created successfully
130 """
131 self.output.change_head("Creating required directories")
133 workspace_path = self.path / "workspace"
134 workspace_path.mkdir(parents=True, exist_ok=True)
136 frappe_bench_dir = workspace_path / "frappe-bench"
137 frappe_bench_dir.mkdir(parents=True, exist_ok=True)
139 (frappe_bench_dir / "sites").mkdir(parents=True, exist_ok=True)
140 (frappe_bench_dir / "apps").mkdir(parents=True, exist_ok=True)
141 (frappe_bench_dir / "logs").mkdir(parents=True, exist_ok=True)
142 (frappe_bench_dir / "config").mkdir(parents=True, exist_ok=True)
143 (frappe_bench_dir / "config" / "pids").mkdir(parents=True, exist_ok=True)
145 apps_txt = frappe_bench_dir / "sites" / "apps.txt"
146 if not apps_txt.exists():
147 apps_txt.write_text("frappe\n")
149 common_site_config = frappe_bench_dir / "sites" / "common_site_config.json"
150 if not common_site_config.exists():
151 common_site_config.write_text("{}")
153 configs_path = self.path / "configs"
154 configs_path.mkdir(parents=True, exist_ok=True)
156 # create nginx dirs
157 nginx_dir = configs_path / "nginx"
158 nginx_dir.mkdir(parents=True, exist_ok=True)
160 nginx_populate_dir = ["conf"]
161 nginx_image = self.compose_file_manager.yml["services"]["nginx"]["image"]
163 for directory in nginx_populate_dir:
164 new_dir = nginx_dir / directory
165 if not new_dir.exists():
166 new_dir_abs = str(new_dir.absolute())
167 host_run_cp(
168 nginx_image,
169 source="/etc/nginx",
170 destination=new_dir_abs,
171 docker=self.docker_client,
172 )
174 nginx_subdirs = ["logs", "cache", "run", "html"]
176 for directory in nginx_subdirs:
177 new_dir = nginx_dir / directory
178 new_dir.mkdir(parents=True, exist_ok=True)
180 # Copy prebaked Python and Node from Docker image to host workspace
181 frappe_image = self.compose_file_manager.yml["services"]["frappe"]["image"]
183 # Copy prebaked UV Python installations
184 uv_dir = workspace_path / "frappe-bench" / ".uv"
185 if not uv_dir.exists():
186 uv_dir_abs = str(uv_dir.absolute())
187 host_run_cp(
188 frappe_image,
189 source="/workspace/frappe-bench/.uv",
190 destination=uv_dir_abs,
191 docker=self.docker_client,
192 )
194 # Copy prebaked FNM Node installations
195 fnm_dir = workspace_path / "frappe-bench" / ".fnm"
196 if not fnm_dir.exists():
197 fnm_dir_abs = str(fnm_dir.absolute())
198 host_run_cp(
199 frappe_image,
200 source="/workspace/frappe-bench/.fnm",
201 destination=fnm_dir_abs,
202 docker=self.docker_client,
203 )
205 self.output.print("Created all required directories")
207 return True
209 def start(
210 self,
211 services: list | None = None,
212 force_recreate: bool = False,
213 pull: Literal["missing", "never", "always"] = "never",
214 ) -> None:
215 """
216 Start bench services.
218 Args:
219 services: List of specific services to start (None for all)
220 force_recreate: Force recreate containers
221 pull: Pull policy (never, always, missing)
222 """
223 self.output.change_head("Starting bench services")
225 self.docker_client.compose.up(services=services or [], detach=True, pull=pull, force_recreate=force_recreate)
227 self.output.print("Started bench services")
229 def stop(self, timeout: int = 10) -> None:
230 """
231 Stop bench services.
233 Args:
234 timeout: Timeout in seconds for stopping containers
235 """
236 self.output.change_head("Stopping bench services")
237 self.docker_client.compose.stop(services=[], timeout=timeout)
238 self.output.print("Stopped bench services")
240 def remove_containers(self, remove_volumes: bool = True, timeout: int = 5) -> None:
241 """
242 Remove bench containers.
244 Args:
245 remove_volumes: Whether to remove volumes
246 timeout: Timeout for removal
247 """
248 if self.compose_file_manager.exists():
249 self.output.change_head("Removing bench containers")
250 output = self.docker_client.compose.down(
251 remove_orphans=True,
252 volumes=remove_volumes,
253 timeout=timeout,
254 stream=True,
255 )
256 self.output.live_lines(cast("Iterator[tuple[str, bytes]]", output), padding=(0, 0, 0, 2))
257 self.output.print("Removed bench containers")
258 else:
259 self.output.warning("Bench compose file not found. Skipping containers removal.")
261 def shell(
262 self,
263 compose_service: str,
264 user: str | None = None,
265 shell_path: str | None = None,
266 use_run: bool = False,
267 ) -> None:
268 """
269 Spawn a shell for the specified service.
271 Args:
272 compose_service: The name of the service
273 user: The name of the user (defaults to "frappe" for frappe service)
274 shell_path: Path to shell executable (overrides auto-detection)
275 use_run: Use 'docker compose run --rm' instead of 'docker compose exec'
276 """
277 self.output.change_head("Spawning shell")
279 if compose_service == "frappe" and not user:
280 user = "frappe"
282 if not use_run and not self._is_service_running(compose_service):
283 self.output.stop()
284 self.output.display_error(f"Cannot spawn shell. Compose service '{compose_service}' not running!")
285 return
287 self.output.stop()
289 if not shell_path:
290 shell_path = "/bin/bash" if compose_service not in NON_BASH_SUPPORTED_SERVICES else "sh"
292 if use_run:
293 run_cmd = self.docker_client.compose.docker_compose_cmd + [
294 "run",
295 "--rm",
296 "--entrypoint",
297 "/exec-entrypoint.sh",
298 ]
299 # Use lightweight exec-entrypoint.sh that only handles UID/GID mismatch
300 run_cmd += [compose_service, shell_path]
302 import os
304 os.execvp(run_cmd[0], run_cmd)
305 else:
306 exec_cmd = self.docker_client.compose.docker_compose_cmd + ["exec"]
308 if user:
309 exec_cmd += ["--user", user]
311 if compose_service == "frappe":
312 exec_cmd += ["--workdir", "/workspace/frappe-bench"]
314 exec_cmd += [compose_service, shell_path]
316 import os
318 os.execvp(exec_cmd[0], exec_cmd)
320 def execute_command(
321 self,
322 compose_service: str,
323 command: str,
324 user: str | None = None,
325 shell_path: str | None = None,
326 use_run: bool = False,
327 ) -> int:
328 """
329 Execute a single command in the specified service and return exit code.
331 Args:
332 compose_service: The name of the service
333 command: The command to execute
334 user: The name of the user (defaults to "frappe" for frappe service)
335 shell_path: Path to shell executable (overrides auto-detection)
336 use_run: Use 'docker compose run --rm' instead of 'docker compose exec'
338 Returns:
339 Exit code of the executed command
340 """
341 if compose_service == "frappe" and not user:
342 user = "frappe"
344 if not use_run and not self._is_service_running(compose_service):
345 self.output.display_error(f"Cannot execute command. Compose service '{compose_service}' not running!")
346 return 1
348 if not shell_path:
349 shell_path = "/bin/bash" if compose_service not in NON_BASH_SUPPORTED_SERVICES else "sh"
351 if use_run:
352 run_args: dict[str, Any] = {
353 "service": compose_service,
354 "command": f'{shell_path} -c "{command}"',
355 "rm": True,
356 "use_shlex_split": True,
357 "stream": False,
358 "entrypoint": "/exec-entrypoint.sh",
359 # Use lightweight exec-entrypoint.sh that only handles UID/GID mismatch
360 }
362 try:
363 result = cast("SubprocessOutput", self.docker_client.compose.run(**run_args))
365 if result.stdout:
366 for line in result.stdout:
367 print(line)
368 if result.stderr:
369 for line in result.stderr:
370 print(line, file=sys.stderr)
372 return result.exit_code
373 except DockerException as e:
374 if e.output.stdout:
375 for line in e.output.stdout:
376 print(line)
377 if e.output.stderr:
378 for line in e.output.stderr:
379 print(line, file=sys.stderr)
380 return e.output.exit_code
381 else:
382 exec_args: dict[str, Any] = {
383 "service": compose_service,
384 "command": f'{shell_path} -c "{command}"',
385 "stream": False,
386 "capture_output": True,
387 "use_shlex_split": True,
388 }
390 if compose_service == "frappe":
391 exec_args["workdir"] = "/workspace/frappe-bench"
393 if user:
394 exec_args["user"] = user
396 try:
397 result = self.docker_client.compose.exec(**exec_args)
399 if result.stdout:
400 for line in result.stdout:
401 print(line)
402 if result.stderr:
403 for line in result.stderr:
404 print(line, file=sys.stderr)
406 return result.exit_code
407 except DockerException as e:
408 if e.output.stdout:
409 for line in e.output.stdout:
410 print(line)
411 if e.output.stderr:
412 for line in e.output.stderr:
413 print(line, file=sys.stderr)
414 return e.output.exit_code
416 def logs(self, services: list | None = None, follow: bool = False) -> None:
417 """
418 Display logs for services.
420 Args:
421 services: List of services to show logs for (None for all)
422 follow: Whether to follow logs continuously
423 """
424 self.output.change_head("Showing logs")
426 services_list = services or []
427 if services_list and not self._is_service_running(services_list[0]):
428 self.output.stop()
429 self.output.display_error(f"Cannot show logs. Service '{services_list[0]}' not running!")
430 return
432 output = self.docker_client.compose.logs(services=services_list, follow=follow, stream=True)
433 self.output.live_lines(cast("Iterator[tuple[str, bytes]]", output), padding=(0, 0, 0, 2))
435 def frappe_logs_till_start(self) -> None:
436 """
437 Retrieve and print the logs of the 'frappe' service until supervisor starts.
438 """
439 output = cast(
440 "Iterable[tuple[str, bytes]]",
441 self.docker_client.compose.logs(
442 services=["frappe"],
443 no_log_prefix=True,
444 no_color=True,
445 follow=True,
446 stream=True,
447 ),
448 )
450 self.output.live_lines(
451 cast("Iterator[tuple[str, bytes]]", output),
452 padding=(0, 0, 0, 2),
453 stop_string="INFO supervisord started with pid",
454 )
456 def restart_services(self, services: list, force: bool = False) -> None:
457 """
458 Restart specific services.
460 Args:
461 services: List of service names to restart
462 force: If True, use timeout=0 for immediate kill. If False, use default graceful timeout.
463 """
464 timeout = 0 if force else 100
465 self.output.change_head(f"Restarting services - {' '.join(services)}")
466 self.docker_client.compose.restart(services=services, timeout=timeout)
467 action = "Force restarted" if force else "Restarted"
468 self.output.print(f"{action} services - {' '.join(services)}")
470 def exec_command(self, service: str, command: str, user: str | None = None, stream: bool = False):
471 """
472 Execute a command in a service container.
474 Args:
475 service: Service name
476 command: Command to execute
477 user: User to run as
478 stream: Whether to stream output
480 Returns:
481 Command output
482 """
483 exec_args = {"service": service, "command": command, "stream": stream}
485 if user:
486 exec_args["user"] = user
488 return self.docker_client.compose.exec(**exec_args)
490 def check_required_docker_images_available(self) -> None:
491 """
492 Check if all required Docker images are available locally.
494 This method verifies that all images needed for the bench are
495 present on the system before attempting to start containers.
497 Raises:
498 BenchOperationRequiredDockerImagesNotAvailable: If any required images are missing
500 Example:
501 >>> docker_ops.check_required_docker_images_available()
502 """
503 from frappe_manager.site_manager.exceptions import BenchOperationRequiredDockerImagesNotAvailable
504 from frappe_manager.utils.site import get_all_docker_images
506 self.output.change_head("Checking required docker images availability")
507 fm_images = get_all_docker_images()
508 system_available_images = self.docker_client.images()
510 not_available_images = []
512 for key, value in fm_images.items():
513 name = value["name"]
514 tag = value["tag"]
516 found = False
518 for item in system_available_images:
519 if item.get("Repository") == name and item.get("Tag") == tag:
520 found = True
521 break
523 if not found:
524 image = f"{name}:{tag}"
525 not_available_images.append(image)
527 not_available_images = list(dict.fromkeys(not_available_images))
529 if not_available_images:
530 for image in not_available_images:
531 self.output.display_error(f"Docker image '{image}' is not available locally")
533 bench_name = self.config.container_name_prefix.replace("-", ".")
534 raise BenchOperationRequiredDockerImagesNotAvailable(bench_name, "fm self update-images")