Coverage for frappe_manager / site_manager / modules / bench_orchestrator.py: 10%

289 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-07-02 18:13 +0530

1""" 

2BenchOrchestrator - Complex workflow orchestration for bench operations 

3 

4This module handles multi-step orchestration workflows that require coordination 

5between multiple modules and services. It extracts complex business logic from 

6the main Bench class to keep it as a thin facade. 

7 

8The orchestrator encapsulates: 

9- Bench creation workflow 

10- Bench startup workflow 

11- Alias domain updates workflow 

12- Other complex multi-step operations 

13 

14By centralizing orchestration logic here, we maintain separation of concerns: 

15- Individual modules handle specific responsibilities 

16- Orchestrator coordinates between modules 

17- Bench class remains a simple interface 

18""" 

19 

20import copy 

21import time 

22from typing import TYPE_CHECKING 

23 

24from frappe_manager.logger.contextual import ContextualLogger 

25from frappe_manager.output_manager import OutputHandler 

26from frappe_manager.output_manager.rich_output import RichOutputHandler 

27from frappe_manager.site_manager.bench_config import FMBenchEnvType 

28from frappe_manager.site_manager.exceptions import BenchOperationException 

29 

30if TYPE_CHECKING: 

31 from frappe_manager.site_manager.site import Bench 

32 

33 

34class BenchOrchestrator: 

35 """ 

36 Orchestrator for complex multi-step bench workflows. 

37 

38 This class coordinates between multiple modules to execute complex 

39 workflows that require specific sequencing and error handling. 

40 

41 Attributes: 

42 bench: Reference to parent Bench instance 

43 logger: Logger instance for this orchestrator 

44 

45 Example: 

46 >>> orchestrator = BenchOrchestrator(bench_instance) 

47 >>> orchestrator.create_bench(is_template=False) 

48 """ 

49 

50 def __init__(self, logger: ContextualLogger, bench: "Bench", output_handler: OutputHandler | None = None): 

51 """ 

52 Initialize orchestrator with bench reference. 

53 

54 Args: 

55 logger: Contextual logger for audit/debug logging 

56 bench: Parent Bench instance that owns this orchestrator 

57 output_handler: Output handler for UI/logging (defaults to RichOutputHandler) 

58 """ 

59 self.logger = logger.child(component="orchestrator") 

60 self.bench = bench 

61 self.output = output_handler or RichOutputHandler() 

62 

63 def create_bench(self, is_template_bench: bool = False) -> None: 

64 """ 

65 Orchestrate the complete bench creation workflow using 5-phase approach. 

66 

67 Phase 1: Prepare Structure 

68 - Check Docker images 

69 - Create directories 

70 - Generate docker-compose.yml with FRAPPE_ENV 

71 

72 Phase 2: Initialize Bench (using docker compose run --rm) 

73 - Configure common_site_config.json 

74 - Setup supervisor configs 

75 - Clone apps 

76 - Detect Python/Node versions 

77 - Install dependencies 

78 - Build assets 

79 - All done in one-off containers (auto-removed) 

80 

81 Phase 3: Start and Verify Bench 

82 - Start containers with docker compose up 

83 - Wait for services 

84 - Verify bench server responding 

85 

86 Phase 4: Create Site 

87 - Create empty site in running container (no apps installed yet) 

88 - Set admin password and sync config 

89 

90 Phase 5: Finalize 

91 - Setup workers 

92 - Set migration state 

93 - Save config 

94 - Verify infrastructure 

95 

96 Phase 6: Install Apps 

97 - Install all apps to site 

98 - Run bench migrate 

99 - Graceful failure (bench remains functional if app installation fails) 

100 

101 Args: 

102 is_template_bench: If True, creates a minimal bench without site creation 

103 

104 Raises: 

105 Exception: If any step in the creation process fails 

106 """ 

107 bench = self.bench 

108 

109 bench.docker_ops.check_required_docker_images_available() 

110 

111 try: 

112 self._phase1_prepare_structure() 

113 

114 if is_template_bench: 

115 self._create_template_bench() 

