Coverage for frappe_manager / site_manager / site.py: 44%
609 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
1import itertools
2import shutil
3import time
4from collections.abc import Iterator
5from pathlib import Path
6from typing import Any, cast
8from frappe_manager import (
9 CLI_BENCH_CONFIG_FILE_NAME,
10 CLI_BENCHES_DIRECTORY,
11 SiteServicesEnum,
12)
13from frappe_manager.docker import ComposeFile, DockerClient, DockerException
14from frappe_manager.logger import ContextualLogger
15from frappe_manager.migration_manager.backup_manager import BackupManager
16from frappe_manager.output_manager import OutputHandler
17from frappe_manager.output_manager.rich_output import RichOutputHandler
18from frappe_manager.services_manager.services import ServicesManager
19from frappe_manager.site_manager.bench_config import BenchConfig, FMBenchEnvType
20from frappe_manager.site_manager.exceptions import (
21 BenchException,
22 BenchRemoveDirectoryError,
23)
24from frappe_manager.site_manager.modules.bench_admin_tools import BenchAdminTools
25from frappe_manager.site_manager.modules.bench_app import BenchAppManager
26from frappe_manager.site_manager.modules.bench_database import BenchDatabase
27from frappe_manager.site_manager.modules.bench_devtools import BenchDevTools
28from frappe_manager.site_manager.modules.bench_docker import BenchDockerOps
29from frappe_manager.site_manager.modules.bench_info import BenchInfo
30from frappe_manager.site_manager.modules.bench_orchestrator import BenchOrchestrator
31from frappe_manager.site_manager.modules.bench_site import BenchSiteManager
32from frappe_manager.site_manager.modules.bench_ssl import BenchSSL
33from frappe_manager.site_manager.modules.bench_supervisor import BenchSupervisor
34from frappe_manager.site_manager.modules.bench_workers import BenchWorkerCoordinator, BenchWorkers
35from frappe_manager.site_manager.modules.upload_limit_manager import UploadLimitManager
36from frappe_manager.ssl_manager.certificate import SSLCertificate
37from frappe_manager.ssl_manager.certificate_link_manager import CertificateLinkManager
38from frappe_manager.ssl_manager.nginx_controller import NginxController
39from frappe_manager.ssl_manager.proxy_storage import ProxyStoragePaths
40from frappe_manager.ssl_manager.service_factory import create_certificate_service
41from frappe_manager.ssl_manager.ssl_certificate_manager import SSLCertificateManager
42from frappe_manager.ssl_manager.storage_config import SSLStorageConfig
43from frappe_manager.utils.helpers import (
44 log_file,
45 save_dict_to_file,
46)
47from frappe_manager.utils.site import domain_level
50class Bench:
51 def __init__(
52 self,
53 logger: ContextualLogger,
54 path: Path,
55 name: str,
56 bench_config: BenchConfig,
57 compose_file_manager: ComposeFile,
58 docker_client: DockerClient,
59 services: ServicesManager,
60 workers_check: bool = True,
61 admin_tools_check: bool = True,
62 verbose: bool = False,
63 output_handler: OutputHandler | None = None,
64 ) -> None:
65 self.path = path
66 self.name = name
67 self.output = output_handler or RichOutputHandler()
68 self.services = services
69 self.backup_path = self.path / "backups"
70 self.bench_config: BenchConfig = bench_config
71 self.logger = logger.child(bench=name)
73 self.compose_file_manager = compose_file_manager
74 self.docker_client = docker_client
76 # Initialize specialized modules
77 self.docker_ops = BenchDockerOps(
78 logger=self.logger,
79 docker_client=docker_client,
80 compose_file_manager=compose_file_manager,
81 config=bench_config,
82 path=path,
83 output_handler=self.output,
84 )
85 self.supervisor = BenchSupervisor(
86 logger=self.logger,
87 docker_client=docker_client,
88 config=bench_config,
89 bench_name=name,
90 output_handler=self.output,
91 )
93 # Initialize local nginx proxy components
94 self.bench_proxy_storage = ProxyStoragePaths("nginx", self.compose_file_manager)
95 self.bench_nginx_controller = NginxController("nginx", self.compose_file_manager, self.docker_client)
97 # For backward compatibility with admin_tools
98 # Create a simple proxy manager object with required attributes
99 self.proxy_manager = type(
100 "ProxyManager",
101 (),
102 {
103 "dirs": self.bench_proxy_storage.dirs,
104 "restart": self.bench_nginx_controller.restart,
105 "reload": self.bench_nginx_controller.reload,
106 },
107 )()
109 self.admin_tools = BenchAdminTools(self, self.proxy_manager, verbose=verbose, output_handler=self.output)
111 # Get global nginx-proxy storage config from services
112 global_proxy_storage = services.proxy_storage
113 webroot_dir = self.bench_proxy_storage.dirs.html.host
115 ssl_storage_config = SSLStorageConfig(
116 ssl_dir=global_proxy_storage.dirs.ssl.host,
117 ssl_dir_container=global_proxy_storage.dirs.ssl.container,
118 certs_dir=global_proxy_storage.dirs.certs.host,
119 certs_dir_container=global_proxy_storage.dirs.certs.container,
120 vhostd_dir=global_proxy_storage.dirs.vhostd.host,
121 webroot_dir=webroot_dir,
122 )
124 link_manager = CertificateLinkManager(ssl_storage_config)
126 def certificate_service_factory(cert, storage_cfg, output_handler):
127 return create_certificate_service(self.logger, cert, storage_cfg, output_handler)
129 self.certificate_manager = SSLCertificateManager(
130 logger=self.logger,
131 certificates=self.bench_config.ssl_certificates,
132 service_factory=certificate_service_factory,
133 link_manager=link_manager,
134 nginx_controller=services.nginx_controller,
135 storage_config=ssl_storage_config,
136 config_save_callback=self.save_bench_config,
137 output_handler=self.output,
138 )
140 self.ssl = BenchSSL(
141 certificate_manager=self.certificate_manager,
142 bench_name=name,
143 is_service_running_fn=self._is_service_running,
144 )
146 self.devtools = BenchDevTools(
147 docker_client=docker_client,
148 compose_file_manager=compose_file_manager,
149 bench_path=path,
150 bench_name=name,
151 is_running_fn=lambda: self.running,
152 output_handler=self.output,
153 )
154 self.devtools.logger = self.logger
156 self.database = BenchDatabase(
157 bench_name=name,
158 bench_path=path,
159 services=services,
160 set_common_bench_config_fn=self.set_common_bench_config,
161 output_handler=self.output,
162 )
164 self.site_manager = BenchSiteManager(
165 logger=self.logger,
166 bench_name=name,
167 bench_path=path,
168 docker_client=docker_client,
169 bench_config=bench_config,
170 services=services,
171 compose_file_manager=compose_file_manager,
172 output_handler=self.output,
173 )
175 self.app_manager = BenchAppManager(
176 logger=self.logger,
177 bench_name=name,
178 bench_path=path,
179 docker_client=docker_client,
180 bench_config=bench_config,
181 output_handler=self.output,
182 )
184 self.workers = BenchWorkers(self, not verbose, output_handler=self.output)
186 self.info_display = BenchInfo(
187 bench_name=name,
188 bench_path=path,
189 bench_config=bench_config,
190 services=services,
191 workers=self.workers,
192 admin_tools=self.admin_tools,
193 certificate_manager=self.certificate_manager,
194 get_db_connection_info_fn=self.get_db_connection_info,
195 has_certificate_fn=lambda: self.has_certificate(),
196 is_running_fn=lambda: self.running,
197 get_services_running_status_fn=self._get_services_running_status,
198 output_handler=self.output,
199 )
201 self.worker_coordinator = BenchWorkerCoordinator(
202 bench_name=name,
203 workers=self.workers,
204 supervisor=self.supervisor,
205 bench_path=self.path,
206 restart_supervisor_service_fn=self.restart_supervisor_service,
207 is_running_fn=lambda: self.running,
208 docker_ops=self.docker_ops,
209 output_handler=self.output,
210 )
212 # For complex workflows
213 self.orchestrator = BenchOrchestrator(logger=self.logger, bench=self, output_handler=self.output)
215 if workers_check:
216 self.ensure_workers_running_if_available()
218 if admin_tools_check:
219 self.ensure_admin_tools_running_if_available()
221 @classmethod
222 def get_object(
223 cls,
224 bench_name: str,
225 services: ServicesManager,
226 logger: ContextualLogger | None = None,
227 benches_path: Path = CLI_BENCHES_DIRECTORY,
228 bench_config_file_name: str = CLI_BENCH_CONFIG_FILE_NAME,
229 workers_check: bool = False,
230 admin_tools_check: bool = False,
231 verbose: bool = False,
232 output_handler: OutputHandler | None = None,
233 ) -> "Bench":
234 if domain_level(bench_name) == 0:
235 bench_name = bench_name + ".localhost"
237 bench_path = benches_path / bench_name
238 bench_config_path: Path = bench_path / bench_config_file_name
240 if not bench_path.exists():
241 from frappe_manager.site_manager.exceptions import BenchNotFoundError
243 raise BenchNotFoundError(bench_name, bench_path)
245 compose_file_manager = ComposeFile(bench_path / "docker-compose.yml")
246 docker_client = DockerClient(compose_file_path=bench_path / "docker-compose.yml", output=output_handler)
248 bench_config: BenchConfig = BenchConfig.import_from_toml(bench_config_path)
250 if logger is None:
251 from frappe_manager.logger import log as base_log
252 from frappe_manager.logger.context import LoggerContext
253 from frappe_manager.logger.contextual import ContextualLogger
255 logger = ContextualLogger(base_log.get_logger(), context=LoggerContext())
257 parms: dict[str, Any] = {
258 "logger": logger,
259 "name": bench_name,
260 "path": bench_path,
261 "bench_config": bench_config,
262 "compose_file_manager": compose_file_manager,
263 "docker_client": docker_client,
264 "services": services,
265 "workers_check": workers_check,
266 "admin_tools_check": admin_tools_check,
267 }
269 if output_handler is not None:
270 parms["output_handler"] = output_handler
272 return cls(**parms)
274 def _is_service_running(self, service: str) -> bool:
275 """Check if a specific service is running."""
276 return self.docker_ops._is_service_running(service)
278 @property
279 def running(self) -> bool:
280 """Check if all bench services are running."""
281 return self.docker_ops.is_running()
283 def _get_services_running_status(self) -> dict:
284 """Get the running status of all services."""
285 return self.docker_ops.get_services_running_status()
287 def sync_bench_config_configuration(self):
288 extra = {"operation": "config_sync_bench_config", "bench_name": self.name}
289 self.logger.debug(f"Syncing bench config configuration: {self.name}", extra_fields=extra)
290 try:
291 # set developer_mode based on config
292 self.set_common_bench_config({"developer_mode": self.bench_config.developer_mode})
294 # ssl
295 certificate_updated = self.update_certificate(
296 self.bench_config.get_primary_certificate(),
297 raise_error=False,
298 )
299 if certificate_updated:
300 self.output.print("Certificate Updated")
302 # admin tools
303 if self.bench_config.admin_tools:
304 if not self.admin_tools.compose_file_manager.compose_path.exists():
305 self.sync_admin_tools_compose()
306 else:
307 self.admin_tools.enable(force_configure=True)
308 self.output.print("Enabled Admin-tools")
310 elif not self.admin_tools.compose_file_manager.compose_path.exists():
311 self.output.print("Admin tools is already disabled")
312 else:
313 self.admin_tools.disable()
314 self.output.print("Disabled Admin-tools")
316 self.output.change_head("Restarting frappe server")
317 self.restart_supervisor_service("frappe")
318 self.output.print("Restarted frappe server")
319 self.logger.info(f"Bench config synchronized: {self.name}", extra_fields=extra)
320 except Exception as e:
321 extra["error"] = str(e)
322 self.logger.exception(f"Failed to sync bench config: {self.name}", extra_fields=extra)
323 raise
325 def save_bench_config(self, print_message: bool = True):
326 extra = {"operation": "config_save_bench_config", "bench_name": self.name, "print_message": print_message}
327 self.logger.debug(f"Saving bench config: {self.name}", extra_fields=extra)
328 try:
329 if print_message:
330 self.output.change_head("Saving bench config changes")
331 self.bench_config.export_to_toml(self.bench_config.root_path)
332 if print_message:
333 self.output.print("Saved bench config")
334 self.logger.info(f"Bench config saved: {self.name}", extra_fields=extra)
335 except Exception as e:
336 extra["error"] = str(e)
337 self.logger.exception(f"Failed to save bench config: {self.name}", extra_fields=extra)
338 raise
340 @property
341 def exists(self):
342 return self.path.exists()
344 def create(self, is_template_bench: bool = False):
345 """
346 Creates a new bench using the provided template inputs.
348 Args:
349 is_template_bench: If True, creates a minimal bench without full site setup
351 Returns:
352 None
353 """
354 extra = {"operation": "bench_create", "bench_name": self.name, "is_template_bench": is_template_bench}
355 self.logger.debug(f"Starting bench creation: {self.name}", extra_fields=extra)
356 try:
357 self.orchestrator.create_bench(is_template_bench)
358 self.logger.info(f"Bench created successfully: {self.name}", extra_fields=extra)
359 except Exception as e:
360 extra["error"] = str(e)
361 self.logger.exception(f"Failed to create bench: {self.name}", extra_fields=extra)
362 raise
364 def set_common_bench_config(self, config: dict):
365 """
366 Sets the values in the common_site_config.json file.
368 Args:
369 config (dict): A dictionary containing the key-value pairs
370 """
371 extra = {"operation": "config_set_common", "bench_name": self.name, "config_keys": list(config.keys())}
372 self.logger.debug(f"Setting common bench configuration: {self.name}", extra_fields=extra)
373 try:
374 common_bench_config_path = self.path / "workspace/frappe-bench/sites/common_site_config.json"
375 if not common_bench_config_path.exists():
376 raise BenchException(self.name, message=f"File not found {common_bench_config_path.name}.")
378 save_dict_to_file(config, common_bench_config_path)
379 self.logger.info(f"Common bench configuration set: {self.name}", extra_fields=extra)
380 except Exception as e:
381 extra["error"] = str(e)
382 self.logger.exception(f"Failed to set common bench configuration: {self.name}", extra_fields=extra)
383 raise
385 def set_bench_site_config(self, config: dict):
386 """
387 Sets the values in the bench's site site_config.json file.
389 Args:
390 config (dict): A dictionary containing the key-value pairs
391 """
392 site_config_path = self.path / "workspace/frappe-bench/sites" / self.name / "site_config.json"
393 if not site_config_path.exists():
394 raise BenchException(self.name, message=f"File not found {site_config_path.name}.")
395 save_dict_to_file(config, site_config_path)
397 def get_common_bench_config(self):
398 return self.info_display.get_common_config()
400 def get_bench_site_config(self):
401 return self.info_display.get_site_config()
403 def generate_compose(self, inputs: dict) -> None:
404 """
405 Generates the compose file for the site based on the given inputs.
407 Args:
408 inputs (dict): A dictionary containing the inputs for generating the compose file.
410 Returns:
411 None
412 """
413 return self.docker_ops.generate_compose(inputs)
415 def sync_bench_common_site_config(self, services_db_host: str, services_db_port: int):
416 """
417 Syncs the common site configuration with the global database information and container prefix.
419 This function sets the common site configuration data including the socketio port, database host and port,
420 and the Redis cache, queue, and socketio URLs.
421 """
422 self.database.sync_common_site_config(services_db_host, services_db_port)
424 def create_compose_dirs(self) -> bool:
425 return self.docker_ops.create_compose_dirs()
427 def start(
428 self,
429 force: bool = False,
430 reconfigure_workers: bool = False,
431 include_default_workers=False,
432 include_custom_workers=False,
433 reconfigure_supervisor: bool = False,
434 reconfigure_common_site_config: bool = False,
435 sync_dev_packages: bool = False,
436 ):
437 """
438 Starts the bench with various configuration options.
439 """
440 extra = {
441 "operation": "bench_start",
442 "bench_name": self.name,
443 "force": force,
444 "reconfigure_workers": reconfigure_workers,
445 }
446 self.logger.debug(f"Starting bench: {self.name}", extra_fields=extra)
447 try:
448 self.orchestrator.start_bench(
449 force=force,
450 reconfigure_workers=reconfigure_workers,
451 include_default_workers=include_default_workers,
452 include_custom_workers=include_custom_workers,
453 reconfigure_supervisor=reconfigure_supervisor,
454 reconfigure_common_site_config=reconfigure_common_site_config,
455 sync_dev_packages=sync_dev_packages,
456 )
457 self.logger.info(f"Bench started successfully: {self.name}", extra_fields=extra)
458 except Exception as e:
459 extra["error"] = str(e)
460 self.logger.exception(f"Failed to start bench: {self.name}", extra_fields=extra)
461 raise
463 def frappe_logs_till_start(self):
464 """
465 Retrieves and prints the logs of the 'frappe' service until site supervisor starts.
467 Args:
468 status_msg (str, optional): Custom status message to display. Defaults to None.
469 """
470 return self.docker_ops.frappe_logs_till_start()
472 def stop(self):
473 """
474 Stop the site by stopping the containers.
476 Returns:
477 bool: True if the site is successfully stopped, False otherwise.
478 """
479 extra = {"operation": "bench_stop", "bench_name": self.name}
480 self.logger.debug(f"Stopping bench: {self.name}", extra_fields=extra)
481 try:
482 self.docker_ops.stop(timeout=10)
484 if self.workers.compose_file_manager.exists():
485 self.output.change_head("Stopping bench workers services")
486 self.workers.docker_client.compose.stop(services=[], timeout=10)
487 self.output.print("Stopped bench workers services")
489 # stop admin_tools if exists
490 if self.admin_tools.compose_file_manager.exists():
491 self.output.change_head("Stopping bench admin tools services")
492 self.admin_tools.stop()
493 self.output.print("Stopped bench admin tools services")
495 self.logger.info(f"Bench stopped successfully: {self.name}", extra_fields=extra)
496 except Exception as e:
497 extra["error"] = str(e)
498 self.logger.exception(f"Failed to stop bench: {self.name}", extra_fields=extra)
499 raise
501 def remove_containers_and_dirs(self):
502 """
503 Removes the site by stopping and removing the containers associated with it,
504 and deleting the site directory.
506 Returns:
507 bool: True if the site is successfully removed, False otherwise.
508 """
509 # TODO handle low level errors like read only, write only, etc.
510 if self.compose_file_manager.exists():
511 self.output.change_head("Removing bench containers")
512 self.docker_ops.remove_containers(remove_volumes=True, timeout=5)
513 self.output.print("Removed bench containers")
514 else:
515 self.output.warning("Bench compose file not found. Skipping containers removal.")
517 if self.workers.compose_file_manager.exists():
518 self.output.change_head("Removing bench workers containers")
519 output = self.workers.docker_client.compose.down(remove_orphans=True, volumes=True, timeout=5, stream=True)
520 self.output.live_lines(cast("Iterator[tuple[str, bytes]]", output), padding=(0, 0, 0, 2))
521 self.output.print("Removed bench workers containers")
522 else:
523 self.output.warning("Bench workers compose file not found. Skipping containers removal.")
525 if self.admin_tools.compose_file_manager.exists():
526 self.output.change_head("Removing bench admin tools containers")
527 # down_service equivalent: stop + remove containers + volumes
528 try:
529 self.admin_tools.docker_client.compose.down(remove_orphans=True, volumes=True, timeout=5, stream=True)
530 except Exception:
531 pass # Best effort cleanup
532 self.output.print("Removed bench admin tools containers")
533 else:
534 self.output.warning("Bench admin tools compose file not found. Skipping containers removal.")
536 self.output.change_head("Removing all bench files and directories")
537 try:
538 shutil.rmtree(self.path)
539 except PermissionError:
540 try:
541 images = self.compose_file_manager.get_all_images()
542 if "frappe" in images:
543 frappe_image = images["frappe"]
544 frappe_image = f"{frappe_image['name']}:{frappe_image['tag']}"
545 self.docker_client.run(
546 image=frappe_image,
547 entrypoint="/bin/sh",
548 command="-c 'chown -R frappe:frappe .'",
549 volume=[f"{self.path}/workspace:/workspace"],
550 stream=False,
551 )
552 shutil.rmtree(self.path)
553 except Exception:
554 raise BenchRemoveDirectoryError(self.name, self.path)
556 self.output.print("Removed all bench files and directories")
558 def is_bench_created(self, retry=60, interval=1) -> bool:
559 curl_command = "curl -I --max-time {retry} --connect-timeout {retry} {headers} {url}"
560 url = "http://localhost"
561 headers = ""
562 if self.bench_config.environment_type == FMBenchEnvType.prod:
563 headers = f"-H 'Host: {self.name}'"
565 check_command = curl_command.format(retry=retry, headers=headers, url=url)
567 for _ in range(retry):
568 try:
569 # Execute curl command on frappe service
570 result = self.docker_client.compose.exec(
571 service="frappe",
572 command=check_command,
573 stream=False,
574 )
575 for line in result.stdout:
576 if "HTTP/1.1 200 OK" in line:
577 return True
578 except Exception:
579 time.sleep(interval)
580 return False
582 def sync_workers_compose(
583 self,
584 force_recreate: bool = False,
585 setup_supervisor: bool = True,
586 include_default_workers: bool = True,
587 include_custom_workers: bool = True,
588 ):
589 extra = {
590 "operation": "workers_sync_compose",
591 "bench_name": self.name,
592 "force_recreate": force_recreate,
593 "setup_supervisor": setup_supervisor,
594 }
595 self.logger.debug(f"Syncing workers compose for bench: {self.name}", extra_fields=extra)
596 try:
597 self.worker_coordinator.sync_workers_compose(
598 force_recreate=force_recreate,
599 setup_supervisor=setup_supervisor,
600 include_default_workers=include_default_workers,
601 include_custom_workers=include_custom_workers,
602 )
603 self.logger.info(f"Workers compose synced for bench: {self.name}", extra_fields=extra)
604 except Exception as e:
605 extra["error"] = str(e)
606 self.logger.exception(f"Failed to sync workers compose for bench: {self.name}", extra_fields=extra)
607 raise
609 def backup_restore_workers_supervisor(self, backup_manager: BackupManager):
610 self.worker_coordinator.backup_restore_workers_supervisor(backup_manager)
612 def backup_workers_supervisor_conf(self):
613 return self.worker_coordinator.backup_workers_supervisor_conf()
615 def regenerate_workers_supervisor_conf(self):
616 self.worker_coordinator.regenerate_workers_supervisor_conf()
618 def get_bench_installed_apps_list(self):
619 return self.info_display.get_installed_apps_list()
621 # this can be plugable
622 def get_db_connection_info(self):
623 return self.database.get_connection_info()
625 def create_certificate(self):
626 extra = {"operation": "ssl_create_certificate", "bench_name": self.name}
627 self.logger.debug(f"Creating SSL certificate: {self.name}", extra_fields=extra)
628 try:
629 self.ssl.create_individual_certificates()
630 self.save_bench_config()
631 self.logger.info(f"SSL certificate created successfully: {self.name}", extra_fields=extra)
632 except Exception as e:
633 extra["error"] = str(e)
634 self.logger.exception(f"Failed to create SSL certificate: {self.name}", extra_fields=extra)
635 raise
637 def has_certificate(self):
638 return self.ssl.has_certificate()
640 def remove_certificate(self):
641 """
642 Remove ALL SSL certificates for this bench.
644 This removes certificates for the primary domain and all alias domains,
645 including their symlinks, vhost configs, and acme.sh configurations.
646 Then clears the certificate list in bench_config.
647 """
648 extra = {"operation": "ssl_remove_certificate", "bench_name": self.name}
649 self.logger.debug(f"Removing SSL certificate: {self.name}", extra_fields=extra)
650 try:
651 self.ssl.remove_all_certificates()
652 # Clear all certificates from config
653 self.bench_config.ssl_certificates = []
654 self.save_bench_config()
655 self.logger.info(f"SSL certificate removed successfully: {self.name}", extra_fields=extra)
656 except Exception as e:
657 extra["error"] = str(e)
658 self.logger.exception(f"Failed to remove SSL certificate: {self.name}", extra_fields=extra)
659 raise
661 def update_certificate(self, certificate: SSLCertificate, raise_error: bool = True):
662 extra = {"operation": "ssl_update_certificate", "bench_name": self.name, "raise_error": raise_error}
663 self.logger.debug(f"Updating SSL certificate: {self.name}", extra_fields=extra)
664 try:
665 result = self.ssl.update_certificate(certificate, raise_error)
666 if result:
667 self.bench_config.set_primary_certificate(certificate)
668 self.logger.info(f"SSL certificate updated: {self.name} (result: {result})", extra=extra)
669 return result
670 except Exception as e:
671 extra["error"] = str(e)
672 self.logger.exception(f"Failed to update SSL certificate: {self.name}", extra_fields=extra)
673 raise
675 def renew_certificate(self):
676 extra = {"operation": "ssl_renew_certificate", "bench_name": self.name}
677 self.logger.debug(f"Renewing SSL certificate: {self.name}", extra_fields=extra)
678 try:
679 result = self.ssl.renew_certificate()
680 self.logger.info(f"SSL certificate renewed: {self.name} (result: {result})", extra=extra)
681 return result
682 except Exception as e:
683 extra["error"] = str(e)
684 self.logger.exception(f"Failed to renew SSL certificate: {self.name}", extra_fields=extra)
685 raise
687 def update_alias_domains(self, add_domains: list[str] | None = None, remove_domains: list[str] | None = None):
688 """
689 Update alias domains for the bench with atomic rollback support.
691 Works independently of SSL status:
692 - If SSL is active: regenerates certificate with updated domains
693 - If SSL is inactive: updates config only
695 Args:
696 add_domains: List of domains to add as aliases
697 remove_domains: List of domains to remove from aliases
699 Raises:
700 ValueError: If attempting to remove primary domain
701 Exception: If certificate generation fails (config is rolled back)
702 """
703 self.orchestrator.update_alias_domains(add_domains, remove_domains)
705 def info(self):
706 """
707 Retrieves and displays information about the bench.
709 This method retrieves various information about the site, such as site URL, site root, database details,
710 Frappe username and password, root database user and password, and more. It then formats and displays
711 this information using the richprint library.
712 """
713 self.info_display.display_info()
715 def shell(self, compose_service: str, user: str | None, shell_path: str | None = None, use_run: bool = False):
716 """
717 Spawns a shell for the specified service and user.
719 Args:
720 service (str): The name of the service.
721 user (str | None): The name of the user. If None, defaults to "frappe".
722 shell_path (str | None): Path to shell executable (e.g., /bin/sh, /bin/bash).
723 use_run (bool): Use 'docker compose run --rm' instead of 'docker compose exec'.
725 """
726 return self.docker_ops.shell(compose_service, user, shell_path=shell_path, use_run=use_run)
728 def execute_command(
729 self,
730 compose_service: str,
731 command: str,
732 user: str | None = None,
733 shell_path: str | None = None,
734 use_run: bool = False,
735 ) -> int:
736 """
737 Execute a single command in the specified service and return exit code.
739 Args:
740 compose_service: The name of the service
741 command: The command to execute
742 user: The name of the user (defaults to "frappe" for frappe service)
743 shell_path: Path to shell executable (e.g., /bin/sh, /bin/bash)
744 use_run: Use 'docker compose run --rm' instead of 'docker compose exec'
746 Returns:
747 Exit code of the executed command
748 """
749 return self.docker_ops.execute_command(compose_service, command, user, shell_path=shell_path, use_run=use_run)
751 def get_log_file_paths(self):
752 return self.info_display.get_log_file_paths()
754 def handle_frappe_server_file_logs(self, follow: bool):
755 log_generators = []
757 try:
758 # Get log file paths
759 log_file_paths = self.get_log_file_paths()
761 # Check how many log files are available
762 num_log_files = len(log_file_paths)
764 if num_log_files == 0:
765 self.output.print("[yellow]No log files found.[/yellow]")
766 return
768 # Open log files and create generators
769 for path in log_file_paths:
770 log_generators.append(log_file(open(path), follow=follow))
772 if follow:
773 while True:
774 try:
775 for line in itertools.chain.from_iterable(log_generators):
776 print(line.strip())
777 except StopIteration:
778 time.sleep(0.1)
779 else:
780 for lines in itertools.zip_longest(*log_generators, fillvalue=""):
781 for line in lines:
782 if line:
783 print(line.strip())
785 finally:
786 for logfile in log_generators:
787 logfile.close()
789 def logs(self, follow: bool, service: str | None = None):
790 """
791 Display logs for the site or a specific service.
793 Args:
794 follow (bool): Whether to continuously follow the logs or not.
795 service (str, optional): The name of the service to display logs for. If not provided, logs for the entire site will be displayed.
796 """
797 self.output.change_head("Showing logs")
798 try:
799 if not service:
800 self.handle_frappe_server_file_logs(follow=follow)
801 else:
802 if not self._is_service_running(service):
803 self.output.stop()
804 self.output.display_error(
805 f"Cannot show logs. [blue]{self.name}[/blue]'s compose service '{service}' not running!",
806 )
807 return
808 self.docker_ops.logs(services=[service], follow=follow)
810 except KeyboardInterrupt:
811 print("Detected CTRL+C. Exiting..")
813 def attach_to_bench(self, user: str, extensions: list[str], workdir: str, debugger: bool = False) -> None:
814 """
815 Attaches to a running bench's container using Visual Studio Code Remote Containers extension.
817 Args:
818 user: Username to be used in the container
819 extensions: List of VS Code extensions to install
820 workdir: Working directory path inside container
821 debugger: Whether to setup debugging configuration
823 Raises:
824 BenchNotRunning: If the bench container is not running
825 BenchAttachTocontainerFailed: If attaching to container fails
826 """
827 return self.devtools.attach_to_bench(user, extensions, workdir, debugger)
829 def remove_database_and_user(self):
830 """
831 This function is used to remove db and user of the site at self.name and path at self.path.
832 """
833 extra = {"operation": "db_remove", "bench_name": self.name}
834 self.logger.debug(f"Removing database and user for bench: {self.name}", extra_fields=extra)
835 try:
836 self.database.remove_database_and_user()
837 self.logger.info(f"Database and user removed for bench: {self.name}", extra_fields=extra)
838 except Exception as e:
839 extra["error"] = str(e)
840 self.logger.exception(f"Failed to remove database and user for bench: {self.name}", extra_fields=extra)
841 raise
843 def remove_bench(self, default_choice: bool = True, delete_db_from_global_db: bool | None = None):
844 """
845 Removes the bench.
847 Args:
848 default_choice: If True, defaults to 'no' for confirmation prompt
849 delete_db_from_global_db: Whether to delete DB from global-db.
850 If None, prompts interactively when DB is in global-db.
851 """
852 extra = {"operation": "bench_remove", "bench_name": self.name, "default_choice": default_choice}
853 self.logger.debug(f"Attempting to remove bench: {self.name}", extra_fields=extra)
855 params: dict[str, Any] = {}
856 params["prompt"] = f"🤔 Do you want to remove [bold][green]'{self.name}'[/bold][/green]"
857 params["choices"] = ["yes", "no"]
859 if default_choice:
860 params["default"] = "no"
862 params["required_flag"] = "--yes or -y"
863 continue_remove = self.output.prompt_ask(**params)
865 if continue_remove == "no":
866 self.logger.debug(f"Bench removal cancelled by user: {self.name}", extra_fields=extra)
867 return False
869 from frappe_manager.output_manager import spinner
871 try:
872 with spinner(self.output, "Removing bench"):
873 try:
874 self.remove_certificate()
875 except Exception as e:
876 self.output.warning(str(e))
878 try:
879 self._handle_database_deletion(delete_db_from_global_db)
880 except Exception as e:
881 self.output.warning(f"Database deletion failed: {e!s}")
882 self.output.warning("Continuing with bench removal...")
884 self.remove_containers_and_dirs()
886 self.logger.info(f"Bench removed successfully: {self.name}", extra_fields=extra)
887 return True
888 except Exception as e:
889 extra["error"] = str(e)
890 self.logger.exception(f"Failed to remove bench: {self.name}", extra_fields=extra)
891 raise
893 def _is_using_global_db(self) -> bool:
894 """
895 Check if bench is using FM's managed global-db service.
897 Returns:
898 True if bench uses global-db, False otherwise
899 """
900 try:
901 db_info = self.database.get_connection_info()
902 db_host = db_info.get("host", "")
904 return db_host == "global-db"
905 except Exception:
906 return False
908 def _handle_database_deletion(self, delete_db_from_global_db: bool | None):
909 """
910 Handle database deletion based on user preference and database location.
912 Args:
913 delete_db_from_global_db: User preference for database deletion.
914 None = prompt if using global-db
915 True = delete from global-db
916 False = don't delete from global-db
917 """
918 extra = {"operation": "db_handle_deletion", "bench_name": self.name}
919 self.logger.debug(f"Handling database deletion for bench: {self.name}", extra_fields=extra)
920 try:
921 is_global_db = self._is_using_global_db()
923 if not is_global_db:
924 self.output.print("Bench is not using FM's managed global-db. Skipping database deletion")
925 self.logger.info(f"Skipping database deletion - not using global-db: {self.name}", extra_fields=extra)
926 return
928 should_delete = delete_db_from_global_db
930 if should_delete is None:
931 params = {
932 "prompt": f"🗄️ Do you want to remove the database '[bold]{self.name}[/bold]' from global-db?",
933 "choices": ["yes", "no"],
934 "default": "yes",
935 "required_flag": "--delete-db-from-global-db or --no-delete-db-from-global-db",
936 }
937 choice = self.output.prompt_ask(**params)
938 should_delete = choice == "yes"
940 if should_delete:
941 self.remove_database_and_user()
942 else:
943 self.output.print("Skipping database deletion from global-db")
944 self.logger.info(f"Database deletion skipped by user: {self.name}", extra_fields=extra)
946 self.logger.info(f"Database deletion handled: {self.name}", extra_fields=extra)
947 except Exception as e:
948 extra["error"] = str(e)
949 self.logger.exception(f"Failed to handle database deletion for bench: {self.name}", extra_fields=extra)
950 raise
952 def ensure_workers_running_if_available(self):
953 extra = {"operation": "workers_ensure_running", "bench_name": self.name}
954 self.logger.debug(f"Ensuring workers running if available for bench: {self.name}", extra_fields=extra)
955 try:
956 self.worker_coordinator.ensure_workers_running_if_available()
957 self.logger.info(f"Workers status ensured for bench: {self.name}", extra_fields=extra)
958 except Exception as e:
959 extra["error"] = str(e)
960 self.logger.exception(f"Failed to ensure workers for bench: {self.name}", extra_fields=extra)
961 raise
963 def ensure_admin_tools_running_if_available(self):
964 if self.admin_tools.compose_file_manager.exists():
965 if self.bench_config.admin_tools:
966 admin_tools_running = False
967 try:
968 services = self.admin_tools.compose_file_manager.get_services_list()
969 containers = self.admin_tools.compose_file_manager.get_container_names().values()
970 all_statuses = self.admin_tools.docker_client.compose.get_all_services_status()
971 running_statuses = {
972 status["Service"]: status["State"]
973 for status in all_statuses
974 if status.get("Name") in containers
975 }
976 admin_tools_running = all(running_statuses.get(service) == "running" for service in services)
977 except Exception:
978 admin_tools_running = False
980 if not admin_tools_running:
981 if self.running:
982 self.admin_tools.enable()
983 else:
984 atleast_one_service_running = False
986 try:
987 services = self.admin_tools.compose_file_manager.get_services_list()
988 containers = self.admin_tools.compose_file_manager.get_container_names().values()
989 all_statuses = self.admin_tools.docker_client.compose.get_all_services_status()
990 running_services = {
991 status["Service"]: status["State"]
992 for status in all_statuses
993 if status.get("Name") in containers
994 }
995 for service in running_services:
996 if service == "running":
997 atleast_one_service_running = True
998 except Exception:
999 atleast_one_service_running = False
1001 if atleast_one_service_running:
1002 self.admin_tools.disable()
1004 def sync_admin_tools_compose(self):
1005 extra = {"operation": "admin_tools_sync_compose", "bench_name": self.name}
1006 self.logger.debug(f"Syncing admin tools compose for bench: {self.name}", extra_fields=extra)
1007 try:
1008 self.admin_tools.generate_compose(self.services.database_manager.database_server_info.host)
1009 restart_required = self.admin_tools.enable(force_recreate_container=True)
1010 self.logger.info(f"Admin tools compose synced for bench: {self.name}", extra_fields=extra)
1011 return restart_required
1012 except Exception as e:
1013 extra["error"] = str(e)
1014 self.logger.exception(f"Failed to sync admin tools compose for bench: {self.name}", extra_fields=extra)
1015 raise
1017 def frappe_service_run_command(self, command: str):
1018 try:
1019 self.docker_client.compose.exec("frappe", command, user="frappe", stream=False)
1020 except DockerException as e:
1021 raise BenchException("frappe", f"Faild to run {command} in frappe service.")
1023 def get_apps_dev_requirements(self) -> list[str]:
1024 """Parse pip requirement string to package name and version"""
1025 return self.devtools.get_apps_dev_requirements()
1027 def remove_dev_packages(self):
1028 return self.devtools.remove_dev_packages()
1030 def install_dev_packages(self):
1031 return self.devtools.install_dev_packages()
1033 def is_supervisord_running(self, interval: int = 2, timeout: int = 30):
1034 return self.supervisor.is_supervisord_running(interval, timeout)
1036 def reset(self, admin_password: str | None = None):
1037 admin_pass = None
1039 if admin_password:
1040 admin_pass = admin_password
1041 else:
1042 if not admin_pass:
1043 site_config = self.get_bench_site_config()
1044 if "admin_password" in site_config:
1045 admin_pass = site_config["admin_password"]
1046 self.output.print("Using admin_password defined in site_config.json")
1048 if not admin_pass:
1049 common_site_config = self.get_common_bench_config()
1050 if "admin_password" in common_site_config:
1051 admin_pass = common_site_config["admin_password"]
1052 self.output.print("Using admin_password defined in common_site_config.json")
1054 if not admin_pass:
1055 admin_pass = self.output.prompt_ask(
1056 prompt=f"Please enter admin password for site {self.name}",
1057 required_flag="--admin-pass",
1058 )
1060 self.output.change_head(f"Resetting bench site {self.name}")
1062 self.site_manager.reset_bench_site(admin_pass)
1063 self.set_bench_site_config({"admin_password": admin_pass})
1065 self.output.print(f"Reset bench site {self.name}")
1067 def restart_supervisor_service(
1068 self,
1069 service: str,
1070 docker_client_obj: DockerClient | None = None,
1071 timeout: int = 30,
1072 interval: int = 1,
1073 force: bool = False,
1074 ):
1075 return self.supervisor.restart_supervisor_service(service, docker_client_obj, timeout, interval, force)
1077 def restart_web_containers_services(self, use_container_restart: bool = False, force: bool = False):
1078 """
1079 Restarts frappe server and socketio containers.
1081 Args:
1082 use_container_restart: If True, restart entire containers. If False, restart supervisor processes.
1083 force: If True, use aggressive restart (timeout=0 for container, stop+start for supervisor).
1084 """
1085 web_services = [
1086 SiteServicesEnum.frappe.value,
1087 SiteServicesEnum.socketio.value,
1088 ]
1090 if use_container_restart:
1091 self.docker_ops.restart_services(web_services, force=force)
1092 else:
1093 for service in web_services:
1094 self.output.change_head(f"Restarting web services - {service}")
1095 is_restarted = self.restart_supervisor_service(service, force=force)
1096 if is_restarted:
1097 action = "Stopped and started" if force else "Restarted"
1098 self.output.print(f"{action} supervisor processes - {service}")
1100 def restart_redis_services_containers(self):
1101 """Restarts redis containers"""
1103 redis_services = [
1104 SiteServicesEnum.redis_cache.value,
1105 SiteServicesEnum.redis_queue.value,
1106 ]
1107 self.output.change_head(f"Restarting redis services - {' '.join(redis_services)}")
1108 self.docker_ops.restart_services(redis_services)
1109 self.output.print(f"Restarted redis services - {' '.join(redis_services)}")
1111 def restart_nginx_service(self, force: bool = False):
1112 """
1113 Restarts nginx container.
1115 Args:
1116 force: If True, use timeout=0 for immediate kill. If False, use default graceful timeout.
1117 """
1118 nginx_service = [SiteServicesEnum.nginx.value]
1119 self.output.change_head("Restarting nginx service")
1120 self.docker_ops.restart_services(nginx_service, force=force)
1121 action = "Force restarted" if force else "Restarted"
1122 self.output.print(f"{action} nginx service")
1124 def restart_workers_containers_services(self, use_container_restart: bool = False, force: bool = False):
1125 """Restarts workers and schedule containers"""
1126 self.worker_coordinator.restart_workers_containers_services(
1127 use_container_restart=use_container_restart,
1128 force=force,
1129 )
1131 def update_upload_limit(self, upload_limit: str):
1132 """
1133 Update upload size limit across all three required locations:
1134 1. site_config.json (max_file_size in bytes)
1135 2. Bench nginx config (via template regeneration)
1136 3. nginx-proxy vhost.d files (for all domains)
1138 Args:
1139 upload_limit: Size string (e.g., "50M", "100M", "1G")
1141 Raises:
1142 BenchException: If format is invalid or operation fails
1143 """
1144 import re
1146 # Validate format (e.g., "50M", "100M", "500M", "1G")
1147 if not re.match(r"^\d+[MG]$", upload_limit, re.IGNORECASE):
1148 raise BenchException(
1149 self.name,
1150 message=f"Invalid upload limit format: '{upload_limit}'. Use format like '50M' or '1G'",
1151 )
1153 # 1. Update site_config.json (convert to bytes)
1154 size_bytes = self._parse_size_to_bytes(upload_limit)
1155 self.set_bench_site_config({"max_file_size": size_bytes})
1156 self.output.print(f"Updated site_config.json (max_file_size: {size_bytes} bytes)")
1158 # 2. Update BenchConfig (will affect nginx template on restart)
1159 self.bench_config.upload_limit = upload_limit.upper()
1160 self.save_bench_config()
1161 self.output.print("Updated bench configuration")
1163 # 2b. Regenerate docker-compose to include new environment variable
1164 inputs = self.bench_config.export_to_compose_inputs()
1165 self.generate_compose(inputs)
1166 self.output.print("Regenerated docker-compose configuration")
1168 # 3. Create custom nginx config file for bench nginx
1169 custom_conf_dir = self.path / "configs" / "nginx" / "conf" / "custom"
1170 custom_conf_dir.mkdir(parents=True, exist_ok=True)
1171 upload_limit_conf = custom_conf_dir / "upload-limit.conf"
1172 upload_limit_conf.write_text(f"client_max_body_size {upload_limit.lower()};\n")
1173 self.output.print("Created custom nginx configuration")
1175 # 4. Reload bench nginx to apply configuration
1176 self.bench_nginx_controller.reload()
1178 # 5. Update nginx-proxy vhost.d for all domains (primary + aliases)
1179 all_domains = [self.name] + self.bench_config.alias_domains
1180 vhostd_dir = self.services.path / "nginx-proxy" / "vhostd"
1182 if vhostd_dir.exists():
1183 upload_mgr = UploadLimitManager(vhostd_dir)
1184 upload_mgr.set_upload_limit_for_domains(all_domains, upload_limit.lower())
1185 self.output.print(f"Updated nginx-proxy vhost.d for {len(all_domains)} domain(s)")
1187 # 6. Reload nginx-proxy to pick up vhost.d changes
1188 if self.services.is_service_running("global-nginx-proxy"):
1189 self.services.nginx_controller.reload()
1191 self.output.print(
1192 f"Upload size limit updated to {upload_limit} (site_config: {size_bytes} bytes, nginx: {upload_limit.lower()})",
1193 )
1195 def _parse_size_to_bytes(self, size_str: str) -> int:
1196 """
1197 Convert size string (e.g., '50M', '1G') to bytes for Frappe site_config.json.
1199 Args:
1200 size_str: Size string (e.g., "50M", "1G")
1202 Returns:
1203 Size in bytes (integer)
1205 Raises:
1206 BenchException: If format is invalid
1208 Examples:
1209 "50M" -> 52428800 (50 * 1024 * 1024)
1210 "1G" -> 1073741824 (1 * 1024 * 1024 * 1024)
1211 """
1212 import re
1214 match = re.match(r"^(\d+)([MG])$", size_str, re.IGNORECASE)
1215 if not match:
1216 raise BenchException(
1217 self.name,
1218 message=f"Invalid size format: '{size_str}'. Expected format: <number><unit> (e.g., '50M', '1G')",
1219 )
1221 value = int(match.group(1))
1222 unit = match.group(2).upper()
1224 if unit == "M":
1225 return value * 1024 * 1024 # Convert MB to bytes
1226 if unit == "G":
1227 return value * 1024 * 1024 * 1024 # Convert GB to bytes
1229 # Should never reach here due to regex validation
1230 raise BenchException(self.name, message=f"Unsupported unit: {unit}")
1232 def get_available_services(self) -> list[str]:
1233 """
1234 Get all available services from all compose files.
1236 Dynamically discovers services from:
1237 - docker-compose.yml (main services: frappe, nginx, redis, etc.)
1238 - docker-compose.workers.yml (workers: schedule, default-worker, short-worker, long-worker, custom workers)
1239 - docker-compose.admin-tools.yml (admin tools: adminer, mailpit)
1241 Returns:
1242 List of available service names across all compose files
1243 """
1244 services = []
1246 # Get services from main compose file
1247 if self.compose_file_manager.compose_path.exists():
1248 services.extend(self.compose_file_manager.get_services_list())
1250 # Get services from workers compose file
1251 workers_compose_path = self.path / "docker-compose.workers.yml"
1252 if workers_compose_path.exists():
1253 services.extend(self.workers.compose_file_manager.get_services_list())
1255 # Get services from admin tools compose file
1256 admin_tools_compose_path = self.path / "docker-compose.admin-tools.yml"
1257 if admin_tools_compose_path.exists():
1258 services.extend(self.admin_tools.compose_file_manager.get_services_list())
1260 return services