# Copyright: (c) 2025, Luca Bilke # MIT License (see LICENSE) from __future__ import annotations import copy from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING, Any, TypeVar, cast import yaml from ansible_collections.snailed.ez_docker.plugins.module_utils.models import Result, State if TYPE_CHECKING: from ansible.module_utils.basic import AnsibleModule # pyright: ignore[reportMissingTypeStubs] def recursive_update( default: dict[str, Any], update: dict[str, Any], ) -> dict[str, Any]: default = copy.deepcopy(default) for k, v in update.items(): if isinstance(v, dict): v = cast(dict[str, Any], v) if v.get("_ezd_no_defaults"): v.pop("_ezd_no_defaults") default[k] = v else: default[k] = recursive_update(default.get(k, {}), v) elif isinstance(v, list): v = cast(list[Any], v) old = cast(list[Any], (default.get(k, []))) if "_ezd_no_defaults" in v: v.remove("_ezd_no_defaults") default[k] = v else: old.extend(v) default[k] = old else: default[k] = v return default def get_state(module: AnsibleModule) -> State: """Create a new state object, loading the compose file into "before" if it exists.""" # def clean_params[T](obj: T) -> T: T = TypeVar("T") def clean_params(obj: T) -> T: obj = copy.deepcopy(obj) if isinstance(obj, dict): return {key: clean_params(value) for key, value in obj.items() if value is not None} # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType, reportReturnType] if isinstance(obj, list): return [clean_params(item) for item in obj if item is not None] # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType, reportReturnType] return obj compose_filepath = f"{module.params['project_dir']}/docker-compose.yml" try: with Path(compose_filepath).open("r") as fp: before = yaml.safe_load(fp) except FileNotFoundError: before: dict[str, Any] = {} return State( module=module, params=clean_params(module.params), result=Result(), compose_filepath=compose_filepath, before=before, after={ "name": module.params["name"], "services": {}, "networks": {}, "volumes": {}, }, ) def update_project(state: State) -> State: """Properly configure top level volume/network elements.""" project = copy.deepcopy(state.after) services: dict[str, Any] = project.get("services", {}) settings: dict[str, Any] = state.params.get("settings", {}) volume_sources = [ vol["source"] for service in services.values() for vol in service.get("volumes", []) if vol.get("type") == "volume" ] network_names = [ network for service in services.values() for network in service.get("networks", {}) ] project["volumes"] = {source: None for source in volume_sources} | { source: {"external": True} for source in volume_sources if source in settings.get("external_volumes", []) } project["networks"] = { network: ({"external": True} if network in settings.get("external_networks", []) else None) for network in network_names } return replace(state, after=project) def set_result(state: State) -> State: # noqa: C901 def _changed(before: Any, after: Any) -> bool: # noqa: ANN401, C901, PLR0911 if type(before) is not type(after): return True if isinstance(before, dict): before = cast(dict[str, Any], before) if len(before) != len(after): return True for key in before: if key not in after: return True if _changed(before[key], after[key]): return True return False if isinstance(before, list): before = cast(list[Any], before) if len(before) != len(after): return True for index, _ in enumerate(before): if _changed(before[index], after[index]): return True return before != after result = Result( _changed(state.before, state.after), { "before": state.before, "after": state.after, }, ) return replace(state, result=result) def write_compose(state: State) -> State: file = state.compose_filepath with Path(file).open(mode="w") as stream: yaml.dump(state.after, stream) return state