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

1import itertools 

2import shutil 

3import time 

4from collections.abc import Iterator 

5from pathlib import Path 

6from typing import Any, cast 

7 

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 

48 

49 

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) 

72 

73 self.compose_file_manager = compose_file_manager 

74 self.docker_client = docker_client 

75 

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 ) 

92 

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) 

96 

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 )() 

108 

109 self.admin_tools = BenchAdminTools(self, self.proxy_manager, verbose=verbose, output_handler=self.output) 

110 

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 

114 

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 ) 

123 

124 link_manager = CertificateLinkManager(ssl_storage_config) 

125 

126 def certificate_service_factory(cert, storage_cfg, output_handler): 

127 return create_certificate_service(self.logger, cert, storage_cfg, output_handler) 

128 

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 ) 

139 

140 self.ssl = BenchSSL( 

141 certificate_manager=self.certificate_manager, 

142 bench_name=name, 

143 is_service_running_fn=self._is_service_running, 

144 ) 

145 

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 

155 

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 ) 

163 

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 ) 

174 

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 ) 

183 

184 self.workers = BenchWorkers(self, not verbose, output_handler=self.output) 

185 

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 ) 

200 

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 ) 

211 

212 # For complex workflows 

213 self.orchestrator = BenchOrchestrator(logger=self.logger, bench=self, output_handler=self.output) 

214 

215 if workers_check: 

216 self.ensure_workers_running_if_available() 

217 

218 if admin_tools_check: 

219 self.ensure_admin_tools_running_if_available() 

220 

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" 

236 

237 bench_path = benches_path / bench_name 

238 bench_config_path: Path = bench_path / bench_config_file_name 

239 

240 if not bench_path.exists(): 

241 from frappe_manager.site_manager.exceptions import BenchNotFoundError 

242 

243 raise BenchNotFoundError(bench_name, bench_path) 

244 

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) 

247 

248 bench_config: BenchConfig = BenchConfig.import_from_toml(bench_config_path) 

249 

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 

254 

255 logger = ContextualLogger(base_log.get_logger(), context=LoggerContext()) 

256 

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 } 

268 

269 if output_handler is not None: 

270 parms["output_handler"] = output_handler 

271 

272 return cls(**parms) 

273 

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) 

277 

278 @property 

279 def running(self) -> bool: 

280 """Check if all bench services are running.""" 

281 return self.docker_ops.is_running() 

282 

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() 

286 

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}) 

293 

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") 

301 

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") 

309 

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") 

315 

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 

324 

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 

339 

340 @property 

341 def exists(self): 

342 return self.path.exists() 

343 

344 def create(self, is_template_bench: bool = False): 

345 """ 

346 Creates a new bench using the provided template inputs. 

347 

348 Args: 

349 is_template_bench: If True, creates a minimal bench without full site setup 

350 

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 

363 

364 def set_common_bench_config(self, config: dict): 

365 """ 

366 Sets the values in the common_site_config.json file. 

367 

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}.") 

377 

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 

384 

385 def set_bench_site_config(self, config: dict): 

386 """ 

387 Sets the values in the bench's site site_config.json file. 

388 

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) 

396 

397 def get_common_bench_config(self): 

398 return self.info_display.get_common_config() 

399 

400 def get_bench_site_config(self): 

401 return self.info_display.get_site_config() 

402 

403 def generate_compose(self, inputs: dict) -> None: 

404 """ 

405 Generates the compose file for the site based on the given inputs. 

406 

407 Args: 

408 inputs (dict): A dictionary containing the inputs for generating the compose file. 

409 

410 Returns: 

411 None 

412 """ 

413 return self.docker_ops.generate_compose(inputs) 

414 

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. 

418 

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) 

423 

424 def create_compose_dirs(self) -> bool: 

425 return self.docker_ops.create_compose_dirs() 

426 

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 

462 

463 def frappe_logs_till_start(self): 

464 """ 

