Coverage for frappe_manager / site_manager / modules / bench_docker.py: 18%

239 statements  

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

1""" 

2BenchDockerOps - Docker and Compose Operations Module 

3 

4This module handles all Docker and docker-compose operations for a bench. 

5Extracted from the monolithic Bench class for better separation of concerns. 

6""" 

7 

8import sys 

9from collections.abc import Iterable, Iterator 

10from pathlib import Path 

11from typing import Any, Literal, cast 

12 

13from frappe_manager.docker import DockerClient, DockerException 

14from frappe_manager.docker.compose_file import ComposeFile 

15from frappe_manager.docker.subprocess_output import SubprocessOutput 

16from frappe_manager.logger.contextual import ContextualLogger 

17from frappe_manager.output_manager import OutputHandler 

18from frappe_manager.output_manager.rich_output import RichOutputHandler 

19from frappe_manager.site_manager import NON_BASH_SUPPORTED_SERVICES 

20from frappe_manager.site_manager.bench_config import BenchConfig 

21from frappe_manager.utils.docker import host_run_cp 

22from frappe_manager.utils.helpers import get_container_name_prefix, get_current_fm_version 

23 

24 

25class BenchDockerOps: 

26 """Handles all Docker and compose operations for a bench.""" 

27 

28 def __init__( 

29 self, 

30 logger: ContextualLogger, 

31 docker_client: DockerClient, 

32 compose_file_manager: ComposeFile, 

33 config: BenchConfig, 

34 path: Path, 

35 output_handler: OutputHandler | None = None, 

36 ): 

37 """ 

38 Initialize BenchDockerOps. 

39 

40 Args: 

41 logger: Contextual logger for audit/debug logging 

42 docker_client: Docker client for operations 

43 compose_file_manager: Compose file manager 

44 config: Bench configuration 

45 path: Path to bench directory 

46 output_handler: Handler for output operations 

47 """ 

48 self.logger = logger.child(component="docker") 

49 self.docker_client = docker_client 

50 self.compose_file_manager = compose_file_manager 

51 self.config = config 

52 self.path = path 

53 self.output = output_handler or RichOutputHandler() 

54 

55 def _is_service_running(self, service: str) -> bool: 

56 """Check if a specific service is running.""" 

57 try: 

58 all_statuses = self.docker_client.compose.get_all_services_status() 

59 return any(status["Service"] == service and status["State"] == "running" for status in all_statuses) 

60 except DockerException: 

61 return False 

62 

63 def is_running(self) -> bool: 

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

65 try: 

66 services = self.compose_file_manager.get_services_list() 

67 containers = self.compose_file_manager.get_container_names().values() 

68 all_statuses = self.docker_client.compose.get_all_services_status() 

69 running_statuses = { 

70 status["Service"]: status["State"] for status in all_statuses if status.get("Name") in containers 

71 } 

72 return all(running_statuses.get(s) == "running" for s in services) 

73 except DockerException: 

74 return False 

75 

76 def get_services_running_status(self) -> dict: 

77 """Get the running status of all services.""" 

78 try: 

79 services = self.compose_file_manager.get_services_list() 

80 containers = self.compose_file_manager.get_container_names().values() 

81 all_statuses = self.docker_client.compose.get_all_services_status() 

82 return {status["Service"]: status["State"] for status in all_statuses if status.get("Name") in containers} 

83 except DockerException: 

84 return {} 

85 

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

87 """ 

88 Generate the compose file for the bench based on the given inputs. 

89 

90 Args: 

91 inputs: Dictionary containing environment, labels, users, etc. 

92 """ 

93 # Extract inputs 

94 environments = inputs.get("environment") 

95 labels = inputs.get("labels") 

96 users = None 

97 

98 if "user" in inputs: 

99 users = {} 

100 for container_name, user_data in inputs["user"].items(): 

101 users[container_name] = (user_data["uid"], user_data["gid"]) 

102 

103 network_aliases = [self.config.name] 

104 if self.config.alias_domains: 

105 network_aliases.extend(self.config.alias_domains) 

106 

107 self.compose_file_manager.set_network_alias("nginx", "site-network", network_aliases) 

108 

109 # Use configure_bench method to set all configurations atomically 

110 self.compose_file_manager.configure_bench( 

111 prefix=get_container_name_prefix(network_aliases[0]), 

112 version=get_current_fm_version(), 

113 envs=environments, 

114 labels=labels, 

115 users=users, 

116 network_name="site-network", 

117 auto_save=False, 

118 ) 

119 

120 restart_policy = inputs.get("restart_policy", "no") 

121 self.compose_file_manager.set_all_services_restart(restart_policy) 

122 self.compose_file_manager.write_to_file() 

123 

124 def create_compose_dirs(self) -> bool: 