116 return 

117 

118 self._phase2_initialize_bench() 

119 self._phase3_start_and_verify_bench() 

120 self._phase4_create_site() 

121 self._phase5_finalize() 

122 

123 apps_installed = self._phase6_install_apps() 

124 

125 if apps_installed: 

126 bench.info() 

127 

128 if ".localhost" not in bench.name: 

129 self.output.print( 

130 "Please note that You will have to add a host entry to your system's hosts file to access the bench locally.", 

131 ) 

132 else: 

133 remove_status = bench.remove_bench(default_choice=False) 

134 if not remove_status: 

135 bench.info() 

136 

137 except Exception as e: 

138 self._handle_creation_failure(e) 

139 

140 def _phase1_prepare_structure(self) -> None: 

141 """Phase 1: Create directories and docker-compose.yml""" 

142 bench = self.bench 

143 

144 self.output.change_head("Creating Bench Directory") 

145 bench.path.mkdir(parents=True, exist_ok=True) 

146 

147 self.output.change_head("Generating bench compose") 

148 compose_inputs = bench.bench_config.export_to_compose_inputs() 

149 

150 if "environment" not in compose_inputs: 

151 compose_inputs["environment"] = {} 

152 

153 compose_inputs["environment"]["frappe"] = compose_inputs["environment"].get("frappe", {}) 

154 compose_inputs["environment"]["frappe"]["FRAPPE_ENV"] = bench.bench_config.environment_type.value 

155 

156 bench.generate_compose(compose_inputs) 

157 bench.create_compose_dirs() 

158 

159 def _phase2_initialize_bench(self) -> None: 

160 """Phase 2: Initialize bench using docker compose run (no persistent containers)""" 

161 bench = self.bench 

162 

163 self.output.change_head("Initializing bench (this may take several minutes)") 

164 

165 self.output.change_head("Configuring common_site_config.json") 

166 common_site_config_data = bench.bench_config.get_commmon_site_config_data( 

167 bench.services.database_manager.database_server_info, 

168 ) 

169 bench.set_common_bench_config(common_site_config_data) 

170 self.output.print("Configured common_site_config.json") 

171 

172 bench.supervisor.setup_supervisor(bench.path, force=True, use_run=True) 

173 

174 self.output.change_head("Cloning apps") 

175 bench.app_manager.install_apps( 

176 bench.bench_config.apps_list, 

177 github_token=bench.bench_config.github_token, 

178 use_uv=bench.bench_config.use_uv, 

179 clone_only=True, 

180 use_run=True, 

181 ) 

182 

183 self._detect_python_node_versions() 

184 

185 if bench.bench_config.python_version or bench.bench_config.node_version: 

186 bench.app_manager.setup_python_and_node_environments(use_run=True, recreate_python_env=True) 

187 

188 self.output.change_head("Installing dependencies for all apps") 

189 bench.app_manager.install_apps( 

190 bench.bench_config.apps_list, 

191 github_token=bench.bench_config.github_token, 

192 use_uv=bench.bench_config.use_uv, 

193 skip_clone=True, 

194 use_run=True, 

195 ) 

196 

197 def _detect_python_node_versions(self) -> None: 

198 """Detect Python and Node version requirements from frappe app""" 

199 bench = self.bench 

200 

201 from frappe_manager.site_manager.bench_config import ( 

202 extract_node_version_requirement, 

203 extract_python_version_requirement, 

204 ) 

205 

206 frappe_app_path = bench.path / "workspace" / "frappe-bench" / "apps" / "frappe" 

207 if frappe_app_path.exists(): 

208 if not bench.bench_config.python_version: 

209 detected_python = extract_python_version_requirement(frappe_app_path) 

210 if detected_python: 

211 bench.bench_config.python_version = detected_python 

212 self.output.print(f"Detected Python version requirement: {detected_python}") 

213 self.logger.info(f"{bench.name}: Auto-detected Python version: {detected_python}") 

214 

215 if not bench.bench_config.node_version: 

216 detected_node = extract_node_version_requirement(frappe_app_path) 

