Coverage for frappe_manager / site_manager / modules / bench_site.py: 34%
95 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"""
2BenchSiteManager - Frappe Site Lifecycle Management Module
4This module handles all Frappe site-related operations within a bench including
5site creation, deletion, migration, reset, and status checking.
7Extracted from the monolithic Bench class and BenchOperations for better
8separation of concerns.
9"""
11from collections.abc import Iterator
12from pathlib import Path
13from typing import cast
15from frappe_manager import CLI_DEFAULT_DELIMETER
16from frappe_manager.docker import DockerClient, DockerException
17from frappe_manager.docker.compose_file import ComposeFile
18from frappe_manager.docker.subprocess_output import SubprocessOutput
19from frappe_manager.logger.contextual import ContextualLogger
20from frappe_manager.output_manager import OutputHandler
21from frappe_manager.output_manager.rich_output import RichOutputHandler
22from frappe_manager.services_manager.services import ServicesManager
23from frappe_manager.site_manager.bench_config import BenchConfig
24from frappe_manager.site_manager.exceptions import (
25 BenchOperationBenchSiteCreateFailed,
26 BenchOperationException,
27 BenchOperationWaitForRequiredServiceFailed,
28)
29from frappe_manager.utils.helpers import get_redis_cache_addr, get_redis_queue_addr
32class BenchSiteManager:
33 """
34 Manages Frappe site lifecycle operations within a bench.
36 This module is responsible for all site-related operations including:
37 - Site creation and initialization
38 - Site deletion and cleanup
39 - Site migration and updates
40 - Site reset (reinstall)
41 - Site status checking
42 - Service availability checks
44 The module encapsulates bench command execution and provides a clean
45 interface for site management operations.
47 Attributes:
48 bench_name: Name of the bench/site
49 bench_path: Path to the bench directory
50 docker_client: Docker client for container operations
51 bench_config: Bench configuration object
52 services: Services manager for database/Redis access
53 logger: Logger instance
54 frappe_bench_dir: Path to frappe-bench directory inside container
55 bench_cli_cmd: Base bench command prefix
57 Example:
58 >>> site_manager = BenchSiteManager(
59 ... bench_name="example.localhost",
60 ... bench_path=Path("/home/user/frappe/example.localhost"),
61 ... docker_client=docker_client,
62 ... bench_config=bench_config,
63 ... services=services,
64 ... )
65 >>> site_manager.create_site(admin_pass="admin")
66 >>> if site_manager.is_site_created():
67 ... print("Site created successfully")
68 """
70 def __init__(
71 self,
72 logger: ContextualLogger,
73 bench_name: str,
74 bench_path: Path,
75 docker_client: DockerClient,
76 bench_config: BenchConfig,
77 services: ServicesManager,
78 compose_file_manager: ComposeFile | None = None,
79 output_handler: OutputHandler | None = None,
80 ):
81 """
82 Initialize BenchSiteManager.
84 Args:
85 logger: Contextual logger for audit/debug logging
86 bench_name: Name of the bench (typically the site domain)
87 bench_path: Path to the bench directory on host
88 docker_client: Docker client for container operations
89 bench_config: Bench configuration object
90 services: Services manager providing database/Redis access
91 compose_file_manager: Optional ComposeFile instance for the bench's
92 docker-compose file. When provided, ``wait_for_required_services``
93 will skip health checks for any required services that are
94 marked as disabled in that compose file. Pass ``None`` (default)
95 to always perform all health checks regardless of service profiles.
96 output_handler: Optional output handler for displaying information
97 """
98 self.logger = logger.child(component="site_manager")
99 self.bench_name = bench_name
100 self.bench_path = bench_path
101 self.docker_client = docker_client
102 self.bench_config = bench_config
103 self.services = services
104 self.compose_file_manager = compose_file_manager
105 self.output = output_handler or RichOutputHandler()
107 self.frappe_bench_dir: Path = bench_path / "workspace" / "frappe-bench"
108 self.bench_cli_cmd = ["/opt/user/.bin/bench"]
110 def is_site_created(self, site_name: str | None = None) -> bool:
111 """
112 Check if a Frappe site exists in the bench.
114 Args:
115 site_name: Name of the site to check. Defaults to bench_name.
117 Returns:
118 True if the site exists, False otherwise.
120 Example:
121 >>> if site_manager.is_site_created():
122 ... print("Site already exists")
123 """
124 if site_name is None:
125 site_name = self.bench_name
127 site_path: Path = self.frappe_bench_dir / "sites" / site_name
128 return site_path.exists()
130 def wait_for_required_services(self, timeout: int = 120) -> None:
131 """
132 Wait for required services (database, Redis) to be available.
134 This method checks if database and Redis services are reachable
135 before proceeding with site operations. It will block until all
136 services are available or timeout is reached.
138 Args:
139 timeout: Maximum time to wait in seconds (default: 120)
141 Raises:
142 BenchOperationWaitForRequiredServiceFailed: If any service is not available
144 Example:
145 >>> site_manager.wait_for_required_services(timeout=60)
146 """
147 self.output.change_head("Checking if required services are available")
149 db_info = self.services.database_manager.database_server_info
150 cache_host, cache_port = get_redis_cache_addr(self.bench_config.container_name_prefix)
151 queue_host, queue_port = get_redis_queue_addr(self.bench_config.container_name_prefix)
153 candidates = [
154 (self.services.compose_file_manager, db_info.host, db_info.host, db_info.port),
155 (self.compose_file_manager, "redis-cache", cache_host, cache_port),
156 (self.compose_file_manager, "redis-queue", queue_host, queue_port),
157 ]
159 for cfm, compose_service, host, port in candidates:
160 if cfm and cfm.is_service_profile_disabled(compose_service):
161 continue
162 output: SubprocessOutput = self._wait_for_service(host=host, port=port, timeout=timeout)
163 if output.combined:
164 command_output = output.combined[-1].replace("wait-for-it: ", "")
165 service_name = command_output.split(" ")[0]
166 simplified_service_name = service_name.split(":")[0]
167 simplified_service_name = simplified_service_name.split(CLI_DEFAULT_DELIMETER)[-1]
168 self.output.print(command_output.replace(service_name, simplified_service_name), highlight=False)
170 def _wait_for_service(self, host: str, port: int, timeout: int = 120) -> SubprocessOutput:
171 """
172 Wait for a specific service to be available.
174 Args:
175 host: Service hostname
176 port: Service port
177 timeout: Maximum time to wait in seconds
179 Returns:
180 SubprocessOutput with the wait-for-it command output
182 Raises:
183 BenchOperationWaitForRequiredServiceFailed: If service is not available
184 """
185 return cast(
186 "SubprocessOutput",
187 self._container_run(
188 f"wait-for-it -t {timeout} {host}:{port}",
189 raise_exception_obj=BenchOperationWaitForRequiredServiceFailed(
190 bench_name=self.bench_name,
191 host=host,
192 port=str(port),
193 timeout=timeout,
194 ),
195 capture_output=True,
196 ),
197 )
199 def create_bench_site(self, admin_pass: str | None = None) -> None:
200 """
201 Create a new Frappe site in the bench.
203 This method runs the 'bench new-site' command with appropriate database
204 credentials and configuration. It also sets the site as default and
205 enables the scheduler.
207 Args:
208 admin_pass: Administrator password. Defaults to bench_config.admin_pass.
210 Raises:
211 BenchOperationBenchSiteCreateFailed: If site creation fails
212 BenchOperationException: If post-creation setup fails
214 Example:
215 >>> site_manager.create_bench_site(admin_pass="secure_password")
216 """
217 if admin_pass is None:
218 admin_pass = self.bench_config.admin_pass
220 # Build new-site command
221 new_site_command = self.bench_cli_cmd + ["new-site"]
222 new_site_command += ["--db-root-password", self.services.database_manager.database_server_info.password]
223 if self.bench_config.db_name:
224 new_site_command += ["--db-name", self.bench_config.db_name]
225 new_site_command += ["--db-host", self.services.database_manager.database_server_info.host]
226 new_site_command += ["--admin-password", admin_pass]
227 new_site_command += ["--db-port", str(self.services.database_manager.database_server_info.port)]
228 new_site_command += ["--verbose", "--mariadb-user-host-login-scope", "%"]
229 new_site_command += [self.bench_name]
231 new_site_command = " ".join(new_site_command)
233 # Create the site
234 self._container_run(new_site_command, raise_exception_obj=BenchOperationBenchSiteCreateFailed(self.bench_name))
236 # Set as default site
237 self._container_run(
238 " ".join(self.bench_cli_cmd + [f"use {self.bench_name}"]),
239 raise_exception_obj=BenchOperationException(
240 self.bench_name,
241 f"Failed to set {self.bench_name} as default site.",
242 ),
243 )
245 # Enable scheduler
246 self._container_run(
247 " ".join(self.bench_cli_cmd + [f"--site {self.bench_name} scheduler enable"]),
248 raise_exception_obj=BenchOperationException(
249 self.bench_name,
250 f"Failed to enable {self.bench_name}'s scheduler.",
251 ),
252 )
254 def reset_bench_site(self, admin_password: str) -> None:
255 """
256 Reset (reinstall) a Frappe site, wiping all data.
258 This method runs 'bench reinstall' which drops and recreates the
259 site's database, effectively resetting it to a fresh state.
261 Args:
262 admin_password: New administrator password for the reset site
264 Raises:
265 BenchOperationException: If site reset fails
267 Warning:
268 This operation is destructive and will delete all site data!
270 Example:
271 >>> site_manager.reset_bench_site(admin_password="new_admin_pass")
272 """
273 global_db_info = self.services.database_manager.database_server_info
275 reset_bench_site_command = self.bench_cli_cmd + ["--site", self.bench_name]
276 reset_bench_site_command += ["reinstall", "--admin-password", admin_password]
277 reset_bench_site_command += ["--db-root-username", global_db_info.user]
278 reset_bench_site_command += ["--db-root-password", global_db_info.password]
279 reset_bench_site_command += ["--yes"]
281 reset_bench_site_command = " ".join(reset_bench_site_command)
283 self._container_run(
284 reset_bench_site_command,
285 raise_exception_obj=BenchOperationException(
286 bench_name=self.bench_name,
287 message=f"Failed to reset bench site {self.bench_name}.",
288 ),
289 )
291 def _container_run(
292 self,
293 command: str,
294 raise_exception_obj: BenchOperationException | None = None,
295 capture_output: bool = False,
296 user: str = "frappe",
297 workdir: str = "/workspace/frappe-bench",
298 service: str = "frappe",
299 use_run: bool = False,
300 ) -> SubprocessOutput | None:
301 """
302 Execute a command inside the bench container.
304 This is an internal helper method that wraps docker_client.compose.exec
305 or docker_client.compose.run depending on use_run parameter.
307 Args:
308 command: Shell command to execute
309 raise_exception_obj: Exception to raise on failure
310 capture_output: Whether to capture output instead of streaming
311 user: User to run command as (default: frappe)
312 workdir: Working directory (default: /workspace/frappe-bench)
313 service: Docker service name (default: frappe)
314 use_run: If True, use 'docker compose run --rm' instead of 'exec' (default: False)
316 Returns:
317 SubprocessOutput if capture_output=True, None otherwise
319 Raises:
320 BenchOperationException: If command fails and raise_exception_obj is provided
321 DockerException: If command fails and no exception object provided
322 """
323 try:
324 if use_run:
325 wrapped_command = f"cd {workdir} && {command}"
326 run_command = f"/bin/bash -c '{wrapped_command}'"
327 if capture_output:
328 output = cast(
329 "SubprocessOutput",
330 self.docker_client.compose.run(
331 service=service,
332 command=run_command,
333 rm=True,
334 stream=False,
335 entrypoint="/exec-entrypoint.sh",
336 ),
337 )
338 return output
339 output = cast(
340 "Iterator[tuple[str, bytes]]",
341 self.docker_client.compose.run(
342 service=service,
343 command=run_command,
344 rm=True,
345 entrypoint="/exec-entrypoint.sh",
346 stream=True,
347 ),
348 )
349 self.output.live_lines(output)
350 else:
351 exec_command = f"/bin/bash -c '{command}'"
352 if capture_output:
353 output = cast(
354 "SubprocessOutput",
355 self.docker_client.compose.exec(
356 service=service,
357 command=exec_command,
358 user=user,
359 workdir=workdir,
360 stream=False,
361 ),
362 )
363 return output
364 output = cast(
365 "Iterator[tuple[str, bytes]]",
366 self.docker_client.compose.exec(
367 service=service,
368 command=exec_command,
369 workdir=workdir,
370 user=user,
371 stream=True,
372 ),
373 )
374 self.output.live_lines(output)
376 except DockerException as e:
377 if raise_exception_obj:
378 raise_exception_obj.set_output(e.output)
379 raise raise_exception_obj
380 raise e