Coverage for frappe_manager / services_manager / services.py: 36%

163 statements  

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

1import os 

2import platform 

3import shutil 

4from datetime import datetime 

5from pathlib import Path 

6from typing import Any 

7 

8from jinja2 import Template 

9 

10from frappe_manager import CLI_DIR, CLI_SERVICES_DIRECTORY 

11from frappe_manager.docker import ComposeFile, DockerClient, DockerException 

12from frappe_manager.output_manager import OutputHandler 

13from frappe_manager.output_manager.rich_output import RichOutputHandler 

14from frappe_manager.services_manager.database_service_manager import ( 

15 DatabaseServerServiceInfo, 

16 DatabaseServiceManager, 

17 MariaDBManager, 

18) 

19from frappe_manager.services_manager.services_exceptions import ( 

20 ServicesComposeNotExist, 

21 ServicesException, 

22 ServicesNotCreated, 

23) 

24from frappe_manager.ssl_manager.nginx_controller import NginxController 

25from frappe_manager.ssl_manager.proxy_storage import ProxyStoragePaths 

26from frappe_manager.utils.docker import host_run_cp 

27from frappe_manager.utils.helpers import ( 

28 get_current_fm_version, 

29 get_template_path, 

30 get_unix_groups, 

31 random_password_generate, 

32) 

33 

34 

35class ServicesManager: 

36 def __init__( 

37 self, 

38 path=CLI_SERVICES_DIRECTORY, 

39 verbose: bool = False, 

40 invoked_subcommand: str | None = None, 

41 output_handler: OutputHandler | None = None, 

42 ) -> None: 

43 self.path = path 

44 self.compose_path = self.path / "docker-compose.yml" 

45 self.invoked_subcommand = invoked_subcommand 

46 self.output = output_handler or RichOutputHandler() 

47 

48 def entrypoint_checks(self, start=False): 

49 if not self.path.exists(): 

50 try: 

51 self.output.print( 

52 f"Creating global services [blue]{', '.join(self.compose_file_manager.get_services_list())}[/blue].", 

53 emoji_code=":construction:", 

54 ) 

55 self.create(clean_install=True) 

56 

57 except Exception as e: 

58 self.output.error("Error during service creation", e) 

59 import traceback 

60 

61 traceback.print_exc() 

62 raise ServicesNotCreated( 

63 f"Not able to create global services [blue]{', '.join(self.compose_file_manager.get_services_list())}[/blue].", 

64 ) from e 

65 

66 # Pull images 

67 output = self.docker_client.compose.pull(stream=False) 

68 

69 self.output.print( 

70 f"Created global services [blue]{', '.join(self.compose_file_manager.get_services_list())}[/blue].", 

71 ) 

72 

73 if start: 

74 self.docker_client.compose.up(services=[], detach=True, pull="never") 

75 

76 if not self.compose_path.exists(): 

77 raise ServicesComposeNotExist( 

78 f"Seems like global services has taken a down. No compose file found at {self.compose_path}.", 

79 ) 

80 

81 if start: 

82 if not self.invoked_subcommand == "service": 

83 services = self.compose_file_manager.get_services_list(exclude_disabled=True) 

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

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

86 running_statuses = { 

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

88 } 

89 all_running = all(running_statuses.get(s) == "running" for s in services) 

90 

91 if not all_running: 

92 self.output.print( 

93 f"Started non running global services [blue]{', '.join(services)}[/blue].", 

94 ) 

95 self.docker_client.compose.up(services=[], detach=True, pull="missing") 

96 

97 self.database_manager: DatabaseServiceManager = MariaDBManager( 

98 DatabaseServerServiceInfo.import_from_compose_file("global-db", self.compose_file_manager), 

99 self.compose_file_manager, 

100 self.docker_client, 

101 output_handler=self.output, 

102 ) 

103 

104 def init(self): 

105 # check if the global services exits if not then create 

106 # TODO this should be done by factory 

107 current_system = platform.system() 