217 if detected_node: 

218 bench.bench_config.node_version = detected_node 

219 self.output.print(f"Detected Node version requirement: {detected_node}") 

220 self.logger.info(f"{bench.name}: Auto-detected Node version: {detected_node}") 

221 

222 def _phase3_start_and_verify_bench(self) -> None: 

223 """Phase 3: Start containers and verify bench server responding""" 

224 bench = self.bench 

225 

226 self.output.change_head("Starting bench services") 

227 bench.docker_client.compose.up( 

228 services=[], 

229 detach=True, 

230 pull="never", 

231 force_recreate=False, 

232 ) 

233 self.output.print("Started bench services") 

234 

235 bench.site_manager.wait_for_required_services() 

236 

237 self._verify_bench_server_responding() 

238 

239 def _verify_bench_server_responding(self) -> None: 

240 """Verify bench server is working before site creation""" 

241 bench = self.bench 

242 

243 self.output.change_head("Verifying bench server is responding") 

244 

245 if not bench.supervisor.is_supervisord_running(timeout=30): 

246 raise Exception("Supervisord not running after 30 seconds") 

247 

248 max_retries = 30 

249 

250 for i in range(max_retries): 

251 try: 

252 result = bench.docker_client.compose.exec( 

253 service="frappe", 

254 command='curl -s -o /dev/null -w "%{http_code}" http://localhost:80', 

255 user="frappe", 

256 stream=False, 

257 ) 

258 status_code = "".join(result.stdout).strip() 

259 

260 if status_code in ["200", "404"]: 

261 self.output.print("Bench server is responding correctly") 

262 return 

263 

264 except Exception as e: 

265 self.logger.debug(f"Bench server check attempt {i + 1}: {e}") 

266 

267 if i < max_retries - 1: 

268 time.sleep(2) 

269 

270 raise Exception("Bench server not responding after 60 seconds") 

271 

272 def _phase4_create_site(self) -> None: 

273 """Phase 4: Create empty site (no apps installed yet)""" 

274 bench = self.bench 

275 

276 self.output.change_head(f"Creating bench site {bench.name}") 

277 bench.site_manager.create_bench_site() 

278 

279 bench.set_bench_site_config({"admin_password": bench.bench_config.admin_pass}) 

280 bench.sync_bench_config_configuration() 

281 

282 def _phase5_finalize(self) -> None: 

283 """Phase 5: Finalize bench infrastructure""" 

284 bench = self.bench 

285 

286 self.output.change_head("Configuring bench workers") 

287 bench.sync_workers_compose(force_recreate=True, setup_supervisor=False) 

288 self.output.print("Configured bench workers") 

289 

290 from datetime import datetime 

291 

292 from frappe_manager.migration_manager.version import Version 

293 from frappe_manager.site_manager.bench_config import MigrationState 

294 from frappe_manager.utils.helpers import get_current_fm_version 

295 

296 current_fm_version = Version(get_current_fm_version()) 

297 bench.bench_config.migration_state = MigrationState( 

298 migrated_to=str(current_fm_version.version), 

299 last_migration_date=datetime.now().isoformat(), 

300 ) 

301 

302 bench.save_bench_config() 

303 

304 self.output.change_head("Verifying bench infrastructure") 

305 if not bench.is_bench_created(): 

306 raise Exception("Bench site is inactive or unresponsive.") 

307 

308 self.output.print("Bench infrastructure ready") 

309 self.logger.info(f"{bench.name}: Bench infrastructure verified and ready.") 

310 

311 def _phase6_install_apps(self) -> bool: 

312 """Phase 6: Install apps to site with graceful failure handling 

313 

314 Returns: 

315 True if apps installed successfully, False if failed 

316 """ 

317 bench = self.bench 

318 

319 self.output.change_head("Installing apps to site") 

320 

321 try: 

322 bench.app_manager.install_apps_to_site() 

323 self.output.print("All apps installed successfully") 

324 

325 self.output.change_head("Running bench migrate") 

326 self._run_bench_migrate() 

