Coverage for frappe_manager / site_manager / modules / bench_site.py: 34%

95 statements  

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

1""" 

2BenchSiteManager - Frappe Site Lifecycle Management Module 

3 

4This module handles all Frappe site-related operations within a bench including 

5site creation, deletion, migration, reset, and status checking. 

6 

7Extracted from the monolithic Bench class and BenchOperations for better 

8separation of concerns. 

9""" 

10 

11from collections.abc import Iterator 

12from pathlib import Path 

13from typing import cast 

14 

15from frappe_manager import CLI_DEFAULT_DELIMETER 

16from frappe_manager.docker import DockerClient, DockerException 

17from frappe_manager.docker.compose_file import ComposeFile 

18from frappe_manager.docker.subprocess_output import SubprocessOutput 

19from frappe_manager.logger.contextual import ContextualLogger 

20from frappe_manager.output_manager import OutputHandler 

21from frappe_manager.output_manager.rich_output import RichOutputHandler 

22from frappe_manager.services_manager.services import ServicesManager 

23from frappe_manager.site_manager.bench_config import BenchConfig 

24from frappe_manager.site_manager.exceptions import ( 

25 BenchOperationBenchSiteCreateFailed, 

26 BenchOperationException, 

27 BenchOperationWaitForRequiredServiceFailed, 

28) 

29from frappe_manager.utils.helpers import get_redis_cache_addr, get_redis_queue_addr 

30 

31 

32class BenchSiteManager: 

33 """ 

34 Manages Frappe site lifecycle operations within a bench. 

35 

36 This module is responsible for all site-related operations including: 

37 - Site creation and initialization 

38 - Site deletion and cleanup 

39 - Site migration and updates 

40 - Site reset (reinstall) 

41 - Site status checking 

42 - Service availability checks 

43 

44 The module encapsulates bench command execution and provides a clean 

45 interface for site management operations. 

46 

47 Attributes: 

48 bench_name: Name of the bench/site 

49 bench_path: Path to the bench directory 

50 docker_client: Docker client for container operations 

51 bench_config: Bench configuration object 

52 services: Services manager for database/Redis access 

53 logger: Logger instance 

54 frappe_bench_dir: Path to frappe-bench directory inside container 

55 bench_cli_cmd: Base bench command prefix 

56 

57 Example: 

58 >>> site_manager = BenchSiteManager( 

59 ... bench_name="example.localhost", 

60 ... bench_path=Path("/home/user/frappe/example.localhost"), 

61 ... docker_client=docker_client, 

62 ... bench_config=bench_config, 

63 ... services=services, 

64 ... ) 

65 >>> site_manager.create_site(admin_pass="admin") 

66 >>> if site_manager.is_site_created(): 

67 ... print("Site created successfully") 

68 """ 

69 

70 def __init__( 

71 self, 

72 logger: ContextualLogger, 

73 bench_name: str, 

74 bench_path: Path, 

75 docker_client: DockerClient, 

76 bench_config: BenchConfig, 

77 services: ServicesManager, 

78 compose_file_manager: ComposeFile | None = None, 

79 output_handler: OutputHandler | None = None, 

80 ): 

81 """ 

82 Initialize BenchSiteManager. 

83 

84 Args: 

85 logger: Contextual logger for audit/debug logging 

86 bench_name: Name of the bench (typically the site domain) 

87 bench_path: Path to the bench directory on host 

88 docker_client: Docker client for container operations 

89 bench_config: Bench configuration object 

90 services: Services manager providing database/Redis access 

91 compose_file_manager: Optional ComposeFile instance for the bench's 

92 docker-compose file. When provided, ``wait_for_required_services`` 

93 will skip health checks for any required services that are 

94 marked as disabled in that compose file. Pass ``None`` (default) 

95 to always perform all health checks regardless of service profiles. 

96 output_handler: Optional output handler for displaying information 

97 """ 

98 self.logger = logger.child(component="site_manager") 

99 self.bench_name = bench_name 

100 self.bench_path = bench_path 

101 self.docker_client = docker_client 

102 self.bench_config = bench_config 

103 self.services = services 

104 self.compose_file_manager = compose_file_manager 

105 self.output = output_handler or RichOutputHandler() 

106 

107 self.frappe_bench_dir: Path = bench_path / "workspace" / "frappe-bench" 

108 self.bench_cli_cmd = ["/opt/user/.bin/bench"] 

109 

110 def is_site_created(self, site_name: str | None = None) -> bool: 

111 """ 

112 Check if a Frappe site exists in the bench. 

113 

114 Args: 

115 site_name: Name of the site to check. Defaults to bench_name. 

116 

117 Returns: 

118 True if the site exists, False otherwise. 

119 

120 Example: 

121 >>> if site_manager.is_site_created(): 

122 ... print("Site already exists") 

123 """ 