465 Retrieves and prints the logs of the 'frappe' service until site supervisor starts. 

466 

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() 

471 

472 def stop(self): 

473 """ 

474 Stop the site by stopping the containers. 

475 

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) 

483 

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") 

488 

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") 

494 

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 

500 

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. 

505 

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.") 

516 

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.") 

524 

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.") 

535 

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) 

555 

556 self.output.print("Removed all bench files and directories") 

557 

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}'" 

564 

565 check_command = curl_command.format(retry=retry, headers=headers, url=url) 

566 

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 

581 

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 

608 

609 def backup_restore_workers_supervisor(self, backup_manager: BackupManager): 

610 self.worker_coordinator.backup_restore_workers_supervisor(backup_manager) 

611 

612 def backup_workers_supervisor_conf(self): 

613 return self.worker_coordinator.backup_workers_supervisor_conf() 

614 

615 def regenerate_workers_supervisor_conf(self): 

616 self.worker_coordinator.regenerate_workers_supervisor_conf() 

617 

618 def get_bench_installed_apps_list(self): 

619 return self.info_display.get_installed_apps_list() 

620 

621 # this can be plugable 

622 def get_db_connection_info(self): 

623 return self.database.get_connection_info() 

624 

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 

636 

637 def has_certificate(self): 

638 return self.ssl.has_certificate() 

639 

640 def remove_certificate(self): 

641 """ 

642 Remove ALL SSL certificates for this bench. 

643 

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 

660 

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 

674 

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 

686 

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. 

690 

691 Works independently of SSL status: 

692 - If SSL is active: regenerates certificate with updated domains 

693 - If SSL is inactive: updates config only 

694 

695 Args: 

696 add_domains: List of domains to add as aliases 

697 remove_domains: List of domains to remove from aliases 

698 

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) 

704 

705 def info(self): 

706 """ 

707 Retrieves and displays information about the bench. 

708 

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() 

714 

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. 

718 

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'. 

724 

725 """ 

726 return self.docker_ops.shell(compose_service, user, shell_path=shell_path, use_run=use_run) 

727 

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. 

738 

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' 

745 

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) 

750 

751 def get_log_file_paths(self): 

752 return self.info_display.get_log_file_paths() 

753 

754 def handle_frappe_server_file_logs(self, follow: bool): 

755 log_generators = [] 

756 

757 try: 

758 # Get log file paths 

759 log_file_paths = self.get_log_file_paths() 

760 

761 # Check how many log files are available 

762 num_log_files = len(log_file_paths) 

763 

764 if num_log_files == 0: 

765 self.output.print("[yellow]No log files found.[/yellow]") 

766 return 

767 

768 # Open log files and create generators 

769 for path in log_file_paths: 

770 log_generators.append(log_file(open(path), follow=follow)) 

771 

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()) 

784 

785 finally: 

786 for logfile in log_generators: 

787 logfile.close() 

788 

789 def logs(self, follow: bool, service: str | None = None): 

790 """ 

791 Display logs for the site or a specific service. 

792 

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) 

809 

810 except KeyboardInterrupt: 

811 print("Detected CTRL+C. Exiting..") 

812 

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. 

816 

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 

822 

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) 

828 

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 

842 

843 def remove_bench(self, default_choice: bool = True, delete_db_from_global_db: bool | None = None): 

844 """ 

845 Removes the bench. 

846 

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) 

854 

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"] 

858 

859 if default_choice: 

860 params["default"] = "no" 

861 

862 params["required_flag"] = "--yes or -y" 

863 continue_remove = self.output.prompt_ask(**params) 

864 

865 if continue_remove == "no": 

866 self.logger.debug(f"Bench removal cancelled by user: {self.name}", extra_fields=extra) 

867 return False 

868 

869 from frappe_manager.output_manager import spinner 

870 

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)) 

877 

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...") 

883 

884 self.remove_containers_and_dirs() 

885 

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 

892 