327 self.output.print("Database migrations completed") 

328 return True 

329 

330 except Exception as e: 

331 from frappe_manager import CLI_DIR 

332 from frappe_manager.utils.helpers import capture_and_format_exception 

333 

334 self.logger.error(f"{bench.name}: App installation to site failed: {e}\n{capture_and_format_exception()}") 

335 

336 self.output.stop() 

337 self.output.warning( 

338 "App Installation Failed\n\n" 

339 f"Error: {e}\n\n" 

340 "Good News: The bench is configured correctly and running!\n" 

341 "- Containers are healthy ✓\n" 

342 "- Site created ✓\n" 

343 "- Workers configured ✓\n" 

344 "- All apps available at bench level ✓\n\n" 

345 "What happened?\n" 

346 "Some apps failed to install in the site. This is usually due to:\n" 

347 "- App dependency conflicts\n" 

348 "- Database migration errors\n" 

349 "- Missing Python packages\n\n" 

350 "How to fix:\n" 

351 f"1. Shell into the bench: fm shell {bench.name}\n" 

352 "2. Install apps manually:\n" 

353 f" bench --site {bench.name} install-app <app_name>\n" 

354 "3. Check logs for specific errors:\n" 

355 f" fm logs {bench.name} -f\n\n" 

356 f"📋 Check detailed logs at: {CLI_DIR / 'logs' / 'fm.log'}\n", 

357 ) 

358 return False 

359 

360 def _run_bench_migrate(self) -> None: 

361 """Run bench migrate after app installation""" 

362 bench = self.bench 

363 

364 migrate_cmd = " ".join(bench.app_manager.bench_cli_cmd + ["--site", bench.name, "migrate"]) 

365 

366 try: 

367 bench.app_manager._container_run( 

368 migrate_cmd, 

369 raise_exception_obj=BenchOperationException(bench.name, "bench migrate failed"), 

370 ) 

371 except Exception as e: 

372 self.logger.warning(f"{bench.name}: bench migrate failed: {e}") 

373 self.output.warning( 

374 "⚠️ Database migration failed. You may need to run:\n" 

375 f" fm shell {bench.name} -- bench --site {bench.name} migrate", 

376 ) 

377 

378 def _create_template_bench(self): 

379 """Create a template bench (minimal configuration without full site setup).""" 

380 bench = self.bench 

381 global_db_info = bench.services.database_manager.database_server_info 

382 bench.sync_bench_common_site_config(global_db_info.host, global_db_info.port) 

383 

384 from datetime import datetime 

385 

386 from frappe_manager.migration_manager.version import Version 

387 from frappe_manager.site_manager.bench_config import MigrationState 

388 from frappe_manager.utils.helpers import get_current_fm_version 

389 

390 current_fm_version = Version(get_current_fm_version()) 

391 bench.bench_config.migration_state = MigrationState( 

392 migrated_to=str(current_fm_version.version), 

393 last_migration_date=datetime.now().isoformat(), 

394 ) 

395 

396 bench.save_bench_config() 

397 self.output.print(f"Created template bench: {bench.name}", emoji_code=":white_check_mark:") 

398 

399 def _handle_creation_failure(self, exception: Exception): 

400 """Handle failures during bench creation with cleanup.""" 

401 from frappe_manager import CLI_DIR 

402 from frappe_manager.utils.helpers import capture_and_format_exception 

403 

404 bench = self.bench 

405 

406 self.output.stop() 

407 self.output.display_error(f"[red][bold]Error Occured: [/bold][/red]{exception}") 

408 

409 exception_traceback_str = capture_and_format_exception() 

410 self.logger.error(f"{bench.name}: NOT WORKING\n Exception: {exception_traceback_str}") 

411 

412 log_path = CLI_DIR / "logs" / "fm.log" 

413 error_message = [ 

414 "There has been some error creating/starting the bench.", 

415 f":mag: Please check the logs at {log_path}", 

416 ] 

417 self.output.display_error("\n".join(error_message)) 

418 

419 if bench.exists: 