124 if site_name is None: 

125 site_name = self.bench_name 

126 

127 site_path: Path = self.frappe_bench_dir / "sites" / site_name 

128 return site_path.exists() 

129 

130 def wait_for_required_services(self, timeout: int = 120) -> None: 

131 """ 

132 Wait for required services (database, Redis) to be available. 

133 

134 This method checks if database and Redis services are reachable 

135 before proceeding with site operations. It will block until all 

136 services are available or timeout is reached. 

137 

138 Args: 

139 timeout: Maximum time to wait in seconds (default: 120) 

140 

141 Raises: 

142 BenchOperationWaitForRequiredServiceFailed: If any service is not available 

143 

144 Example: 

145 >>> site_manager.wait_for_required_services(timeout=60) 

146 """ 

147 self.output.change_head("Checking if required services are available") 

148 

149 db_info = self.services.database_manager.database_server_info 

150 cache_host, cache_port = get_redis_cache_addr(self.bench_config.container_name_prefix) 

151 queue_host, queue_port = get_redis_queue_addr(self.bench_config.container_name_prefix) 

152 

153 candidates = [ 

154 (self.services.compose_file_manager, db_info.host, db_info.host, db_info.port), 

155 (self.compose_file_manager, "redis-cache", cache_host, cache_port), 

156 (self.compose_file_manager, "redis-queue", queue_host, queue_port), 

157 ] 

158 

159 for cfm, compose_service, host, port in candidates: 

160 if cfm and cfm.is_service_profile_disabled(compose_service): 

161 continue 

162 output: SubprocessOutput = self._wait_for_service(host=host, port=port, timeout=timeout) 

163 if output.combined: 

164 command_output = output.combined[-1].replace("wait-for-it: ", "") 

165 service_name = command_output.split(" ")[0] 

166 simplified_service_name = service_name.split(":")[0] 

167 simplified_service_name = simplified_service_name.split(CLI_DEFAULT_DELIMETER)[-1] 

168 self.output.print(command_output.replace(service_name, simplified_service_name), highlight=False) 

169 

170 def _wait_for_service(self, host: str, port: int, timeout: int = 120) -> SubprocessOutput: 

171 """ 

172 Wait for a specific service to be available. 

173 

174 Args: 

175 host: Service hostname 

176 port: Service port 

177 timeout: Maximum time to wait in seconds 

178 

179 Returns: 

180 SubprocessOutput with the wait-for-it command output 

181 

182 Raises: 

183 BenchOperationWaitForRequiredServiceFailed: If service is not available 

184 """ 

185 return cast( 

186 "SubprocessOutput", 

187 self._container_run( 

188 f"wait-for-it -t {timeout} {host}:{port}", 

189 raise_exception_obj=BenchOperationWaitForRequiredServiceFailed( 

190 bench_name=self.bench_name, 

191 host=host, 

192 port=str(port), 

193 timeout=timeout, 

194 ), 

195 capture_output=True, 

196 ), 

197 ) 

198 

199 def create_bench_site(self, admin_pass: str | None = None) -> None: 

200 """ 

201 Create a new Frappe site in the bench. 

202 

203 This method runs the 'bench new-site' command with appropriate database 

204 credentials and configuration. It also sets the site as default and 

205 enables the scheduler. 

206 

207 Args: 

208 admin_pass: Administrator password. Defaults to bench_config.admin_pass. 

209 

210 Raises: 

211 BenchOperationBenchSiteCreateFailed: If site creation fails 

212 BenchOperationException: If post-creation setup fails 

213 

214 Example: 

215 >>> site_manager.create_bench_site(admin_pass="secure_password") 

216 """ 

217 if admin_pass is None: 

218 admin_pass = self.bench_config.admin_pass 

219 

220 # Build new-site command 

221 new_site_command = self.bench_cli_cmd + ["new-site"] 

222 new_site_command += ["--db-root-password", self.services.database_manager.database_server_info.password] 

223 if self.bench_config.db_name: 

224 new_site_command += ["--db-name", self.bench_config.db_name] 

225 new_site_command += ["--db-host", self.services.database_manager.database_server_info.host] 

226 new_site_command += ["--admin-password", admin_pass] 

227 new_site_command += ["--db-port", str(self.services.database_manager.database_server_info.port)] 

228 new_site_command += ["--verbose", "--mariadb-user-host-login-scope", "%"] 

229 new_site_command += [self.bench_name] 

230 

231 new_site_command = " ".join(new_site_command) 

232 

233 # Create the site 

234 self._container_run(new_site_command, raise_exception_obj=BenchOperationBenchSiteCreateFailed(self.bench_name)) 

235 

236 # Set as default site 

237 self._container_run( 

238 " ".join(self.bench_cli_cmd + [f"use {self.bench_name}"]), 

239 raise_exception_obj=BenchOperationException( 

240 self.bench_name, 

241 f"Failed to set {self.bench_name} as default site.", 

242 ), 

243 ) 