108 

109 template_name = "docker-compose.services.tmpl" 

110 if current_system == "Darwin": 

111 template_name = "docker-compose.services.osx.tmpl" 

112 

113 self.compose_file_manager = ComposeFile(self.compose_path, template_name=template_name) 

114 self.docker_client = DockerClient(compose_file_path=self.compose_path, output=self.output) 

115 

116 self.proxy_storage = ProxyStoragePaths("global-nginx-proxy", self.compose_file_manager) 

117 self.nginx_controller = NginxController("global-nginx-proxy", self.compose_file_manager, self.docker_client) 

118 

119 # For backward compatibility 

120 # TODO: Remove this when all code is updated 

121 self.proxy_manager = type( 

122 "ProxyManager", 

123 (), 

124 { 

125 "dirs": self.proxy_storage.dirs, 

126 "restart": self.nginx_controller.restart, 

127 "reload": self.nginx_controller.reload, 

128 }, 

129 )() 

130 

131 self.fm_headers_path: Path = self.proxy_storage.dirs.confd.host / "fm_headers.conf" 

132 self.set_frappe_headers_conf() 

133 

134 def set_frappe_headers_conf(self): 

135 if self.fm_headers_path.parent.exists(): 

136 template_path: Path = get_template_path("fm_headers.conf.tmpl") 

137 template = Template(template_path.read_text()) 

138 output = template.render(current_version=f"v{get_current_fm_version()}") 

139 self.fm_headers_path.write_text(output) 

140 

141 def create(self, backup: bool = False, clean_install: bool = True): 

142 envs = { 

143 "global-db": { 

144 "MYSQL_ROOT_PASSWORD_FILE": "/run/secrets/db_root_password", 

145 "MYSQL_DATABASE": "root", 

146 "MYSQL_USER": "admin", 

147 "MYSQL_PASSWORD_FILE": "/run/secrets/db_password", 

148 }, 

149 } 

150 current_system = platform.system() 

151 inputs: dict[str, Any] = {"environment": envs} 

152 try: 

153 user = { 

154 "global-db": { 

155 "uid": os.getuid(), 

156 "gid": os.getgid(), 

157 }, 

158 } 

159 

160 if not current_system == "Darwin": 

161 user["global-nginx-proxy"] = { 

162 "uid": os.getuid(), 

163 "gid": get_unix_groups()["docker"], 

164 } 

165 

166 inputs["user"] = user 

167 except KeyError: 

168 raise ServicesException( 

169 "docker group not found in system. Please add docker group to the system and current user to the docker group.", 

170 ) 

171 

172 if backup and self.path.exists(): 

173 backup_path: Path = CLI_DIR / "backups" 

174 backup_path.mkdir(parents=True, exist_ok=True) 

175 current_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 

176 backup_dir_name = f"services_{current_time}" 

177 self.path.rename(backup_path / backup_dir_name) 

178 

179 if self.path.exists(): 

180 shutil.rmtree(self.path) 

181 

182 self.path.mkdir(parents=True, exist_ok=True) 

183 

184 # create required directories 

185 dirs_to_create = [ 

186 "mariadb/conf", 

187 "mariadb/logs", 

188 "nginx-proxy/dhparam", 

189 "nginx-proxy/certs", 

190 "nginx-proxy/confd", 

191 "nginx-proxy/htpasswd", 

192 "nginx-proxy/vhostd", 

193 "nginx-proxy/html", 

194 "nginx-proxy/logs", 

195 "nginx-proxy/run", 

196 "nginx-proxy/ssl", 

197 "nginx-proxy/cache", 

198 "secrets", 

199 ] 

200 

201 # set secrets in compose 

202 self.generate_compose(inputs) 

203 

204 if current_system == "Darwin": 

205 self.compose_file_manager.remove_container_user("global-nginx-proxy") 

206 self.compose_file_manager.remove_container_user("global-db") 

207 else: 

208 dirs_to_create.append("mariadb/data") 

209 