125 """ 

126 Create the necessary directories for the Compose setup. 

127 

128 Returns: 

129 True if directories are created successfully 

130 """ 

131 self.output.change_head("Creating required directories") 

132 

133 workspace_path = self.path / "workspace" 

134 workspace_path.mkdir(parents=True, exist_ok=True) 

135 

136 frappe_bench_dir = workspace_path / "frappe-bench" 

137 frappe_bench_dir.mkdir(parents=True, exist_ok=True) 

138 

139 (frappe_bench_dir / "sites").mkdir(parents=True, exist_ok=True) 

140 (frappe_bench_dir / "apps").mkdir(parents=True, exist_ok=True) 

141 (frappe_bench_dir / "logs").mkdir(parents=True, exist_ok=True) 

142 (frappe_bench_dir / "config").mkdir(parents=True, exist_ok=True) 

143 (frappe_bench_dir / "config" / "pids").mkdir(parents=True, exist_ok=True) 

144 

145 apps_txt = frappe_bench_dir / "sites" / "apps.txt" 

146 if not apps_txt.exists(): 

147 apps_txt.write_text("frappe\n") 

148 

149 common_site_config = frappe_bench_dir / "sites" / "common_site_config.json" 

150 if not common_site_config.exists(): 

151 common_site_config.write_text("{}") 

152 

153 configs_path = self.path / "configs" 

154 configs_path.mkdir(parents=True, exist_ok=True) 

155 

156 # create nginx dirs 

157 nginx_dir = configs_path / "nginx" 

158 nginx_dir.mkdir(parents=True, exist_ok=True) 

159 

160 nginx_populate_dir = ["conf"] 

161 nginx_image = self.compose_file_manager.yml["services"]["nginx"]["image"] 

162 

163 for directory in nginx_populate_dir: 

164 new_dir = nginx_dir / directory 

165 if not new_dir.exists(): 

166 new_dir_abs = str(new_dir.absolute()) 

167 host_run_cp( 

168 nginx_image, 

169 source="/etc/nginx", 

170 destination=new_dir_abs, 

171 docker=self.docker_client, 

172 ) 

173 

174 nginx_subdirs = ["logs", "cache", "run", "html"] 

175 

176 for directory in nginx_subdirs: 

177 new_dir = nginx_dir / directory 

178 new_dir.mkdir(parents=True, exist_ok=True) 

179 

180 # Copy prebaked Python and Node from Docker image to host workspace 

181 frappe_image = self.compose_file_manager.yml["services"]["frappe"]["image"] 

182 

183 # Copy prebaked UV Python installations 

184 uv_dir = workspace_path / "frappe-bench" / ".uv" 

185 if not uv_dir.exists(): 

186 uv_dir_abs = str(uv_dir.absolute()) 

187 host_run_cp( 

188 frappe_image, 

189 source="/workspace/frappe-bench/.uv", 

190 destination=uv_dir_abs, 

191 docker=self.docker_client, 

192 ) 

193 

194 # Copy prebaked FNM Node installations 

195 fnm_dir = workspace_path / "frappe-bench" / ".fnm" 

196 if not fnm_dir.exists(): 

197 fnm_dir_abs = str(fnm_dir.absolute()) 

198 host_run_cp( 

199 frappe_image, 

200 source="/workspace/frappe-bench/.fnm", 

201 destination=fnm_dir_abs, 

202 docker=self.docker_client, 

203 ) 

204 

205 self.output.print("Created all required directories") 

206 

207 return True 

208 

209 def start( 

210 self, 

211 services: list | None = None, 

212 force_recreate: bool = False, 

213 pull: Literal["missing", "never", "always"] = "never", 

214 ) -> None: 

215 """ 

216 Start bench services. 

217 

218 Args: 

219 services: List of specific services to start (None for all) 

220 force_recreate: Force recreate containers 

221 pull: Pull policy (never, always, missing) 

222 """ 

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

224 

225 self.docker_client.compose.up(services=services or [], detach=True, pull=pull, force_recreate=force_recreate) 

226 

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

228 

229 def stop(self, timeout: int = 10) -> None: 

230 """ 

231 Stop bench services. 

232 

233 Args: 

234 timeout: Timeout in seconds for stopping containers 

235 """ 

236 self.output.change_head("Stopping bench services") 

237 self.docker_client.compose.stop(services=[], timeout=timeout) 

238 self.output.print("Stopped bench services") 

239 

240 def remove_containers(self, remove_volumes: bool = True, timeout: int = 5) -> None: 

241 """ 

242 Remove bench containers. 

243 

244 Args: 

245 remove_volumes: Whether to remove volumes 

246 timeout: Timeout for removal 

247 """ 

248 if self.compose_file_manager.exists(): 

249 self.output.change_head("Removing bench containers") 

250 output = self.docker_client.compose.down( 

251 remove_orphans=True, 

252 volumes=remove_volumes, 

253 timeout=timeout, 

254 stream=True, 

255 ) 

