Compare commits

...

4 Commits

Author SHA1 Message Date
Luca Bilke 0cb921ab26
add postgres service helper 2024-10-22 22:24:04 +02:00
Luca Bilke 9b8d6771bd
add mariadb service helper 2024-10-22 22:23:59 +02:00
Luca Bilke 074ffbd734
add docker_volume_backupper service helper 2024-10-22 22:23:55 +02:00
Luca Bilke ad618a0442
refactor common.py 2024-10-22 22:23:44 +02:00
6 changed files with 437 additions and 94 deletions

View File

@ -3,65 +3,174 @@
from __future__ import annotations from __future__ import annotations
from typing import Any import copy
import yaml from dataclasses import asdict, dataclass, field, replace
from pydantic import BaseModel, ConfigDict from typing import Any, Callable
import yaml
from ansible.module_utils.basic import AnsibleModule 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 changed: bool = False
diff: dict[str, Any] = {} diff: dict[str, Any] = field(default_factory=dict)
class Settings(BaseModel): @dataclass(frozen=True)
model_config = ConfigDict(frozen=True) class Settings:
projects_dir: str = "/usr/local/share/ez_compose/" projects_dir: str = "/usr/local/share/ez_compose/"
class State(BaseModel): @dataclass(frozen=True)
model_config = ConfigDict(frozen=True) class State:
module: Any # Replace Any with the actual type of AnsibleModule if available
module: AnsibleModule
result: Result result: Result
settings: Settings compose_filepath: str
compose_filename: str before: dict[str, Any]
before: dict[str, Any] = {} after: dict[str, Any] = field(default_factory=dict)
after: dict[str, Any] = {}
def new_state(module: AnsibleModule) -> State: def _recursive_update(default: dict[Any, Any], update: dict[Any, Any]) -> dict[Any, Any]:
settings = Settings(**module.params["settings"]) 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( return State(
module=module, module=module,
result=Result(), result=Result(),
settings=settings, compose_filepath=compose_filepath,
compose_filename=( before=before,
f"{settings.projects_dir}/"
+ f"{module.params['project_name']}/"
+ f"{module.params['name']}.yml"
),
) )
def get_compose(state: State) -> State: def apply_service_base(state: State) -> State:
file = state.compose_filename params = state.module.params
with open(file, "r") as stream: compose = copy.deepcopy(state.before)
compose = yaml.safe_load(stream) 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: def write_compose(state: State) -> State:
file = state.compose_filename file = state.compose_filepath
with open(file, mode="w") as stream: with open(file, mode="w") as stream:
yaml.dump(state.after, stream) yaml.dump(state.after, stream)
return state 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]

View File

@ -0,0 +1,79 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# 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:
archive = state.module.params.get("archive")
backup_volumes = state.module.params.get("backup_volumes", [])
name = state.module.params["name"]
project_name = state.module.params["project_name"]
volumes = [
{
"type": "volume",
"source": volume,
"target": f"/backup/{volume}",
"read_only": True,
}
for volume in backup_volumes
]
environment = {
"BACKUP_FILENAME": f"{project_name}-%Y-%m-%dT%H-%M-%S.{'{{ .Extension }}'}",
"BACKUP_LATEST_SYMLINK": f"{project_name}-latest",
"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",
}
if archive:
volumes.append(
{
"type": "bind",
"source": f"{archive}/{project_name}",
"target": "/archive",
"bind": {"create_host_path": True},
}
)
update = {
"environment": environment,
"volumes": volumes,
}
return update_service(state, update)
def main():
extra_args = {
"archive": {"type": "str"},
"backup_volumes": {"type": "list"},
}
run_service(extra_args, helper)
if __name__ == "__main__":
main()

104
plugins/modules/mariadb.py Normal file
View File