244 

245 # Enable scheduler 

246 self._container_run( 

247 " ".join(self.bench_cli_cmd + [f"--site {self.bench_name} scheduler enable"]), 

248 raise_exception_obj=BenchOperationException( 

249 self.bench_name, 

250 f"Failed to enable {self.bench_name}'s scheduler.", 

251 ), 

252 ) 

253 

254 def reset_bench_site(self, admin_password: str) -> None: 

255 """ 

256 Reset (reinstall) a Frappe site, wiping all data. 

257 

258 This method runs 'bench reinstall' which drops and recreates the 

259 site's database, effectively resetting it to a fresh state. 

260 

261 Args: 

262 admin_password: New administrator password for the reset site 

263 

264 Raises: 

265 BenchOperationException: If site reset fails 

266 

267 Warning: 

268 This operation is destructive and will delete all site data! 

269 

270 Example: 

271 >>> site_manager.reset_bench_site(admin_password="new_admin_pass") 

272 """ 

273 global_db_info = self.services.database_manager.database_server_info 

274 

275 reset_bench_site_command = self.bench_cli_cmd + ["--site", self.bench_name] 

276 reset_bench_site_command += ["reinstall", "--admin-password", admin_password] 

277 reset_bench_site_command += ["--db-root-username", global_db_info.user] 

278 reset_bench_site_command += ["--db-root-password", global_db_info.password] 

279 reset_bench_site_command += ["--yes"] 

280 

281 reset_bench_site_command = " ".join(reset_bench_site_command) 

282 

283 self._container_run( 

284 reset_bench_site_command, 

285 raise_exception_obj=BenchOperationException( 

286 bench_name=self.bench_name, 

287 message=f"Failed to reset bench site {self.bench_name}.", 

288 ), 

289 ) 

290 

291 def _container_run( 

292 self, 

293 command: str, 

294 raise_exception_obj: BenchOperationException | None = None, 

295 capture_output: bool = False, 

296 user: str = "frappe", 

297 workdir: str = "/workspace/frappe-bench", 

298 service: str = "frappe", 

299 use_run: bool = False, 

300 ) -> SubprocessOutput | None: 

301 """ 

302 Execute a command inside the bench container. 

303 

304 This is an internal helper method that wraps docker_client.compose.exec 

305 or docker_client.compose.run depending on use_run parameter. 

306 

307 Args: 

308 command: Shell command to execute 

309 raise_exception_obj: Exception to raise on failure 

310 capture_output: Whether to capture output instead of streaming 

311 user: User to run command as (default: frappe) 

312 workdir: Working directory (default: /workspace/frappe-bench) 

313 service: Docker service name (default: frappe) 

314 use_run: If True, use 'docker compose run --rm' instead of 'exec' (default: False) 

315 

316 Returns: 

317 SubprocessOutput if capture_output=True, None otherwise 

318 

319 Raises: 

320 BenchOperationException: If command fails and raise_exception_obj is provided 

321 DockerException: If command fails and no exception object provided 

322 """ 

323 try: 

324 if use_run: 

325 wrapped_command = f"cd {workdir} && {command}" 

326 run_command = f"/bin/bash -c '{wrapped_command}'" 

327 if capture_output: 

328 output = cast( 

329 "SubprocessOutput", 

330 self.docker_client.compose.run( 

331 service=service, 

332 command=run_command, 

333 rm=True, 

334 stream=False, 

335 entrypoint="/exec-entrypoint.sh", 

336 ), 

337 ) 

338 return output 

339 output = cast( 

340 "Iterator[tuple[str, bytes]]", 

341 self.docker_client.compose.run( 

342 service=service, 

343 command=run_command, 

344 rm=True, 

345 entrypoint="/exec-entrypoint.sh", 

346 stream=True, 

347 ), 

348 ) 

349 self.output.live_lines(output) 

350 else: 

351 exec_command = f"/bin/bash -c '{command}'" 

352 if capture_output: 

353 output = cast( 

354 "SubprocessOutput", 

355 self.docker_client.compose.exec( 

356 service=service, 

357 command=exec_command, 

358 user=user, 

359 workdir=workdir, 

360 stream=False, 

361 ), 

362 ) 

363 return output 

364 output = cast( 

365 "Iterator[tuple[str, bytes]]", 

366 self.docker_client.compose.exec( 

367 service=service, 

368 command=exec_command, 

369 workdir=workdir, 

370 user=user, 

371 stream=True, 

372 ), 

373 ) 

374 self.output.live_lines(output) 

375 

376 except DockerException as e: 

377 if raise_exception_obj: 

378 raise_exception_obj.set_output(e.output) 

379 raise raise_exception_obj 

380 raise e