256 self.output.live_lines(cast("Iterator[tuple[str, bytes]]", output), padding=(0, 0, 0, 2)) 

257 self.output.print("Removed bench containers") 

258 else: 

259 self.output.warning("Bench compose file not found. Skipping containers removal.") 

260 

261 def shell( 

262 self, 

263 compose_service: str, 

264 user: str | None = None, 

265 shell_path: str | None = None, 

266 use_run: bool = False, 

267 ) -> None: 

268 """ 

269 Spawn a shell for the specified service. 

270 

271 Args: 

272 compose_service: The name of the service 

273 user: The name of the user (defaults to "frappe" for frappe service) 

274 shell_path: Path to shell executable (overrides auto-detection) 

275 use_run: Use 'docker compose run --rm' instead of 'docker compose exec' 

276 """ 

277 self.output.change_head("Spawning shell") 

278 

279 if compose_service == "frappe" and not user: 

280 user = "frappe" 

281 

282 if not use_run and not self._is_service_running(compose_service): 

283 self.output.stop() 

284 self.output.display_error(f"Cannot spawn shell. Compose service '{compose_service}' not running!") 

285 return 

286 

287 self.output.stop() 

288 

289 if not shell_path: 

290 shell_path = "/bin/bash" if compose_service not in NON_BASH_SUPPORTED_SERVICES else "sh" 

291 

292 if use_run: 

293 run_cmd = self.docker_client.compose.docker_compose_cmd + [ 

294 "run", 

295 "--rm", 

296 "--entrypoint", 

297 "/exec-entrypoint.sh", 

298 ] 

299 # Use lightweight exec-entrypoint.sh that only handles UID/GID mismatch 

300 run_cmd += [compose_service, shell_path] 

301 

302 import os 

303 

304 os.execvp(run_cmd[0], run_cmd) 

305 else: 

306 exec_cmd = self.docker_client.compose.docker_compose_cmd + ["exec"] 

307 

308 if user: 

309 exec_cmd += ["--user", user] 

310 

311 if compose_service == "frappe": 

312 exec_cmd += ["--workdir", "/workspace/frappe-bench"] 

313 

314 exec_cmd += [compose_service, shell_path] 

315 

316 import os 

317 

318 os.execvp(exec_cmd[0], exec_cmd) 

319 

320 def execute_command( 

321 self, 

322 compose_service: str, 

323 command: str, 

324 user: str | None = None, 

325 shell_path: str | None = None, 

326 use_run: bool = False, 

327 ) -> int: 

328 """ 

329 Execute a single command in the specified service and return exit code. 

330 

331 Args: 

332 compose_service: The name of the service 

333 command: The command to execute 

334 user: The name of the user (defaults to "frappe" for frappe service) 

335 shell_path: Path to shell executable (overrides auto-detection) 

336 use_run: Use 'docker compose run --rm' instead of 'docker compose exec' 

337 

338 Returns: 

339 Exit code of the executed command 

340 """ 

341 if compose_service == "frappe" and not user: 

342 user = "frappe" 

343 

344 if not use_run and not self._is_service_running(compose_service): 

345 self.output.display_error(f"Cannot execute command. Compose service '{compose_service}' not running!") 

346 return 1 

347 

348 if not shell_path: 

349 shell_path = "/bin/bash" if compose_service not in NON_BASH_SUPPORTED_SERVICES else "sh" 

350 

351 if use_run: 

352 run_args: dict[str, Any] = { 

353 "service": compose_service, 

354 "command": f'{shell_path} -c "{command}"', 

355 "rm": True, 

356 "use_shlex_split": True, 

357 "stream": False, 

358 "entrypoint": "/exec-entrypoint.sh", 

359 # Use lightweight exec-entrypoint.sh that only handles UID/GID mismatch 

360 } 

361 

362 try: 

363 result = cast("SubprocessOutput", self.docker_client.compose.run(**run_args)) 

364 

365 if result.stdout: 

366 for line in result.stdout: 

367 print(line) 

368 if result.stderr: 

369 for line in result.stderr: 

370 print(line, file=sys.stderr) 

371 

372 return result.exit_code 

373 except DockerException as e: 

374 if e.output.stdout: 

375 for line in e.output.stdout: 

376 print(line) 

377 if e.output.stderr: 

378 for line in e.output.stderr: 

379 print(line, file=sys.stderr) 

380 return e.output.exit_code 

381 else: 

382 exec_args: dict[str, Any] = { 

383 "service": compose_service, 

384 "command": f'{shell_path} -c "{command}"', 

385 "stream": False, 

386 "capture_output": True, 

387 "use_shlex_split": True, 

388 } 

389 

390 if compose_service == "frappe": 

391 exec_args["workdir"] = "/workspace/frappe-bench" 