420 remove_status = bench.remove_bench(default_choice=False) 

421 if not remove_status: 

422 bench.info() 

423 

424 def start_bench( 

425 self, 

426 force: bool = False, 

427 reconfigure_workers: bool = False, 

428 include_default_workers: bool = False, 

429 include_custom_workers: bool = False, 

430 reconfigure_supervisor: bool = False, 

431 reconfigure_common_site_config: bool = False, 

432 sync_dev_packages: bool = False, 

433 ): 

434 """ 

435 Orchestrate the bench startup workflow. 

436 

437 This method coordinates starting a bench with various configuration options: 

438 - Starting Docker containers 

439 - Reconfiguring services if requested 

440 - Starting admin tools 

441 - Starting workers 

442 - Syncing configuration changes 

443 

444 Args: 

445 force: Force recreate containers 

446 reconfigure_workers: Regenerate worker configuration 

447 include_default_workers: Include default workers in reconfiguration 

448 include_custom_workers: Include custom workers in reconfiguration 

449 reconfigure_supervisor: Regenerate supervisord configuration 

450 reconfigure_common_site_config: Reconfigure common_site_config.json 

451 sync_dev_packages: Install/remove dev packages based on environment 

452 """ 

453 bench = self.bench 

454 

455 bench.docker_ops.check_required_docker_images_available() 

456 

457 if reconfigure_common_site_config: 

458 self.output.print("Reconfiguring common_site_config with defaults") 

459 global_db_info = bench.services.database_manager.database_server_info 

460 bench.sync_bench_common_site_config(global_db_info.host, global_db_info.port) 

461 

462 self.output.change_head("Starting bench services") 

463 bench.docker_ops.start(services=[], force_recreate=force, pull="never") 

464 

465 if bench.admin_tools.compose_file_manager.compose_path.exists(): 

466 self.output.change_head("Starting admin tools services") 

467 if force or not bench.admin_tools.is_running(): 

468 bench.admin_tools.enable(force_recreate_container=force) 

469 self.output.print("Started admin tools services") 

470 

471 if not bench._is_service_running("nginx"): 

472 bench.docker_ops.start(services=["nginx"], force_recreate=False, pull="never") 

473 

474 bench.site_manager.wait_for_required_services() 

475 

476 if reconfigure_supervisor: 

477 self.output.print("Reconfiguring supervisord") 

478 bench.supervisor.setup_supervisor(bench.path, force=True) 

479 

480 if reconfigure_workers: 

481 self.output.print("Reconfiguring workers") 

482 bench.sync_workers_compose( 

483 include_default_workers=include_default_workers, 

484 include_custom_workers=include_custom_workers, 

485 ) 

486 

487 if sync_dev_packages: 

488 self.output.print("Syncing dev packages") 

489 if bench.bench_config.environment_type == FMBenchEnvType.dev: 

490 bench.install_dev_packages() 

491 else: 

492 bench.remove_dev_packages() 

493 

494 if bench.workers.compose_file_manager.exists(): 

495 self.output.change_head("Starting bench workers services") 

496 bench.workers.docker_client.compose.up( 

497 services=[], 

498 detach=True, 

499 pull="never", 

500 force_recreate=force, 

501 ) 

502 self.output.print("Started bench workers services") 

503 

504 bench.save_bench_config() 

505 self.output.print("Started bench services") 

506 

507 def update_alias_domains(self, add_domains: list[str] | None = None, remove_domains: list[str] | None = None): 

508 """ 

509 Update alias domains without restarting services. 

510 

511 SSL certificates are NOT automatically generated for new alias domains. 

512 Users must explicitly add SSL certificates using: fm ssl add <bench> <domain> 

513 """ 

514 bench = self.bench 

515 

516 backup_aliases = copy.deepcopy(bench.bench_config.alias_domains or []) 

517 current_aliases = set(backup_aliases) 

518 

519 add_list = add_domains if add_domains else [] 

520 remove_list = remove_domains if remove_domains else [] 

521 

522 if bench.name in add_list: 

523 self.output.warning(f"Skipping '{bench.name}' - primary domain cannot be added as alias") 

