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
« 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
8from jinja2 import Template
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)
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()
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)
57 except Exception as e:
58 self.output.error("Error during service creation", e)
59 import traceback
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
66 # Pull images
67 output = self.docker_client.compose.pull(stream=False)
69 self.output.print(
70 f"Created global services [blue]{', '.join(self.compose_file_manager.get_services_list())}[/blue].",
71 )
73 if start:
74 self.docker_client.compose.up(services=[], detach=True, pull="never")
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 )
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)
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")
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 )
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()
109 template_name = "docker-compose.services.tmpl"
110 if current_system == "Darwin":
111 template_name = "docker-compose.services.osx.tmpl"
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)
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)
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 )()
131 self.fm_headers_path: Path = self.proxy_storage.dirs.confd.host / "fm_headers.conf"
132 self.set_frappe_headers_conf()
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)
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 }
160 if not current_system == "Darwin":
161 user["global-nginx-proxy"] = {
162 "uid": os.getuid(),
163 "gid": get_unix_groups()["docker"],
164 }
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 )
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)
179 if self.path.exists():
180 shutil.rmtree(self.path)
182 self.path.mkdir(parents=True, exist_ok=True)
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 ]
201 # set secrets in compose
202 self.generate_compose(inputs)
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")
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()}.")
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"
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))
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 )
235 self.set_frappe_headers_conf()
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()
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)
245 def exists(self):
246 return (self.path / "docker-compose.yml").exists()
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
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"])
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)
270 # Commit changes if any were made
271 if environments or labels or users:
272 cf.commit()
274 # TODO do something about this exception
275 except Exception as e:
276 raise ServicesNotCreated("Not able to generate global services compose file.")
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}")
289 def remove_itself(self):
290 shutil.rmtree(self.path)
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)
298 for status in all_statuses:
299 if status.get("Name") == service_container:
300 return status.get("State") == "running"
301 return False
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 )
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)
316 def restart_service(self, services: list[str] | None = None):
317 services = services or []
318 self.docker_client.compose.restart(services=services)