From 27ca6423382dca33b5b8a4efc2cf6ede4d632dfe Mon Sep 17 00:00:00 2001 From: ReLoia Date: Sun, 12 Apr 2026 21:28:10 +0200 Subject: [PATCH] feat: add Docker service management utilities --- app/utils/docker/__init__.py | 0 app/utils/docker/manager.py | 100 +++++++++++++++++++++++++++++++++++ app/utils/docker/ports.py | 70 ++++++++++++++++++++++++ app/utils/docker/types.py | 6 +++ app/utils/proxy/types.py | 2 +- pyproject.toml | 1 + uv.lock | 2 + 7 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 app/utils/docker/__init__.py create mode 100644 app/utils/docker/manager.py create mode 100644 app/utils/docker/ports.py create mode 100644 app/utils/docker/types.py diff --git a/app/utils/docker/__init__.py b/app/utils/docker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/docker/manager.py b/app/utils/docker/manager.py new file mode 100644 index 0000000..6982fb5 --- /dev/null +++ b/app/utils/docker/manager.py @@ -0,0 +1,100 @@ +import socket +from pathlib import Path + +import yaml + +from utils.docker.ports import _get_service_port +from utils.docker.types import DockerService + +COMPOSE_NAMES = ( + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml" +) + + +def _get_random_port() -> int: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('', 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + port = s.getsockname()[1] + return port + except Exception as e: + raise RuntimeError(f"Could not find an available port: {e}") + + +def get_visible_folders(directory: Path) -> list[Path]: + return [ + p for p in Path(directory).iterdir() + if p.is_dir() and not p.name.startswith('.') + ] + + +def find_docker_compose(path: Path) -> tuple[dict, str] | None: + if not path or not path.is_dir(): + return None + + for name in COMPOSE_NAMES: + file = path / name + if file.is_file(): + return yaml.safe_load(file.read_text()), file.name + + return None + + +def get_services(folder: Path) -> list[DockerService] | None: + """ + Returns a list of all public services by automatically finding the docker file of a fodler + + :param folder: + :return: +6 + Usage:: + services = get_services("/root/CApp") + """ + try: + docker_compose, _ = find_docker_compose(folder) + except TypeError: # nessun docker_compose nella cartella + return None + + if not docker_compose or not docker_compose.get('services'): + return None + + services: list[DockerService] = [] + + for service, config in docker_compose["services"].items(): + if not config.get("ports"): + continue + ports = [p for p in (_get_service_port(port) for port in config["ports"]) if p is not None] + if not ports: + continue + services.append({"name": service, "ports": ports}) + + return services or None + + +def get_all_services(directory: str) -> list[DockerService] | None: + """ + Returns a list of all public services in all sub folders of a directory + :param directory: + :return: + + Usage:: + services = get_all_services("/root/") + """ + path = Path(directory) + if not path.is_dir(): + return None + + sub_folders = get_visible_folders(path) + if not sub_folders: + return None + + services = [] + for sub_folder in sub_folders: + if s := get_services(sub_folder): + services.append(s) + + return services or None diff --git a/app/utils/docker/ports.py b/app/utils/docker/ports.py new file mode 100644 index 0000000..5da7399 --- /dev/null +++ b/app/utils/docker/ports.py @@ -0,0 +1,70 @@ +from pathlib import Path + +import yaml + +from utils.docker.manager import find_docker_compose + + +def _get_service_port(mapping: str) -> int | None: + match mapping.split(":"): + case [""]: + return None + case [port] | [port, _]: + return int(port) + case ["localhost" | "127.0.0.1", _, _]: + return None + case [_, port, _]: + return int(port) + case _: + return None + + +def _replace_port_in_mapping(mapping: str, port_from: int, port_to: int) -> str: + parts = str(mapping).split(":") + match parts: + case [_]: + # "8080" -> "9090" + return str(port_to) + case [_, container_port]: + # "8080:80" -> "9090:80" + return f"{port_to}:{container_port}" + case [ip, _, container_port]: + # "127.0.0.1:8080:80" -> "127.0.0.1:9090:80" + return f"{ip}:{port_to}:{container_port}" + case _: + return str(port_to) + + +def _get_port_index(mappings: list[str], port: int) -> int | None: + for i, mapping in enumerate(mappings): + if _get_service_port(mapping) == port: + return i + return None + + +def change_port( + folder: Path, + service_name: str, + port_from: int, + port_to: int, +): + docker_compose, filename = find_docker_compose(folder) + if not docker_compose or not docker_compose.get('services'): + return + + compose_path = folder / filename + backup_compose_path = folder / filename.replace(".yml", ".bak.yml") + + compose_path.copy(backup_compose_path) + + for service, config in docker_compose["services"].items(): + if service != service_name: + continue + idx = _get_port_index(config["ports"], port_from) + if idx is None: + return + config["ports"][idx] = _replace_port_in_mapping(config["ports"][idx], port_from, port_to) + + compose_path.write_text(yaml.dump(docker_compose, default_flow_style=False)) + + # TODO: run docker compose commands diff --git a/app/utils/docker/types.py b/app/utils/docker/types.py new file mode 100644 index 0000000..8644ac5 --- /dev/null +++ b/app/utils/docker/types.py @@ -0,0 +1,6 @@ +from typing import TypedDict + + +class DockerService(TypedDict): + name: str + ports: list[int] diff --git a/app/utils/proxy/types.py b/app/utils/proxy/types.py index a677c3a..548505d 100644 --- a/app/utils/proxy/types.py +++ b/app/utils/proxy/types.py @@ -2,5 +2,5 @@ from typing import TypedDict class ServiceConfig(TypedDict): - address: str + name: str port: int diff --git a/pyproject.toml b/pyproject.toml index da2043d..9eb71e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "motor>=3.7.1", "passlib[bcrypt]>=1.7.4", "pydantic-settings>=2.13.1", + "pyyaml>=6.0.3", "redis>=7.4.0", ] diff --git a/uv.lock b/uv.lock index 2f6ed9a..3aa63d3 100644 --- a/uv.lock +++ b/uv.lock @@ -855,6 +855,7 @@ dependencies = [ { name = "motor" }, { name = "passlib", extra = ["bcrypt"] }, { name = "pydantic-settings" }, + { name = "pyyaml" }, { name = "redis" }, ] @@ -871,6 +872,7 @@ requires-dist = [ { name = "motor", specifier = ">=3.7.1" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "redis", specifier = ">=7.4.0" }, ]