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
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-02 18:13 +0530
1"""
2BenchOrchestrator - Complex workflow orchestration for bench operations
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.
8The orchestrator encapsulates:
9- Bench creation workflow
10- Bench startup workflow
11- Alias domain updates workflow
12- Other complex multi-step operations
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"""
20import copy
21import time
22from typing import TYPE_CHECKING
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
30if TYPE_CHECKING:
31 from frappe_manager.site_manager.site import Bench
34class BenchOrchestrator:
35 """
36 Orchestrator for complex multi-step bench workflows.
38 This class coordinates between multiple modules to execute complex
39 workflows that require specific sequencing and error handling.
41 Attributes:
42 bench: Reference to parent Bench instance
43 logger: Logger instance for this orchestrator
45 Example:
46 >>> orchestrator = BenchOrchestrator(bench_instance)
47 >>> orchestrator.create_bench(is_template=False)
48 """
50 def __init__(self, logger: ContextualLogger, bench: "Bench", output_handler: OutputHandler | None = None):
51 """
52 Initialize orchestrator with bench reference.
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()
63 def create_bench(self, is_template_bench: bool = False) -> None:
64 """
65 Orchestrate the complete bench creation workflow using 5-phase approach.
67 Phase 1: Prepare Structure
68 - Check Docker images
69 - Create directories
70 - Generate docker-compose.yml with FRAPPE_ENV
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)
81 Phase 3: Start and Verify Bench
82 - Start containers with docker compose up
83 - Wait for services
84 - Verify bench server responding
86 Phase 4: Create Site
87 - Create empty site in running container (no apps installed yet)
88 - Set admin password and sync config
90 Phase 5: Finalize
91 - Setup workers
92 - Set migration state
93 - Save config
94 - Verify infrastructure
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)
101 Args:
102 is_template_bench: If True, creates a minimal bench without site creation
104 Raises:
105 Exception: If any step in the creation process fails
106 """
107 bench = self.bench
109 bench.docker_ops.check_required_docker_images_available()
111 try:
112 self._phase1_prepare_structure()
114 if is_template_bench:
115 self._create_template_bench()
116 return
118 self._phase2_initialize_bench()
119 self._phase3_start_and_verify_bench()
120 self._phase4_create_site()
121 self._phase5_finalize()
123 apps_installed = self._phase6_install_apps()
125 if apps_installed:
126 bench.info()
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()
137 except Exception as e:
138 self._handle_creation_failure(e)
140 def _phase1_prepare_structure(self) -> None:
141 """Phase 1: Create directories and docker-compose.yml"""
142 bench = self.bench
144 self.output.change_head("Creating Bench Directory")
145 bench.path.mkdir(parents=True, exist_ok=True)
147 self.output.change_head("Generating bench compose")
148 compose_inputs = bench.bench_config.export_to_compose_inputs()
150 if "environment" not in compose_inputs:
151 compose_inputs["environment"] = {}
153 compose_inputs["environment"]["frappe"] = compose_inputs["environment"].get("frappe", {})
154 compose_inputs["environment"]["frappe"]["FRAPPE_ENV"] = bench.bench_config.environment_type.value
156 bench.generate_compose(compose_inputs)
157 bench.create_compose_dirs()
159 def _phase2_initialize_bench(self) -> None:
160 """Phase 2: Initialize bench using docker compose run (no persistent containers)"""
161 bench = self.bench
163 self.output.change_head("Initializing bench (this may take several minutes)")
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")
172 bench.supervisor.setup_supervisor(bench.path, force=True, use_run=True)
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 )
183 self._detect_python_node_versions()
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)
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 )
197 def _detect_python_node_versions(self) -> None:
198 """Detect Python and Node version requirements from frappe app"""
199 bench = self.bench
201 from frappe_manager.site_manager.bench_config import (
202 extract_node_version_requirement,
203 extract_python_version_requirement,
204 )
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}")
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}")
222 def _phase3_start_and_verify_bench(self) -> None:
223 """Phase 3: Start containers and verify bench server responding"""
224 bench = self.bench
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")
235 bench.site_manager.wait_for_required_services()
237 self._verify_bench_server_responding()
239 def _verify_bench_server_responding(self) -> None:
240 """Verify bench server is working before site creation"""
241 bench = self.bench
243 self.output.change_head("Verifying bench server is responding")
245 if not bench.supervisor.is_supervisord_running(timeout=30):
246 raise Exception("Supervisord not running after 30 seconds")
248 max_retries = 30
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()
260 if status_code in ["200", "404"]:
261 self.output.print("Bench server is responding correctly")
262 return
264 except Exception as e:
265 self.logger.debug(f"Bench server check attempt {i + 1}: {e}")
267 if i < max_retries - 1:
268 time.sleep(2)
270 raise Exception("Bench server not responding after 60 seconds")
272 def _phase4_create_site(self) -> None:
273 """Phase 4: Create empty site (no apps installed yet)"""
274 bench = self.bench
276 self.output.change_head(f"Creating bench site {bench.name}")
277 bench.site_manager.create_bench_site()
279 bench.set_bench_site_config({"admin_password": bench.bench_config.admin_pass})
280 bench.sync_bench_config_configuration()
282 def _phase5_finalize(self) -> None:
283 """Phase 5: Finalize bench infrastructure"""
284 bench = self.bench
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")
290 from datetime import datetime
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
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 )
302 bench.save_bench_config()
304 self.output.change_head("Verifying bench infrastructure")
305 if not bench.is_bench_created():
306 raise Exception("Bench site is inactive or unresponsive.")
308 self.output.print("Bench infrastructure ready")
309 self.logger.info(f"{bench.name}: Bench infrastructure verified and ready.")
311 def _phase6_install_apps(self) -> bool:
312 """Phase 6: Install apps to site with graceful failure handling
314 Returns:
315 True if apps installed successfully, False if failed
316 """
317 bench = self.bench
319 self.output.change_head("Installing apps to site")
321 try:
322 bench.app_manager.install_apps_to_site()
323 self.output.print("All apps installed successfully")
325 self.output.change_head("Running bench migrate")
326 self._run_bench_migrate()
327 self.output.print("Database migrations completed")
328 return True
330 except Exception as e:
331 from frappe_manager import CLI_DIR
332 from frappe_manager.utils.helpers import capture_and_format_exception
334 self.logger.error(f"{bench.name}: App installation to site failed: {e}\n{capture_and_format_exception()}")
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
360 def _run_bench_migrate(self) -> None:
361 """Run bench migrate after app installation"""
362 bench = self.bench
364 migrate_cmd = " ".join(bench.app_manager.bench_cli_cmd + ["--site", bench.name, "migrate"])
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 )
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)
384 from datetime import datetime
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
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 )
396 bench.save_bench_config()
397 self.output.print(f"Created template bench: {bench.name}", emoji_code=":white_check_mark:")
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
404 bench = self.bench
406 self.output.stop()
407 self.output.display_error(f"[red][bold]Error Occured: [/bold][/red]{exception}")
409 exception_traceback_str = capture_and_format_exception()
410 self.logger.error(f"{bench.name}: NOT WORKING\n Exception: {exception_traceback_str}")
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))
419 if bench.exists:
420 remove_status = bench.remove_bench(default_choice=False)
421 if not remove_status:
422 bench.info()
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.
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
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
455 bench.docker_ops.check_required_docker_images_available()
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)
462 self.output.change_head("Starting bench services")
463 bench.docker_ops.start(services=[], force_recreate=force, pull="never")
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")
471 if not bench._is_service_running("nginx"):
472 bench.docker_ops.start(services=["nginx"], force_recreate=False, pull="never")
474 bench.site_manager.wait_for_required_services()
476 if reconfigure_supervisor:
477 self.output.print("Reconfiguring supervisord")
478 bench.supervisor.setup_supervisor(bench.path, force=True)
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 )
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()
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")
504 bench.save_bench_config()
505 self.output.print("Started bench services")
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.
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
516 backup_aliases = copy.deepcopy(bench.bench_config.alias_domains or [])
517 current_aliases = set(backup_aliases)
519 add_list = add_domains if add_domains else []
520 remove_list = remove_domains if remove_domains else []
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]
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.")
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)
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")
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)
550 if not added_domains and not removed_domains:
551 self.output.print("No changes to apply")
552 return
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)}")
559 updated_aliases = sorted(list(current_aliases))
560 bench.bench_config.alias_domains = updated_aliases
562 try:
563 self.output.change_head("Saving configuration")
564 bench.save_bench_config()
565 self.output.print("Configuration saved")
567 self._update_alias_domains_lightweight()
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="")
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}")
580 def _update_alias_domains_lightweight(self):
581 bench = self.bench
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")
587 self.output.change_head("Applying changes")
589 nginx_config_path = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf"
590 if nginx_config_path.exists():
591 nginx_config_path.unlink()
593 bench.docker_client.compose.up(
594 services=["nginx"],
595 detach=True,
596 pull="never",
597 force_recreate=True,
598 )
600 self.output.print("Applied configuration changes")
602 def _restart_services_with_updated_config(self):
603 """Restart all bench services with updated configuration."""
604 bench = self.bench
606 self.output.change_head("Updating services")
607 bench.docker_client.compose.stop(services=[], timeout=10)
609 nginx_config_path = bench.path / "configs" / "nginx" / "conf" / "conf.d" / "default.conf"
610 if nginx_config_path.exists():
611 nginx_config_path.unlink()
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 )
621 if bench.admin_tools.compose_file_manager.compose_path.exists():
622 bench.admin_tools.enable(force_recreate_container=True)
624 bench.site_manager.wait_for_required_services()
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 )
634 self.output.print("Services restarted with updated configuration")