524 add_list = [d for d in add_list if d != bench.name] 

525 

526 if bench.name in remove_list: 

527 self.output.stop() 

528 raise ValueError(f"Cannot remove primary domain '{bench.name}'. Only alias domains can be removed.") 

529 

530 added_domains = [] 

531 for domain in add_list: 

532 if domain in current_aliases: 

533 self.output.warning(f"Domain '{domain}' is already an alias. Skipping") 

534 else: 

535 current_aliases.add(domain) 

536 added_domains.append(domain) 

537 

538 for domain in added_domains: 

539 if domain.startswith("*."): 

540 self.output.warning(f"Wildcard domain '{domain}' requires DNS-01 challenge and Cloudflare credentials") 

541 

542 removed_domains = [] 

543 for domain in remove_list: 

544 if domain not in current_aliases: 

545 self.output.warning(f"Domain '{domain}' is not an alias. Skipping") 

546 else: 

547 current_aliases.remove(domain) 

548 removed_domains.append(domain) 

549 

550 if not added_domains and not removed_domains: 

551 self.output.print("No changes to apply") 

552 return 

553 

554 if added_domains: 

555 self.output.print(f"Adding aliases: {', '.join(added_domains)}") 

556 if removed_domains: 

557 self.output.print(f"Removing aliases: {', '.join(removed_domains)}") 

558 

559 updated_aliases = sorted(list(current_aliases)) 

560 bench.bench_config.alias_domains = updated_aliases 

561 

562 try: 

563 self.output.change_head("Saving configuration") 

564 bench.save_bench_config() 

565 self.output.print("Configuration saved") 

566 

567 self._update_alias_domains_lightweight() 

568 

569 if added_domains: 

570 self.output.print("To add SSL certificates for new alias domains, use:", emoji_code="") 

571 for domain in added_domains: 

572 self.output.print(f" fm ssl add {bench.name} {domain}", emoji_code="") 

573 

574 except Exception as e: 

575 bench.bench_config.alias_domains = backup_aliases 

576 self.output.stop() 

577 self.logger.error(f"Failed to update alias domains: {e}") 

578 raise Exception(f"Failed to update alias domains: {e}") 

579 

580 def _update_alias_domains_lightweight(self): 

581 bench = self.bench 

582 

583 self.output.change_head("Updating compose configuration") 

584 bench.generate_compose(bench.bench_config.export_to_compose_inputs()) 

585 self.output.print("Updated compose configuration with new domains") 

586 

587 self.output.change_head("Applying changes") 

588 

589 nginx_config_path = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf" 

590 if nginx_config_path.exists(): 

591 nginx_config_path.unlink() 

592 

593 bench.docker_client.compose.up( 

594 services=["nginx"], 

595 detach=True, 

596 pull="never", 

597 force_recreate=True, 

598 ) 

599 

600 self.output.print("Applied configuration changes") 

601 

602 def _restart_services_with_updated_config(self): 

603 """Restart all bench services with updated configuration.""" 

604 bench = self.bench 

605 

606 self.output.change_head("Updating services") 

607 bench.docker_client.compose.stop(services=[], timeout=10) 

608 

609 nginx_config_path = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf" 

610 if nginx_config_path.exists(): 

611 nginx_config_path.unlink() 

612 

613 bench.generate_compose(bench.bench_config.export_to_compose_inputs()) 

614 bench.docker_client.compose.up( 

615 services=[], 

616 detach=True, 

617 pull="never", 

618 force_recreate=True, 

619 ) 

620 

621 if bench.admin_tools.compose_file_manager.compose_path.exists(): 

622 bench.admin_tools.enable(force_recreate_container=True) 

623 

624 bench.site_manager.wait_for_required_services() 

625 

626 if bench.workers.compose_file_manager.exists(): 

627 bench.workers.docker_client.compose.up( 

628 services=[], 

629 detach=True, 

630 pull="never", 

631 force_recreate=True, 

632 ) 

633 

634 self.output.print("Services restarted with updated configuration")