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 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]

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>
# 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__":

View File

@ -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"]