diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index 495ea6d..fdd2bd7 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -3,65 +3,174 @@ from __future__ import annotations -from typing import Any -import yaml -from pydantic import BaseModel, ConfigDict +import copy +from dataclasses import asdict, dataclass, field, replace +from typing import Any, Callable +import yaml from ansible.module_utils.basic import AnsibleModule +PROJECTS_DIR = "/var/lib/ez_compose" +BASE_SERVICE_ARGS = { + "project_name": { + "type": "str", + "required": True, + }, + "name": { + "type": "str", + "required": True, + }, + "image": { + "type": "str", + }, + "state": { + "type": "str", + "default": "present", + "choices": ["present", "absent"], + }, + "defaults": { + "type": "dict", + }, +} -class Result(BaseModel): - model_config = ConfigDict(frozen=True) +@dataclass(frozen=True) +class Result: changed: bool = False - diff: dict[str, Any] = {} + diff: dict[str, Any] = field(default_factory=dict) -class Settings(BaseModel): - model_config = ConfigDict(frozen=True) - +@dataclass(frozen=True) +class Settings: projects_dir: str = "/usr/local/share/ez_compose/" -class State(BaseModel): - model_config = ConfigDict(frozen=True) - - module: AnsibleModule +@dataclass(frozen=True) +class State: + module: Any # Replace Any with the actual type of AnsibleModule if available result: Result - settings: Settings - compose_filename: str - before: dict[str, Any] = {} - after: dict[str, Any] = {} + compose_filepath: str + before: dict[str, Any] + after: dict[str, Any] = field(default_factory=dict) -def new_state(module: AnsibleModule) -> State: - settings = Settings(**module.params["settings"]) +def _recursive_update(default: dict[Any, Any], update: dict[Any, Any]) -> dict[Any, Any]: + for key in update: + if isinstance(update[key], dict) and isinstance(default.get(key), dict): + default[key] = _recursive_update(default[key], update[key]) + + elif isinstance(update[key], list) and isinstance(default.get(key), list): + default_set = set(default[key]) + custom_set = set(update[key]) + default[key] = list(default_set.union(custom_set)) + + else: + default[key] = update[key] + + return default + + +def get_state(module: AnsibleModule) -> State: + compose_filepath = f"{PROJECTS_DIR}/{module.params['project_name']}/docker-compose.yml" + + try: + with open(compose_filepath, "r") as fp: + before = yaml.safe_load(fp) + + except FileNotFoundError: + before: dict[str, Any] = {} return State( module=module, result=Result(), - settings=settings, - compose_filename=( - f"{settings.projects_dir}/" - + f"{module.params['project_name']}/" - + f"{module.params['name']}.yml" - ), + compose_filepath=compose_filepath, + before=before, ) -def get_compose(state: State) -> State: - file = state.compose_filename +def apply_service_base(state: State) -> State: + params = state.module.params - with open(file, "r") as stream: - compose = yaml.safe_load(stream) + compose = copy.deepcopy(state.before) + services = compose.get("services", {}) - return state.model_copy(update={"before": compose}) + networks = compose.get("networks", {}).update( + { + f"{params['project_name']}_internal": None, + } + ) + + service = services.get(params["name"], {}).update( + { + "container_name": f"{params['project_name']}_{params['name']}", + "hostname": f"{params['project_name']}_{params['name']}", + "image": params["image"], + "restart": "unless-stopped", + "environment": {}, + "labels": {}, + "volumes": [], + "networks": { + f"{params['project_name']}_internal": None, + }, + } + ) + + services.update( + { + "networks": networks, + params["name"]: service, + } + ) + + return replace(state, after=compose) + + +def set_defaults(state: State) -> State: + params = state.module.params + compose = copy.deepcopy(state.before) + services = compose["services"] + service = services[params["name"]] + + _recursive_update(service, params["defaults"]) + + services.update({params["name"]: service}) + + return replace(state, after=compose) + + +def update_service(state: State, update: dict[str, Any]) -> State: + compose = copy.deepcopy(state.before) + name = state.module.params["name"] + _recursive_update(compose["services"][name], update) + return replace(state, after=compose) def write_compose(state: State) -> State: - file = state.compose_filename + file = state.compose_filepath with open(file, mode="w") as stream: yaml.dump(state.after, stream) return state + + +def run_service( + extra_args: dict[str, Any] = {}, + helper: Callable[[State], State] = lambda x: x, +) -> None: + module = AnsibleModule( + argument_spec=BASE_SERVICE_ARGS.update(extra_args), + supports_check_mode=True, + ) + + state = get_state(module) + + for f in [apply_service_base, set_defaults, helper]: + state = f(state) + + if module.check_mode: + module.exit_json(**asdict(state.result)) # type: ignore[reportUnkownMemberType] + + write_compose(state) + + module.exit_json(**asdict(state.result)) # type: ignore[reportUnkownMemberType] diff --git a/plugins/modules/redis.py b/plugins/modules/redis.py index fe05bc1..a6da3bf 100644 --- a/plugins/modules/redis.py +++ b/plugins/modules/redis.py @@ -1,76 +1,27 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + # Copyright: (c) 2024, Luca Bilke # MIT License (see LICENSE) -from __future__ import annotations +# TODO: write ansible sections -import copy +DOCUMENTATION = r""" +""" -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( - State, - get_compose, - new_state, - write_compose, -) +EXAMPLES = r""" +""" +RETURN = r""" +""" -def generate(state: State) -> State: - params = state.module.params - compose = copy.deepcopy(state.before) +from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues] - services = compose.get("services", {}) - - container = services.get(params["name"], {}) - container.update( - { - "image": params["image"], - } - ) - - services.update({params["name"]: container}) - - return state.model_copy(update={"after": compose}) +from ansible_collections.snailed.ez_compose.plugins.module_utils.common import run_service def main(): - module = AnsibleModule( - argument_spec={ - "state": { - "type": "str", - "default": "present", - "choices": ["present", "absent"], - }, - "name": { - "type": "str", - "required": True, - }, - "project_name": { - "type": "str", - "required": True, - }, - "image": { - "type": "str", - }, - "settings": { - "type": "dict", - "required": True, - }, - }, - supports_check_mode=True, - ) - - state = new_state(module) - - get_compose(state) - - generate(state) - - if module.check_mode: - module.exit_json(**state.result) # type: ignore[reportUnkownMemberType] - - write_compose(state) - - module.exit_json(**state.result) # type: ignore[reportUnkownMemberType] + run_service() if __name__ == "__main__": diff --git a/plugins/pyproject.toml b/plugins/pyproject.toml index 0b3e8e7..659b4b4 100644 --- a/plugins/pyproject.toml +++ b/plugins/pyproject.toml @@ -1,5 +1,5 @@ [tool.black] -line-length=100 +line-length = 100 [tool.basedpyright] typeCheckingMode = "strict" @@ -9,3 +9,8 @@ reportMissingTypeStubs = false # handled by ruff reportUnusedVariable = false reportUnusedImport = false + + +[tool.ruff.lint] +# Irrelevant for ansible +ignore = ["E402", "F404"]