210 # create dirs 

211 for folder in dirs_to_create: 

212 temp_dir = self.path / folder 

213 try: 

214 temp_dir.mkdir(parents=True, exist_ok=True) 

215 except Exception as e: 

216 raise ServicesNotCreated(f"Failed to create global services required dir {temp_dir.absolute()}.") 

217 

218 # populate secrets for db 

219 db_password_path = self.path / "secrets" / "db_password.txt" 

220 db_root_password_path = self.path / "secrets" / "db_root_password.txt" 

221 

222 db_password_path.write_text(random_password_generate(password_length=16, symbols=True)) 

223 db_root_password_path.write_text(random_password_generate(password_length=24, symbols=True)) 

224 

225 # populate mariadb config 

226 mariadb_conf = self.path / "mariadb/conf" 

227 mariadb_conf = str(mariadb_conf.absolute()) 

228 host_run_cp( 

229 image="mariadb:10.6", 

230 source="/etc/mysql/.", 

231 destination=mariadb_conf, 

232 docker=self.docker_client, 

233 ) 

234 

235 self.set_frappe_headers_conf() 

236 

237 self.compose_file_manager.set_secret_file_path("db_password", str(db_password_path.absolute())) 

238 self.compose_file_manager.set_secret_file_path("db_root_password", str(db_root_password_path.absolute())) 

239 self.compose_file_manager.write_to_file() 

240 

241 if clean_install: 

242 # remove previous contaniners and volumes 

243 self.docker_client.compose.down(remove_orphans=True, timeout=10, volumes=True, stream=False) 

244 

245 def exists(self): 

246 return (self.path / "docker-compose.yml").exists() 

247 

248 def generate_compose(self, inputs: dict): 

249 # TODO do something about this function 

250 try: 

251 # Extract inputs 

252 environments = inputs.get("environment") 

253 labels = inputs.get("labels") 

254 users = None 

255 

256 if "user" in inputs: 

257 users = {} 

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

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

260 

261 # Use fluent interface to set all configurations atomically 

262 cf = self.compose_file_manager 

263 if environments: 

264 cf.with_envs(environments) 

265 if labels: 

266 cf.with_labels(labels) 

267 if users: 

268 cf.with_users(users) 

269 

270 # Commit changes if any were made 

271 if environments or labels or users: 

272 cf.commit() 

273 

274 # TODO do something about this exception 

275 except Exception as e: 

276 raise ServicesNotCreated("Not able to generate global services compose file.") 

277 

278 def shell(self, container: str, user: str | None = None): 

279 self.output.stop() 

280 shell_path = "/bin/bash" 

281 try: 

282 if user: 

283 self.docker_client.compose.exec(container, user=user, command=shell_path, capture_output=False) 

284 else: 

285 self.docker_client.compose.exec(container, command=shell_path, capture_output=False) 

286 except DockerException as e: 

287 self.output.warning(f"Shell exited with error code: {e.output.exit_code}") 

288 

289 def remove_itself(self): 

290 shutil.rmtree(self.path) 

291 

292 def is_service_running(self, service: str) -> bool: 

293 """Check if a service is running.""" 

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

295 containers = self.compose_file_manager.get_container_names() 

296 service_container = containers.get(service) 

297 

298 for status in all_statuses: 

299 if status.get("Name") == service_container: 

300 return status.get("State") == "running" 

301 return False 

302 

303 def start_service(self, services: list[str] | None = None, force_recreate: bool = False): 

304 services = services or [] 

305 self.docker_client.compose.up( 

306 services=services, 

307 detach=True, 

308 pull="never", 

309 force_recreate=force_recreate, 

310 ) 

311 

312 def stop_service(self, services: list[str] | None = None, timeout: int = 10): 

313 services = services or [] 

314 self.docker_client.compose.stop(services=services, timeout=timeout) 

315 

316 def restart_service(self, services: list[str] | None = None): 

317 services = services or [] 

318 self.docker_client.compose.restart(services=services)