This commit is contained in:
Luca Bilke 2025-01-17 18:31:17 +01:00
parent 5750a5a495
commit c5c0e3f6f3
25 changed files with 301 additions and 283 deletions

View file

@ -19,7 +19,7 @@ return {
type = "python",
request = "launch",
name = "custom",
program = root .. "/modules/compose.py",
program = root .. "/plugins/modules/compose.py",
console = "integratedTerminal",
pythonPath = os.getenv("VIRTUAL_ENV") .. "/bin/python",
args = { root .. "/test.json" },

View file

@ -1,4 +1,4 @@
Copyright 2024 Luca Bilke
Copyright 2025 Luca Bilke
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View file

@ -1,4 +1,4 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
@ -6,7 +6,7 @@ from __future__ import annotations
import copy
from dataclasses import replace
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import yaml
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import Result, State
@ -21,22 +21,19 @@ def recursive_update(
) -> dict[Any, Any]:
default = copy.deepcopy(default)
for key in update: # noqa: PLC0206
if isinstance(update[key], dict) and (
isinstance(default.get(key), dict) or default.get(key) is None
):
default[key] = recursive_update(default.get(key, {}), update[key])
for k, v in update.items():
if isinstance(v, dict):
v = cast(dict[Any, Any], v)
default[k] = recursive_update(default.get(k) or {}, v)
elif isinstance(update[key], list) and (
isinstance(default.get(key), list) or default.get(key) is None
):
# default_set = set(default.get(key, []))
# custom_set = set(update[key])
# default[key] = list(default_set.union(custom_set))
default[key] = default.get(key, []).extend(update[key])
elif isinstance(v, list):
v = cast(list[Any], v)
new = cast(list[Any], (default.get(k) or []))
new.extend(v)
default[k] = new
else:
default[key] = update[key]
default[k] = v
return default
@ -96,6 +93,49 @@ def update_project(state: State) -> State:
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 = sorted(cast(list[Any], before))
after = sorted(after)
if len(before) != len(after):
return True
for index in before.enumerate():
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

View file

@ -1,4 +1,4 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
__all__ = [

View file

@ -1,14 +1,14 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable
from ansible_collections.snailed.ez_compose.plugins.module_utils.service import common
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
BASE_ARGS: dict[str, Any] = {}
@ -18,6 +18,7 @@ def run_helper(
state: State,
service_name: str,
params: dict[str, Any],
helper: Callable[[State, str, dict[str, Any]], State] = lambda a, _b, _c: a,
helper: Callable[[State, str, dict[str, Any]], dict[str, Any]] = lambda _a, _b, _c: {},
) -> State:
return helper(state, service_name, params)
update = helper(state, service_name, params)
return common.apply_update(state, service_name, update)

View file

@ -1,23 +1,19 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {
"stop": {"type": "bool", "default": True},
}
def helper(state: State, service_name: str, params: dict[str, Any]) -> State:
def helper(state: State, _service_name: str, params: dict[str, Any]) -> dict[str, Any]:
stop: bool = params["stop"]
project_name: str = state.module.params["name"]
@ -28,4 +24,4 @@ def helper(state: State, service_name: str, params: dict[str, Any]) -> State:
"docker-volume-backup.stop-during-backup": project_name,
}
return service.common.update(state, {"name": service_name}, update)
return update

View file

@ -1,16 +1,12 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {
"proxy_type": {"type": "str", "default": "http"},
@ -20,22 +16,19 @@ EXTRA_ARGS = {
}
def helper(state: State, service_name: str, params: dict[str, Any]) -> State:
def helper(state: State, service_name: str, params: dict[str, Any]) -> dict[str, Any]:
project_name: str = state.module.params["name"]
middleware: str = params["middleware"]
settings: dict[str, str] = params["settings"]
proxy_type: str = state.module.params["proxy_type"]
name: str = (
params.get("name")
or f"{project_name}_{service_name}_{proxy_type}_{middleware.lower()}"
params.get("name") or f"{project_name}_{service_name}_{proxy_type}_{middleware.lower()}"
)
prefix = f"traefik.{proxy_type}.middlewares.{name}"
labels = {f"{prefix}.{middleware}.{key}": var for key, var in settings.items()}
update = {
return {
"labels": labels,
}
return service.common.update(state, {"name": service_name}, update)

View file

@ -1,16 +1,12 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {
"name": {"type": "str"},
@ -23,7 +19,7 @@ EXTRA_ARGS = {
}
def helper(state: State, service_name: str, params: dict[str, Any]) -> State:
def helper(state: State, service_name: str, params: dict[str, Any]) -> dict[str, Any]:
project_name: str = state.module.params["name"]
rule: str = params["rule"]
traefik_service: str | None = params.get("service")
@ -52,8 +48,6 @@ def helper(state: State, service_name: str, params: dict[str, Any]) -> State:
if middlewares:
labels[f"{prefix}.middlewares"] = ",".join(middlewares)
update = {
return {
"labels": labels,
}
return service.common.update(state, {"name": service_name}, update)

View file

@ -1,16 +1,12 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {
"proxy_type": {"type": "str", "default": "http"},
@ -19,7 +15,7 @@ EXTRA_ARGS = {
}
def helper(state: State, service_name: str, params: dict[str, Any]) -> State:
def helper(state: State, service_name: str, params: dict[str, Any]) -> dict[str, Any]:
project_name: str = state.module.params["name"]
port: int | None = params.get("port")
proxy_type: str = params["proxy_type"]
@ -32,8 +28,6 @@ def helper(state: State, service_name: str, params: dict[str, Any]) -> State:
if port:
labels[f"{prefix}.loadbalancer.server.port"] = str(port)
update = {
return {
"labels": labels,
}
return service.common.update(state, {"name": service_name}, update)

View file

@ -1,9 +1,9 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@ -13,7 +13,7 @@ if TYPE_CHECKING:
@dataclass(frozen=True)
class Result:
changed: bool = False
diff: dict[str, Any] = field(default_factory=dict)
diff: dict[str, Any] | None = None
@dataclass(frozen=True)

View file

@ -1,4 +1,4 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
__all__ = [

View file

@ -1,23 +1,16 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
# FIX: This entire library is broken and needs to be fixed
from __future__ import annotations
import copy
from dataclasses import replace
from typing import TYPE_CHECKING, Any, Callable
from ansible_collections.snailed.ez_compose.plugins.module_utils.common import (
recursive_update,
update_project,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.common import recursive_update
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
BASE_ARGS: dict[str, Any] = {
"name": {"type": "str"},
@ -25,12 +18,11 @@ BASE_ARGS: dict[str, Any] = {
}
def apply_base(state: State, params: dict[str, Any]) -> State:
def get_base_definition(state: State, service_name: str) -> dict[str, Any]:
project_name: str = state.module.params["name"]
new: dict[str, Any] = {
"container_name": f"{project_name}_{params['name']}",
"hostname": f"{project_name}_{params['name']}",
return {
"container_name": f"{project_name}_{service_name}",
"hostname": f"{project_name}_{service_name}",
"restart": "unless-stopped",
"environment": {},
"labels": {},
@ -38,51 +30,26 @@ def apply_base(state: State, params: dict[str, Any]) -> State:
"networks": {
"internal": None,
},
} | (
}
def get_default_definition(state: State, service_name: str) -> dict[str, Any]:
return (
state.module.params.get("settings", {})
.get("default_definition", {})
.get(params["name"], {})
.get(service_name, {})
)
return update(state, params, new)
def apply_definition(state: State, params: dict[str, Any], definition: dict[str, Any]) -> State:
def apply_update(state: State, service_name: str, update: dict[str, Any]) -> State:
project = copy.deepcopy(state.after)
services: dict[str, Any] = project["services"]
service: dict[str, Any] = services[params["name"]]
service = recursive_update(service, definition)
return update(state, params, service)
def apply_settings(state: State, params: dict[str, Any]) -> State:
settings = state.module.params.get("settings", {})
params = settings.get("service_default_args", {}).get(params["name"], {}) | params
return update(
state,
params,
settings.get("service_default_definitions", {}).get(params["name"], {}),
)
def update(state: State, params: dict[str, Any], update: dict[str, Any]) -> State:
project = copy.deepcopy(state.after)
project["services"][params["name"]] = recursive_update(
project["services"].get(params["name"], {}),
update,
)
new_definition = project["services"][params["name"]]
new_volumes: list[dict[str, Any]] = new_definition.get("volumes") or []
service = project["services"].get(service_name, {})
project["services"][service_name] = recursive_update(service, update)
volumes: list[dict[str, Any]] = project["services"][service_name].get("volumes") or []
# FIX: this silently throws out misconfigured volumes
unique_volumes = list({vol["source"]: vol for vol in new_volumes if "target" in vol}.values())
project["services"][params["name"]]["volumes"] = unique_volumes
unique_volumes = list({vol["source"]: vol for vol in volumes if "target" in vol}.values())
project["services"][service_name]["volumes"] = unique_volumes
return replace(state, after=project)
@ -90,7 +57,7 @@ def update(state: State, params: dict[str, Any], update: dict[str, Any]) -> Stat
def run_helper(
state: State,
params: dict[str, Any],
helper: Callable[[State, dict[str, Any]], State] = lambda x, _: x,
helper: Callable[[State, dict[str, Any]], dict[str, Any]] = lambda _a, _b: {},
) -> State:
if not params.get("name"):
params["name"] = str.split(helper.__module__, ".")[-1]
@ -98,8 +65,11 @@ def run_helper(
if not (overwrite := params.get("overwrite")):
overwrite = params.get("definition", {})
state = apply_base(state, params)
state = apply_settings(state, params)
state = helper(state, params)
state = apply_definition(state, params, overwrite)
return update_project(state)
base_definition = get_base_definition(state, params["name"])
default_definition = get_default_definition(state, params["name"])
helper_update = helper(state, params)
state = apply_update(state, params["name"], base_definition)
state = apply_update(state, params["name"], default_definition)
state = apply_update(state, params["name"], helper_update)
return apply_update(state, params["name"], overwrite)

View file

@ -1,11 +1,11 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import label, service, spec
from ansible_collections.snailed.ez_compose.plugins.module_utils import label, spec
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
@ -20,7 +20,7 @@ FORCE_ARGS = {
}
def helper(state: State, params: dict[str, Any]) -> State:
def helper(state: State, params: dict[str, Any]) -> dict[str, Any]:
internal_network: bool = params["internal_network"]
update: dict[str, Any] = {}
@ -36,4 +36,4 @@ def helper(state: State, params: dict[str, Any]) -> State:
helper = getattr(label, name).helper
state = label.common.run_helper(state, params["name"], args, helper)
return service.common.update(state, params, update)
return update

View file

@ -1,23 +1,17 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.common import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {}
def helper(state: State, params: dict[str, Any]) -> State:
update = {
def helper(_state: State, _params: dict[str, Any]) -> dict[str, Any]:
return {
"privileged": True,
}
return service.common.update(state, params, update)

View file

@ -1,23 +1,19 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.common import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {
"read_only": {"type": "bool", "default": True},
}
def helper(state: State, params: dict[str, Any]) -> State:
def helper(_state: State, params: dict[str, Any]) -> dict[str, Any]:
read_only = params["read_only"]
volumes = [
@ -29,8 +25,6 @@ def helper(state: State, params: dict[str, Any]) -> State:
},
]
update = {
return {
"volumes": volumes,
}
return service.common.update(state, params, update)

View file

@ -1,16 +1,12 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {
"archive": {"type": "path"},
@ -18,7 +14,7 @@ EXTRA_ARGS = {
}
def helper(state: State, params: dict[str, Any]) -> State:
def helper(state: State, params: dict[str, Any]) -> dict[str, Any]:
project_name: str = state.module.params["name"]
archive: str | None = params.get("archive")
backup_volumes: list[str] | None = params["backup_volumes"]
@ -53,9 +49,7 @@ def helper(state: State, params: dict[str, Any]) -> State:
},
)
update = {
return {
"environment": environment,
"volumes": volumes,
}
return service.common.update(state, params, update)

View file

@ -1,4 +1,4 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
@ -6,12 +6,8 @@ from __future__ import annotations
import shlex
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {
"backup": {"type": "bool", "default": True},
@ -22,7 +18,7 @@ EXTRA_ARGS = {
}
def helper(state: State, params: dict[str, Any]) -> State:
def helper(state: State, params: dict[str, Any]) -> dict[str, Any]:
project_name: str = state.module.params["name"]
backup: bool = params["backup"]
database: str = params["database"]
@ -76,10 +72,8 @@ def helper(state: State, params: dict[str, Any]) -> State:
},
)
update = {
return {
"environment": environment,
"volumes": volumes,
"labels": labels,
}
return service.common.update(state, params, update)

View file

@ -1,4 +1,4 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
@ -6,12 +6,8 @@ from __future__ import annotations
import shlex
from typing import TYPE_CHECKING, Any
from ansible_collections.snailed.ez_compose.plugins.module_utils import service
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {
"backup": {"type": "bool", "default": True},
@ -21,7 +17,7 @@ EXTRA_ARGS = {
}
def helper(state: State, params: dict[str, Any]) -> State:
def helper(state: State, params: dict[str, Any]) -> dict[str, Any]:
project_name: str = state.module.params["name"]
backup: bool = params["backup"]
database: str = params["database"]
@ -67,10 +63,8 @@ def helper(state: State, params: dict[str, Any]) -> State:
},
)
update = {
return {
"environment": environment,
"volumes": volumes,
"labels": labels,
}
return service.common.update(state, params, update)

View file

@ -1,17 +1,15 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import (
State,
)
from ansible_collections.snailed.ez_compose.plugins.module_utils.models import State
EXTRA_ARGS = {}
def helper(state: State) -> State:
return state
def helper(_state: State, _params: dict[str, Any]) -> dict[str, Any]:
return {}

View file

@ -1,4 +1,4 @@
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from __future__ import annotations

View file

@ -1,10 +1,12 @@
#!/usr/bin/python
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
# ruff: noqa: E402
from __future__ import annotations
from dataclasses import asdict
# TODO: break this down per module
# TODO: generate this by reassembling
DOCUMENTATION = """
@ -476,12 +478,16 @@ def main() -> None:
"settings": spec.settings_spec(),
"services": spec.service_argument_spec(),
},
supports_check_mode=True,
)
if not find_spec("yaml"):
module.fail_json("PyYAML seems to be missing on host") # pyright: ignore[reportUnknownMemberType]
module.fail_json("PyYAML needs to be installed on the host to use this plugin") # pyright: ignore[reportUnknownMemberType]
state = common.get_state(module)
try:
state = common.get_state(module)
except Exception as e: # noqa: BLE001
module.fail_json(f"Error while reading existing compose file: {e}") # pyright: ignore[reportUnknownMemberType]
for name, services_params in [(x, y) for x, y in module.params["services"].items() if y]:
for index, service_params in enumerate(services_params):
@ -489,7 +495,20 @@ def main() -> None:
helper = getattr(service, name).helper
state = service.common.run_helper(state, service_params, helper)
print("fuck")
state = common.update_project(state)
state = common.set_result(state)
if state.result.changed and not module.check_mode:
try:
state = common.write_compose(state)
except Exception as e: # noqa: BLE001
module.fail_json(f"Error while writing new compose file: {e}") # pyright: ignore[reportUnknownMemberType]
ret = asdict(state.result)
if not module._diff: # noqa: SLF001
del ret["diff"]
module.exit_json(**ret) # pyright: ignore[reportUnknownMemberType]
if __name__ == "__main__":

View file

@ -1,85 +0,0 @@
{
"ANSIBLE_MODULE_ARGS": {
"name": "taskwarrior",
"project_dir": "/var/lib/ez_compose",
"services": {
"custom": [
{
"definition": {
"image": "ghcr.io/gothenburgbitfactory/taskchampion-sync-server:main@sha256:4798edada4b264cdcc82f1c8ea2389cdd5cde02926f74b2361005438056f5729",
"volumes": [
{
"source": "sync_data",
"target": "/var/lib/taskchampion-sync-server",
"type": "volume"
}
]
},
"label_helpers": {
"docker_volume_backupper": {},
"traefik_router": {
"entrypoints": ["web-secure"],
"rule": "Host(`taskwarrior-sync.snailed.de`)"
}
},
"name": "sync"
}
],
"docker_volume_backupper": [
{
"backup_volumes": ["sync_data"]
}
]
},
"settings": {
"default_definition": {
"environment": {
"TZ": "Europe/Berlin"
}
},
"label_default_args": {
"traefik_router": {
"certresolver": "letsencrypt"
}
},
"service_default_args": {
"docker_volume_backupper": {
"archive": "/tank/docker-backups"
}
},
"service_default_definitions": {
"docker_volume_backupper": {
"environment": {
"BACKUP_CRON_EXPRESSION": "0 6 * * *",
"BACKUP_RETENTION_DAYS": "7",
"EXEC_FORWARD_OUTPUT": true,
"GPG_PASSPHRASE": "UYi9wgpWKgZBep",
"GZIP_PARALLELISM": "2",
"NOTIFICATION_URLS": "smtp://root%40snaile.de:RzBNcz@5iq5gGw@tripoli.snaile.de:465/?FromAddress=root@snaile.de&ToAddresses=luca@bil.ke&Encryption=ImplicitTLS",
"SSH_HOST_NAME": "u374707.your-storagebox.de",
"SSH_PASSWORD": "8WHePPymUsPUaXeS",
"SSH_PORT": "23",
"SSH_REMOTE_PATH": "/home/docker-backups",
"SSH_USER": "u374707-sub1"
},
"image": "offen/docker-volume-backup:v2.43.0"
},
"docker_in_docker": {
"image": "docker:27.4.0-dind"
},
"mariadb": {
"image": "mariadb:11.6.2"
},
"postgres": {
"image": "postgres:16.6-alpine"
},
"redis": {
"image": "redis:7.4.1-alpine"
},
"docker_socket_proxy": {
"image": "tecnativa/docker-socket-proxy:0.3.0"
}
}
}
}
}

View file

@ -17,19 +17,20 @@ ignore = [
"D104",
]
[tool.ruff.per-file-ignores]
"tests/units/**" = [
"S101",
"PT009",
]
[tool.ruff.format]
line-ending = "lf"
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.basedpyright]
typeCheckingMode = "strict"
# Handled by ruff
reportPrivateUsage = false
reportIgnoreCommentWithoutRule = false
reportUnusedImport = false
reportUnusedClass = false

4
requirements-dev.txt Normal file
View file

@ -0,0 +1,4 @@
# vim: ft=requirements
ansible-core==2.17.*
pytest
PyYAML

View file

@ -0,0 +1,123 @@
# Copyright: (c) 2025, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
from typing import Any
from unittest import TestCase
import pytest
from ansible_collections.snailed.ez_compose.plugins.module_utils import common
@pytest.mark.parametrize(
("test_input", "expected"),
[ # pyright: ignore[reportUnknownArgumentType]
# Basic nested update (using existing test variables)
(
(
{"one": {"one": "keep", "two": "rewrite"}},
{"one": {"two": "new"}},
),
{"one": {"one": "keep", "two": "new"}},
),
# Deep nested update
(
(
{"a": {"b": {"c": "old", "d": "keep"}}},
{"a": {"b": {"c": "new"}}},
),
{"a": {"b": {"c": "new", "d": "keep"}}},
),
# Adding new keys at different levels
(
(
{"x": {"y": "original"}},
{"x": {"z": "new", "y": "updated"}, "new_key": "value"},
),
{"x": {"y": "updated", "z": "new"}, "new_key": "value"},
),
# Empty dict cases
(
(
{},
{"new": "data"},
),
{"new": "data"},
),
(
(
{"existing": "data"},
{},
),
{"existing": "data"},
),
# Lists within dictionaries
(
(
{"items": ["a", "b"], "nested": {"list": ["1", "2"]}},
{"items": ["c"], "nested": {"list": ["3"]}},
),
{"items": ["a", "b", "c"], "nested": {"list": ["1", "2", "3"]}},
),
# Lists of dictionaries
(
(
{
"configs": [
{"name": "config1", "value": "old"},
{"name": "config2", "enabled": True},
],
},
{
"configs": [
{"name": "config3", "value": "new"},
{"name": "config4", "enabled": False},
],
},
),
{
"configs": [
{"name": "config1", "value": "old"},
{"name": "config2", "enabled": True},
{"name": "config3", "value": "new"},
{"name": "config4", "enabled": False},
],
},
),
# Nested lists of dictionaries
(
(
{
"services": {
"web": [
{"port": 80, "protocol": "http"},
{"port": 443, "protocol": "https"},
],
},
},
{"services": {"web": [{"port": 8080, "protocol": "http"}]}},
),
{
"services": {
"web": [
{"port": 80, "protocol": "http"},
{"port": 443, "protocol": "https"},
{"port": 8080, "protocol": "http"},
],
},
},
),
# Mixed types update
(
(
{"mixed": {"num": 42, "list": [1, 2], "str": "old"}},
{"mixed": {"num": 43, "list": [3], "str": "new"}},
),
{"mixed": {"num": 43, "list": [1, 2, 3], "str": "new"}},
),
],
)
def test_recursive_update(
test_input: tuple[dict[str, Any], dict[str, Any]],
expected: dict[str, Any],
) -> None:
TestCase().assertDictEqual(common.recursive_update(*test_input), expected)