diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index fdd2bd7..f0fc26d 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -32,6 +32,21 @@ BASE_SERVICE_ARGS = { "type": "dict", }, } +BASE_LABEL_ARGS = { + "project_name": { + "type": "str", + "required": True, + }, + "name": { + "type": "str", + "required": True, + }, + "state": { + "type": "str", + "default": "present", + "choices": ["present", "absent"], + }, +} @dataclass(frozen=True) @@ -51,7 +66,7 @@ class State: result: Result compose_filepath: str before: dict[str, Any] - after: dict[str, Any] = field(default_factory=dict) + after: dict[str, Any] def _recursive_update(default: dict[Any, Any], update: dict[Any, Any]) -> dict[Any, Any]: @@ -85,64 +100,119 @@ def get_state(module: AnsibleModule) -> State: result=Result(), compose_filepath=compose_filepath, before=before, + after=before, ) def apply_service_base(state: State) -> State: - params = state.module.params + service_name = state.module.params["name"] + project_name = state.module.params["project_name"] + image = state.module.params["image"] - compose = copy.deepcopy(state.before) - services = compose.get("services", {}) + update: dict[str, Any] = { + "service_name": f"{project_name}_{service_name}", + "hostname": f"{project_name}_{service_name}", + "image": image, + "restart": "unless-stopped", + "environment": {}, + "labels": {}, + "volumes": [], + "networks": { + f"{project_name}_internal": None, + }, + } - 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) + return update_service(state, update) -def set_defaults(state: State) -> State: - params = state.module.params - compose = copy.deepcopy(state.before) - services = compose["services"] - service = services[params["name"]] +def set_service_defaults(state: State) -> State: + container_name = state.module.params["name"] + defaults = state.module.params["defaults"] + project = copy.deepcopy(state.after) + services = project["services"] + service = services[container_name] - _recursive_update(service, params["defaults"]) + _ = _recursive_update(service, defaults) - services.update({params["name"]: service}) + services.update({container_name: service}) - return replace(state, after=compose) + return replace(state, after=project) 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) + project = copy.deepcopy(state.after) + service_name = state.module.params["name"] + + _ = _recursive_update(project["services"][service_name], update) + + return replace(state, after=project) + + +def remove_service(state: State) -> State: + project = copy.deepcopy(state.after) + service_name = state.module.params["name"] + + del project["services"][service_name] + + return replace(state, after=project) + + +def remove_labels(state: State, label_names: list[str]) -> State: + project = copy.deepcopy(state.after) + service_name = state.module.params["name"] + service = project["services"].get(service_name, {}) + + labels = service.get("labels", {}) + + if labels: + for label in labels: + if label in label_names: + try: + del service["labels"][label] + except KeyError: + pass + + service["labels"] = labels + + else: + try: + del service["labels"] + except KeyError: + pass + + project["services"][service_name] = service + + return replace(state, after=project) + + +def update_project(state: State) -> State: + project = copy.deepcopy(state.after) + + project_services = project.get("services", {}) + project_networks = project.get("networks", {}) + project_volumes = project.get("volumes", {}) + + for service in project_services: + if service_volumes := service.get("volumes"): + service_volume_names = [x["source"] for x in service_volumes] + project_volumes.update( + { + service_volume_name: None + for service_volume_name in service_volume_names + if service_volume_name not in project_volumes + } + ) + + if service_network_names := service.get("networks").keys(): + project_networks.update( + { + service_network_name: None + for service_network_name in service_network_names + if service_network_name not in project_networks + } + ) + + return replace(state, after=project) def write_compose(state: State) -> State: @@ -156,21 +226,50 @@ def write_compose(state: State) -> State: def run_service( extra_args: dict[str, Any] = {}, - helper: Callable[[State], State] = lambda x: x, + helper: Callable[[State], State] = lambda _: _, ) -> None: module = AnsibleModule( - argument_spec=BASE_SERVICE_ARGS.update(extra_args), + argument_spec={**BASE_SERVICE_ARGS, **extra_args}, supports_check_mode=True, ) state = get_state(module) - for f in [apply_service_base, set_defaults, helper]: - state = f(state) + if module.params["state"] == "absent": + state = remove_service(state) - if module.check_mode: - module.exit_json(**asdict(state.result)) # type: ignore[reportUnkownMemberType] + else: + for f in [apply_service_base, set_service_defaults, helper, update_project]: + state = f(state) - write_compose(state) + exit(state) - module.exit_json(**asdict(state.result)) # type: ignore[reportUnkownMemberType] + +def run_label( + extra_args: dict[str, Any], helper: Callable[[State], State], label_names: list[str] +) -> None: + module = AnsibleModule( + argument_spec={**BASE_LABEL_ARGS, **extra_args}, + supports_check_mode=True, + ) + + state = get_state(module) + + if module.params["state"] == "absent": + state = remove_labels(state, label_names) + + else: + state = helper(state) + + exit(state) + + +def exit(state: State) -> None: + # TODO: Check diff and set changed variable + + if state.module.check_mode: + state.module.exit_json(**asdict(state.result)) # type: ignore[reportUnkownMemberType] + + _ = write_compose(state) + + state.module.exit_json(**asdict(state.result)) # type: ignore[reportUnkownMemberType] diff --git a/plugins/modules/label_docker_volume_backupper.py b/plugins/modules/label_docker_volume_backupper.py new file mode 100644 index 0000000..63aed2b --- /dev/null +++ b/plugins/modules/label_docker_volume_backupper.py @@ -0,0 +1,51 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Luca Bilke +# MIT License (see LICENSE) + +# TODO: write ansible sections + +DOCUMENTATION = r""" +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues] + +from typing import Any + +from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( + State, + run_label, + update_service, +) + + +def helper(state: State) -> State: + stop = state.module.params.get("stop", True) + project_name = state.module.params["project_name"] + + update: dict[str, Any] = {} + + if stop: + update["labels"] = { + "docker-volume-backup.stop-during-backup": project_name, + } + + return update_service(state, update) + + +def main(): + extra_args = { + "stop": {"type": "bool"}, + } + run_label(extra_args, helper) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/label_traefik_middleware.py b/plugins/modules/label_traefik_middleware.py new file mode 100644 index 0000000..99b17e3 --- /dev/null +++ b/plugins/modules/label_traefik_middleware.py @@ -0,0 +1,60 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Luca Bilke +# MIT License (see LICENSE) + +# TODO: write ansible sections + +DOCUMENTATION = r""" +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues] + +from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( + State, + run_label, + update_service, +) + + +def helper(state: State) -> State: + service_name = state.module.params["name"] + project_name = state.module.params["project_name"] + middleware = state.module.params["middleware"] + settings = state.module.params["settings"] + proxy_type = state.module.params.get("proxy_type", "http") + middleware_name = state.module.params.get( + "middleware_name", + f"{project_name}_{service_name}_{proxy_type}_{middleware.lower()}", + ) + + prefix = f"traefik.{proxy_type}.middlewares.{middleware_name}" + + labels = {f"{prefix}.{middleware}.{key}": var for key, var in settings.items()} + + update = { + "labels": labels, + } + + return update_service(state, update) + + +def main(): + extra_args = { + "proxy_type": {"type": "str"}, + "middleware_name": {"type": "string"}, + "middleware": {"type": "str", "required": True}, + "settings": {"type": "list", "required": True}, + } + run_label(extra_args, helper) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/label_traefik_router.py b/plugins/modules/label_traefik_router.py new file mode 100644 index 0000000..9272e80 --- /dev/null +++ b/plugins/modules/label_traefik_router.py @@ -0,0 +1,83 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Luca Bilke +# MIT License (see LICENSE) + +# TODO: write ansible sections + +DOCUMENTATION = r""" +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues] + +from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( + State, + run_label, + update_service, +) + + +def helper(state: State) -> State: + service_name = state.module.params["name"] + project_name = state.module.params["project_name"] + rule = state.module.params["rule"] + service = state.module.params.get("service") + entrypoints = state.module.params.get("entrypoints") + middlewares = state.module.params.get("middlewares") + certresolver = state.module.params.get("certresolver") + proxy_type = state.module.params.get("proxy_type", "http") + router_name = state.module.params.get( + "router_name", + f"{project_name}_{service_name}_{proxy_type}", + ) + + prefix = f"traefik.{proxy_type}.routers.{router_name}" + + labels = { + "traefik.enable": True, + f"traefik.{prefix}.rule": rule, + } + + if certresolver: + labels[f"traefik.{prefix}.tls.certresolver"] = certresolver + + if entrypoints: + labels[f"{prefix}.entrypoints"] = ",".join(entrypoints) + + if service: + labels[f"{prefix}.service"] = service + + if middlewares: + labels[f"{prefix}.middlewares"] = ",".join(middlewares) + + update = { + "labels": labels, + } + + return update_service(state, update) + + +def main(): + extra_args = { + "proxy_type": {"type": "str"}, + "router_name": { + "type": "str", + }, + "rule": {"type": "str", "required": True}, + "service": {"type": "str"}, + "certresolver": {"type": "str"}, + "entrypoints": {"type": "list"}, + "middlewares": {"type": "list"}, + } + run_label(extra_args, helper) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/label_traefik_service.py b/plugins/modules/label_traefik_service.py new file mode 100644 index 0000000..5de9af2 --- /dev/null +++ b/plugins/modules/label_traefik_service.py @@ -0,0 +1,64 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Luca Bilke +# MIT License (see LICENSE) + +# TODO: write ansible sections + +DOCUMENTATION = r""" +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues] + +from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( + State, + run_label, + update_service, +) + + +from typing import Any + + +def helper(state: State) -> State: + service_name = state.module.params["name"] + project_name = state.module.params["project_name"] + port = state.module.params.get("port") + proxy_type = state.module.params.get("proxy_type", "http") + service_name = state.module.params.get( + "service_name", + f"{project_name}_{service_name}_{proxy_type}", + ) + + prefix = f"traefik.{proxy_type}.services.{service_name}" + + labels: dict[str, Any] = {} + + if port: + labels[f"{prefix}.loadbalancer.server.port"] = str(port) + + update = { + "labels": labels, + } + + return update_service(state, update) + + +def main(): + extra_args = { + "proxy_type": {"type": "str"}, + "service_name": {"type": "string"}, + "port": {"type": "int"}, + } + run_label(extra_args, helper) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/service.py b/plugins/modules/service.py new file mode 100644 index 0000000..048af58 --- /dev/null +++ b/plugins/modules/service.py @@ -0,0 +1,90 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Luca Bilke +# MIT License (see LICENSE) + +# TODO: write ansible sections + +DOCUMENTATION = r""" +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues] + +import copy + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( + State, + exit, + get_state, + remove_service, + update_project, + update_service, +) + + +def helper(state: State) -> State: + project_name = state.module.params["project_name"] + definition = state.module.params["definition"] + internal_network = state.module.params.get("internal_network", False) + + update = copy.deepcopy(definition) + + networks = update.get("networks", {}) + + if internal_network: + networks[f"{project_name}_internal"] = None + + update["networks"] = networks + + return update_service(state, update) + + +def main(): + module = AnsibleModule( + argument_spec={ + "project_name": { + "type": "str", + "required": True, + }, + "name": { + "type": "str", + "required": True, + }, + "internal_network": { + "type": "bool", + }, + "state": { + "type": "str", + "default": "present", + "choices": ["present", "absent"], + }, + "definition": { + "type": "dict", + "required": True, + }, + }, + supports_check_mode=True, + ) + + state = get_state(module) + + if module.params["state"] == "absent": + state = remove_service(state) + + else: + for f in [helper, update_project]: + state = f(state) + + exit(state) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/service_docker_in_docker.py b/plugins/modules/service_docker_in_docker.py new file mode 100644 index 0000000..f516cf1 --- /dev/null +++ b/plugins/modules/service_docker_in_docker.py @@ -0,0 +1,48 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Luca Bilke +# MIT License (see LICENSE) + +# TODO: write ansible sections + +DOCUMENTATION = r""" +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues] + +from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( + State, + run_service, + update_service, +) + + +def helper(state: State) -> State: + command = state.module.params.get("command") + + update = { + "privileged": True, + } + + if command: + update["command"] = command + + return update_service(state, update) + + +def main(): + extra_args = { + "command": {"type": "list"}, + } + run_service(extra_args, helper) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/service_docker_socket_proxy.py b/plugins/modules/service_docker_socket_proxy.py new file mode 100644 index 0000000..3161c81 --- /dev/null +++ b/plugins/modules/service_docker_socket_proxy.py @@ -0,0 +1,54 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Luca Bilke +# MIT License (see LICENSE) + +# TODO: write ansible sections + +DOCUMENTATION = r""" +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues] + +from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( + State, + run_service, + update_service, +) + + +def helper(state: State) -> State: + read_only = state.module.params.get("read_only", True) + + volumes = [ + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": read_only, + } + ] + + update = { + "volumes": volumes, + } + + return update_service(state, update) + + +def main(): + extra_args = { + "read_only": {"type": "bool"}, + } + run_service(extra_args, helper) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/docker_volume_backupper.py b/plugins/modules/service_docker_volume_backupper.py similarity index 93% rename from plugins/modules/docker_volume_backupper.py rename to plugins/modules/service_docker_volume_backupper.py index ed87a2b..309bafa 100644 --- a/plugins/modules/docker_volume_backupper.py +++ b/plugins/modules/service_docker_volume_backupper.py @@ -27,7 +27,7 @@ from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( def helper(state: State) -> State: archive = state.module.params.get("archive") backup_volumes = state.module.params.get("backup_volumes", []) - name = state.module.params["name"] + service_name = state.module.params["name"] project_name = state.module.params["project_name"] volumes = [ @@ -46,7 +46,7 @@ def helper(state: State) -> State: "BACKUP_PRUNING_PREFIX": f"{project_name}-", "BACKUP_STOP_DURING_BACKUP_LABEL": project_name, "BACKUP_ARCHIVE": "/archive", - "DOCKER_HOST": f"tcp://{project_name}_{name}_socket_proxy:2375", + "DOCKER_HOST": f"tcp://{project_name}_{service_name}_socket_proxy:2375", } if archive: diff --git a/plugins/modules/mariadb.py b/plugins/modules/service_mariadb.py similarity index 94% rename from plugins/modules/mariadb.py rename to plugins/modules/service_mariadb.py index b1a012f..486e328 100644 --- a/plugins/modules/mariadb.py +++ b/plugins/modules/service_mariadb.py @@ -32,12 +32,12 @@ def helper(state: State) -> State: username = state.module.params["username"] password = state.module.params["password"] root_password = state.module.params["root_password"] - name = state.module.params["name"] + service_name = state.module.params["name"] project_name = state.module.params["project_name"] volumes = [ { - "source": name, + "source": service_name, "target": "/var/lib/mysql", "type": "volume", } @@ -75,7 +75,7 @@ def helper(state: State) -> State: volumes.append( { "type": "volume", - "source": f"{name}_backup", + "source": f"{service_name}_backup", "target": "/backup", } ) diff --git a/plugins/modules/postgres.py b/plugins/modules/service_postgres.py similarity index 93% rename from plugins/modules/postgres.py rename to plugins/modules/service_postgres.py index 564d0aa..67da053 100644 --- a/plugins/modules/postgres.py +++ b/plugins/modules/service_postgres.py @@ -31,12 +31,12 @@ def helper(state: State) -> State: database = state.module.params["database"] username = state.module.params["username"] password = state.module.params["password"] - name = state.module.params["name"] + service_name = state.module.params["name"] project_name = state.module.params["project_name"] volumes = [ { - "source": name, + "source": service_name, "target": "/var/lib/postgresql/data", "type": "volume", } @@ -67,7 +67,7 @@ def helper(state: State) -> State: volumes.append( { "type": "volume", - "source": f"{name}_backup", + "source": f"{service_name}_backup", "target": "/backup", } ) diff --git a/plugins/modules/redis.py b/plugins/modules/service_redis.py similarity index 100% rename from plugins/modules/redis.py rename to plugins/modules/service_redis.py diff --git a/plugins/pyproject.toml b/plugins/pyproject.toml index 659b4b4..b4d93b0 100644 --- a/plugins/pyproject.toml +++ b/plugins/pyproject.toml @@ -4,11 +4,13 @@ line-length = 100 [tool.basedpyright] typeCheckingMode = "strict" reportIgnoreCommentWithoutRule = true +reportUnusedCallResult = true reportMissingTypeStubs = false # handled by ruff reportUnusedVariable = false reportUnusedImport = false +reportUndefinedVariable = false [tool.ruff.lint]