392 

393 if user: 

394 exec_args["user"] = user 

395 

396 try: 

397 result = self.docker_client.compose.exec(**exec_args) 

398 

399 if result.stdout: 

400 for line in result.stdout: 

401 print(line) 

402 if result.stderr: 

403 for line in result.stderr: 

404 print(line, file=sys.stderr) 

405 

406 return result.exit_code 

407 except DockerException as e: 

408 if e.output.stdout: 

409 for line in e.output.stdout: 

410 print(line) 

411 if e.output.stderr: 

412 for line in e.output.stderr: 

413 print(line, file=sys.stderr) 

414 return e.output.exit_code 

415 

416 def logs(self, services: list | None = None, follow: bool = False) -> None: 

417 """ 

418 Display logs for services. 

419 

420 Args: 

421 services: List of services to show logs for (None for all) 

422 follow: Whether to follow logs continuously 

423 """ 

424 self.output.change_head("Showing logs") 

425 

426 services_list = services or [] 

427 if services_list and not self._is_service_running(services_list[0]): 

428 self.output.stop() 

429 self.output.display_error(f"Cannot show logs. Service '{services_list[0]}' not running!") 

430 return 

431 

432 output = self.docker_client.compose.logs(services=services_list, follow=follow, stream=True) 

433 self.output.live_lines(cast("Iterator[tuple[str, bytes]]", output), padding=(0, 0, 0, 2)) 

434 

435 def frappe_logs_till_start(self) -> None: 

436 """ 

437 Retrieve and print the logs of the 'frappe' service until supervisor starts. 

438 """ 

439 output = cast( 

440 "Iterable[tuple[str, bytes]]", 

441 self.docker_client.compose.logs( 

442 services=["frappe"], 

443 no_log_prefix=True, 

444 no_color=True, 

445 follow=True, 

446 stream=True, 

447 ), 

448 ) 

449 

450 self.output.live_lines( 

451 cast("Iterator[tuple[str, bytes]]", output), 

452 padding=(0, 0, 0, 2), 

453 stop_string="INFO supervisord started with pid", 

454 ) 

455 

456 def restart_services(self, services: list, force: bool = False) -> None: 

457 """ 

458 Restart specific services. 

459 

460 Args: 

461 services: List of service names to restart 

462 force: If True, use timeout=0 for immediate kill. If False, use default graceful timeout. 

463 """ 

464 timeout = 0 if force else 100 

465 self.output.change_head(f"Restarting services - {' '.join(services)}") 

466 self.docker_client.compose.restart(services=services, timeout=timeout) 

467 action = "Force restarted" if force else "Restarted" 

468 self.output.print(f"{action} services - {' '.join(services)}") 

469 

470 def exec_command(self, service: str, command: str, user: str | None = None, stream: bool = False): 

471 """ 

472 Execute a command in a service container. 

473 

474 Args: 

475 service: Service name 

476 command: Command to execute 

477 user: User to run as 

478 stream: Whether to stream output 

479 

480 Returns: 

481 Command output 

482 """ 

483 exec_args = {"service": service, "command": command, "stream": stream} 

484 

485 if user: 

486 exec_args["user"] = user 

487 

488 return self.docker_client.compose.exec(**exec_args) 

489 

490 def check_required_docker_images_available(self) -> None: 

491 """ 

492 Check if all required Docker images are available locally. 

493 

494 This method verifies that all images needed for the bench are 

495 present on the system before attempting to start containers. 

496 

497 Raises: 

498 BenchOperationRequiredDockerImagesNotAvailable: If any required images are missing 

499 

500 Example: 

501 >>> docker_ops.check_required_docker_images_available() 

502 """ 

503 from frappe_manager.site_manager.exceptions import BenchOperationRequiredDockerImagesNotAvailable 

504 from frappe_manager.utils.site import get_all_docker_images 

505 

506 self.output.change_head("Checking required docker images availability") 

507 fm_images = get_all_docker_images() 

508 system_available_images = self.docker_client.images() 

509 

510 not_available_images = [] 

511 

512 for key, value in fm_images.items(): 

513 name = value["name"] 

514 tag = value["tag"] 

515 

516 found = False 

517 

518 for item in system_available_images: 

519 if item.get("Repository") == name and item.get("Tag") == tag: 

520 found = True 

521 break 

522 

523 if not found: 

524 image = f"{name}:{tag}" 

525 not_available_images.append(image) 

526 

527 not_available_images = list(dict.fromkeys(not_available_images)) 

528 

529 if not_available_images: 

530 for image in not_available_images: 

531 self.output.display_error(f"Docker image '{image}' is not available locally") 

532 

533 bench_name = self.config.container_name_prefix.replace("-", ".") 

534 raise BenchOperationRequiredDockerImagesNotAvailable(bench_name, "fm self update-images")