#!/usr/bin/env python3 import argparse import json from dataclasses import dataclass from pathlib import Path from typing import Any from typing import Self from python_on_whales import DockerClient PROJECT_ROOT_DIRECTORY = Path(__file__).parent.parent.resolve() SERVICES_DIRECTORY = PROJECT_ROOT_DIRECTORY / "services" AVAILABLE_SERVICES = list( service.name for service in SERVICES_DIRECTORY.iterdir() ) @dataclass class Server: server_name: str services: list[str] context_name: str | None = None @property def context(self) -> str: return self.context_name if self.context_name else self.server_name @classmethod def parse_dict(cls, data: dict[str, Any]) -> Self: if not (server_name := data.get("server_name")): raise ValueError("The service is missing server_name property.") if not (services := data.get("services")): raise ValueError("The config is missing services property.") if not isinstance(services, list): raise ValueError("The services property should be an array.") return cls(server_name=server_name, services=services) @dataclass class ServicesConfig: servers: list[Server] def search_service(self, service_name: str) -> Server: servers_matched = [] for server in self.servers: if service_name in server.services: servers_matched.append(server) if not servers_matched: raise ValueError(f"Service `{service_name}` not found.") if len(servers_matched) > 1: raise ValueError( f"Two services with the same name (`{service_name}`) found.", ) return servers_matched.pop() @classmethod def parse_dict(cls, data: dict[str, Any]) -> Self: if not (all_servers := data.get("servers")): raise ValueError("The config is missing servers property.") if not isinstance(all_servers, list): raise ValueError("The servers property should be an array.") servers = [] for server in all_servers: if not isinstance(server, dict): raise ValueError("The server should be an object.") servers.append(Server.parse_dict(server)) return cls(servers=servers) def _get_service_context(service_name: str, config: ServicesConfig, docker_client: DockerClient) -> str: context_names = [ctx.name for ctx in docker_client.context.list()] server_config = config.search_service(service_name) if server_config.context not in context_names: raise ValueError("Context not found on docker host.") return server_config.context def deploy_service(service_name: str, config: ServicesConfig): _tmp_client = DockerClient() context = _get_service_context(service_name, config, _tmp_client) service_dir = SERVICES_DIRECTORY / service_name docker = DockerClient(context=context) compose_file = service_dir / "docker-compose.yml" env_file = service_dir / ".env" if not compose_file.exists(): raise ValueError( f"Service {service_name} is missing docker-compose.yml", ) print(f"INFO: deploying service {service_name} with context {context}") docker.stack.deploy( name=service_name, compose_files=[compose_file], env_files=[env_file] if env_file else [], ) def load_config(config_file: Path | str) -> ServicesConfig: config_file = Path(config_file) unknown_data = json.loads(config_file.read_text()) return ServicesConfig.parse_dict(unknown_data) def _add_args(parser: argparse.ArgumentParser): parser.add_argument("services_file") parser.add_argument("service_name", choices=AVAILABLE_SERVICES) def main() -> int: parser = argparse.ArgumentParser("deployservice") _add_args(parser) parsed_args = parser.parse_args() config = load_config(parsed_args.services_file) deploy_service(parsed_args.service_name, config) return 0 if __name__ == "__main__": raise SystemExit(main())