@ -0,0 +1,104 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
# TODO: write ansible sections
DOCUMENTATION = r"""
"""
EXAMPLES = r"""
"""
RETURN = r"""
"""
from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues]
import shlex
from ansible_collections.snailed.ez_compose.plugins.module_utils.common import (
State,
run_service,
update_service,
)
def helper(state: State) -> State:
backup = state.module.params.get("backup", True)
database = state.module.params["database"]
username = state.module.params["username"]
password = state.module.params["password"]
root_password = state.module.params["root_password"]
name = state.module.params["name"]
project_name = state.module.params["project_name"]
volumes = [
{
"source": name,
"target": "/var/lib/mysql",
"type": "volume",
}
]
environment = {
"MARIADB_DATABASE": database,
"MARIADB_USER": username,
"MARIADB_PASSWORD": password,
}
labels: dict[str, str] = {}
if root_password:
environment.update(
{
"MARIADB_ROOT_PASSWORD": root_password,
}
)
if backup:
labels.update(
{
"docker-volume-backup.archive-pre": (
"/bin/sh -c '"
"mysqldump --all-databases "
f"-u {shlex.quote(username)} "
f"-p {shlex.quote(password)} "
f">/backup/{shlex.quote(project_name)}.sql"
"'"
)
}
)
# mysqldump -psecret --all-databases > /tmp/dumps/dump.sql
volumes.append(
{
"type": "volume",
"source": f"{name}_backup",
"target": "/backup",
}
)
update = {
"environment": environment,
"volumes": volumes,
"labels": labels,
}
return update_service(state, update)
def main():
extra_args = {
"archive": {"type": "bool"},
"database": {"type": "str", "required": True},
"username": {"type": "str", "required": True},
"password": {"type": "str", "required": True},
"root_password": {"type": "str"},
}
run_service(extra_args, helper)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,95 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE)
# TODO: write ansible sections
DOCUMENTATION = r"""
"""
EXAMPLES = r"""
"""
RETURN = r"""
"""
from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues]
import shlex
from ansible_collections.snailed.ez_compose.plugins.module_utils.common import (
State,
run_service,
update_service,
)
def helper(state: State) -> State:
backup = state.module.params.get("backup", True)
database = state.module.params["database"]
username = state.module.params["username"]
password = state.module.params["password"]
name = state.module.params["name"]
project_name = state.module.params["project_name"]
volumes = [
{
"source": name,
"target": "/var/lib/postgresql/data",
"type": "volume",
}
]
environment = {
"POSTGRES_DB": database,
"POSTGRES_USER": username,
"POSTGRES_PASSWORD": password,
}
labels: dict[str, str] = {}
if backup:
labels.update(
{
"docker-volume-backup.archive-pre": (
"/bin/sh -c '"
f"PGPASSWORD={shlex.quote(password)} "
"pg_dumpall "
f"-U {shlex.quote(username)} "
f"-f /backup/{shlex.quote(project_name)}.sql"
"'"
)
}
)
volumes.append(
{
"type": "volume",
"source": f"{name}_backup",
"target": "/backup",
}
)
update = {
"environment": environment,
"volumes": volumes,
"labels": labels,
}
return update_service(state, update)
def main():
extra_args = {
"archive": {"type": "bool"},
"database": {"type": "str", "required": True},
"username": {"type": "str", "required": True},
"password": {"type": "str", "required": True},
}
run_service(extra_args, helper)
if __name__ == "__main__":
main()

View File

@ -1,76 +1,27 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2024, Luca Bilke <luca@bil.ke> # Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
# MIT License (see LICENSE) # MIT License (see LICENSE)
from __future__ import annotations # TODO: write ansible sections
import copy DOCUMENTATION = r"""
"""
from ansible.module_utils.basic import AnsibleModule EXAMPLES = r"""
from ansible_collections.snailed.ez_compose.plugins.module_utils.common import ( """
State,
get_compose,
new_state,
write_compose,
)
RETURN = r"""
"""
def generate(state: State) -> State: from __future__ import annotations # pyright: ignore[reportGeneralTypeIssues]
params = state.module.params
compose = copy.deepcopy(state.before)
services = compose.get("services", {}) from ansible_collections.snailed.ez_compose.plugins.module_utils.common import run_service
container = services.get(params["name"], {})
container.update(
{
"image": params["image"],
}
)
services.update({params["name"]: container})
return state.model_copy(update={"after": compose})
def main(): def main():
module = AnsibleModule( run_service()
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]
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,5 +1,5 @@
[tool.black] [tool.black]
line-length=100 line-length = 100
[tool.basedpyright] [tool.basedpyright]
typeCheckingMode = "strict" typeCheckingMode = "strict"
@ -9,3 +9,8 @@ reportMissingTypeStubs = false
# handled by ruff # handled by ruff
reportUnusedVariable = false reportUnusedVariable = false
reportUnusedImport = false reportUnusedImport = false
[tool.ruff.lint]
# Irrelevant for ansible
ignore = ["E402", "F404"]