893 def _is_using_global_db(self) -> bool: 

894 """ 

895 Check if bench is using FM's managed global-db service. 

896 

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", "") 

903 

904 return db_host == "global-db" 

905 except Exception: 

906 return False 

907 

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. 

911 

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() 

922 

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 

927 

928 should_delete = delete_db_from_global_db 

929 

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" 

939 

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) 

945 

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 

951 

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 

962 

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 

979 

980 if not admin_tools_running: 

981 if self.running: 

982 self.admin_tools.enable() 

983 else: 

984 atleast_one_service_running = False 

985 

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 

1000 

1001 if atleast_one_service_running: 

1002 self.admin_tools.disable() 

1003 

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 

1016 

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.") 

1022 

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() 

1026 

1027 def remove_dev_packages(self): 

1028 return self.devtools.remove_dev_packages() 

1029 

1030 def install_dev_packages(self): 

1031 return self.devtools.install_dev_packages() 

1032 

1033 def is_supervisord_running(self, interval: int = 2, timeout: int = 30): 

1034 return self.supervisor.is_supervisord_running(interval, timeout) 

1035 

1036 def reset(self, admin_password: str | None = None): 

1037 admin_pass = None 

1038 

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") 

1047 

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") 

1053 

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 ) 

1059 

1060 self.output.change_head(f"Resetting bench site {self.name}") 

1061 

1062 self.site_manager.reset_bench_site(admin_pass) 

1063 self.set_bench_site_config({"admin_password": admin_pass}) 

1064 

1065 self.output.print(f"Reset bench site {self.name}") 

1066 

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) 

1076 

1077 def restart_web_containers_services(self, use_container_restart: bool = False, force: bool = False): 

1078 """ 

1079 Restarts frappe server and socketio containers. 

1080 

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 ] 

1089 

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}") 

1099 

1100 def restart_redis_services_containers(self): 

1101 """Restarts redis containers""" 

1102 

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)}") 

1110 

1111 def restart_nginx_service(self, force: bool = False): 

1112 """ 

1113 Restarts nginx container. 

1114 

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") 

1123 

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 ) 

1130 

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) 

1137 

1138 Args: 

1139 upload_limit: Size string (e.g., "50M", "100M", "1G") 

1140 

1141 Raises: 

1142 BenchException: If format is invalid or operation fails 

1143 """ 

1144 import re 

1145 

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 ) 

1152 

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)") 

1157 

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") 

1162 

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") 

1167 

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") 

1174 

1175 # 4. Reload bench nginx to apply configuration 

1176 self.bench_nginx_controller.reload() 

1177 

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" 

1181 

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)") 

1186 

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() 

1190 

1191 self.output.print( 

1192 f"Upload size limit updated to {upload_limit} (site_config: {size_bytes} bytes, nginx: {upload_limit.lower()})", 

1193 ) 

1194 

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. 

1198 

1199 Args: 

1200 size_str: Size string (e.g., "50M", "1G") 

1201 

1202 Returns: 

1203 Size in bytes (integer) 

1204 

1205 Raises: 

1206 BenchException: If format is invalid 

1207 

1208 Examples: 

1209 "50M" -> 52428800 (50 * 1024 * 1024) 

1210 "1G" -> 1073741824 (1 * 1024 * 1024 * 1024) 

1211 """ 

1212 import re 

1213 

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 ) 

1220 

1221 value = int(match.group(1)) 

1222 unit = match.group(2).upper() 

1223 

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 

1228 

1229 # Should never reach here due to regex validation 

1230 raise BenchException(self.name, message=f"Unsupported unit: {unit}") 

1231 

1232 def get_available_services(self) -> list[str]: 

1233 """ 

1234 Get all available services from all compose files. 

1235 

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) 

1240 

1241 Returns: 

1242 List of available service names across all compose files 

1243 """ 

1244 services = [] 

1245 

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()) 

1249 

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()) 

1254 

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()) 

1259 

1260 return services