refactor(ariadne): split nextcloud mail and maintenance helpers

This commit is contained in:
codex 2026-04-21 01:18:17 -03:00
parent 2477ca3899
commit d999b4ff8c
4 changed files with 244 additions and 212 deletions

View File

@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
import re
import time import time
from typing import Any from typing import Any
@ -15,96 +13,17 @@ from ..settings import settings
from ..utils.logging import get_logger from ..utils.logging import get_logger
from ..utils.passwords import random_password from ..utils.passwords import random_password
from .keycloak_admin import keycloak_admin from .keycloak_admin import keycloak_admin
from .nextcloud_maintenance import run_maintenance as run_nextcloud_maintenance
from .nextcloud_mail_models import MailSyncCounters
from .nextcloud_mail_models import display_name as _display_name
from .nextcloud_mail_models import _extract_attr
from .nextcloud_mail_models import _parse_mail_export
from .nextcloud_mail_models import _resolve_mailu_email
logger = get_logger(__name__) logger = get_logger(__name__)
def _extract_attr(attrs: Any, key: str) -> str:
if not isinstance(attrs, dict):
return ""
raw = attrs.get(key)
if isinstance(raw, list):
for item in raw:
if isinstance(item, str) and item.strip():
return item.strip()
return ""
if isinstance(raw, str) and raw.strip():
return raw.strip()
return ""
def _resolve_mailu_email(username: str, user: dict[str, Any]) -> str:
attrs = user.get("attributes")
mailu_email = _extract_attr(attrs, "mailu_email")
if mailu_email:
return mailu_email
email = user.get("email")
if isinstance(email, str) and email.strip():
email = email.strip()
if email.lower().endswith(f"@{settings.mailu_domain.lower()}"):
return email
return f"{username}@{settings.mailu_domain}"
def _parse_mail_export(output: str) -> list[tuple[str, str]]:
accounts: list[tuple[str, str]] = []
account_id = ""
for line in output.splitlines():
line = line.strip()
if not line:
continue
match = re.match(r"^Account\s+(\d+):", line, flags=re.IGNORECASE)
if match:
account_id = match.group(1)
continue
match = re.match(r"^-\s*E-?mail:\s*(\S+)", line, flags=re.IGNORECASE)
if match and account_id:
accounts.append((account_id, match.group(1)))
return accounts
@dataclass(frozen=True)
class NextcloudMailSyncSummary:
processed: int
created: int
updated: int
deleted: int
skipped: int
failures: int
detail: str = ""
@dataclass
class MailSyncCounters:
processed: int = 0
created: int = 0
updated: int = 0
deleted: int = 0
skipped: int = 0
failures: int = 0
last_error: str = ""
def summary(self) -> NextcloudMailSyncSummary:
return NextcloudMailSyncSummary(
processed=self.processed,
created=self.created,
updated=self.updated,
deleted=self.deleted,
skipped=self.skipped,
failures=self.failures,
detail=self.last_error,
)
def status(self) -> str:
return "ok" if self.failures == 0 else "error"
def record_failure(self, detail: str) -> None:
self.failures += 1
if detail and not self.last_error:
self.last_error = detail
class NextcloudService: class NextcloudService:
"""Synchronize user mail configuration inside the Nextcloud pod.""" """Synchronize user mail configuration inside the Nextcloud pod."""
@ -162,15 +81,6 @@ class NextcloudService:
result = self._occ_exec(args, check=True) result = self._occ_exec(args, check=True)
return result.stdout return result.stdout
def _display_name(self, user: dict[str, Any]) -> str:
first = user.get("firstName") if isinstance(user.get("firstName"), str) else ""
last = user.get("lastName") if isinstance(user.get("lastName"), str) else ""
first = first.strip()
last = last.strip()
if first and last:
return f"{first} {last}"
return last or first
def _ensure_nextcloud_user( def _ensure_nextcloud_user(
self, self,
username: str, username: str,
@ -493,7 +403,7 @@ class NextcloudService:
username, user_id, mailu_email, app_pw, full_user = context username, user_id, mailu_email, app_pw, full_user = context
try: try:
display_name = self._display_name(full_user) display_name = _display_name(full_user)
self._ensure_nextcloud_user(username, mailu_email, display_name) self._ensure_nextcloud_user(username, mailu_email, display_name)
except Exception as exc: except Exception as exc:
counters.record_failure(f"nextcloud user ensure failed: {exc}") counters.record_failure(f"nextcloud user ensure failed: {exc}")
@ -560,13 +470,6 @@ class NextcloudService:
return {"status": counters.status(), "summary": summary_payload, "detail": summary.detail} return {"status": counters.status(), "summary": summary_payload, "detail": summary.detail}
def _run_shell(self, script: str, check: bool = True) -> None:
self._executor.exec(
script,
timeout_sec=settings.nextcloud_exec_timeout_sec,
check=check,
)
def _external_api(self, method: str, path: str, data: dict[str, Any] | None = None) -> dict[str, Any]: def _external_api(self, method: str, path: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
if not settings.nextcloud_url: if not settings.nextcloud_url:
raise RuntimeError("nextcloud url not configured") raise RuntimeError("nextcloud url not configured")
@ -589,113 +492,7 @@ class NextcloudService:
return {} return {}
def run_maintenance(self) -> dict[str, Any]: def run_maintenance(self) -> dict[str, Any]:
if not settings.nextcloud_namespace: return run_nextcloud_maintenance(self)
raise RuntimeError("nextcloud maintenance not configured")
try:
self._run_shell(
"""
set -euo pipefail
if [ ! -d /var/www/html/lib ] && [ -d /usr/src/nextcloud/lib ]; then
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete --exclude config --exclude data /usr/src/nextcloud/ /var/www/html/
else
cp -a /usr/src/nextcloud/. /var/www/html/
fi
fi
mkdir -p /var/www/html/data
chown 33:33 /var/www/html || true
chmod 775 /var/www/html || true
chown -R 33:33 /var/www/html/apps /var/www/html/custom_apps /var/www/html/data /var/www/html/config 2>/dev/null || true
""",
check=False,
)
self._occ(["config:app:set", "theming", "name", "--value", "Atlas Cloud"])
self._occ(["config:app:set", "theming", "slogan", "--value", "Unified access to Atlas services"])
theming_url = settings.nextcloud_url or "https://cloud.bstein.dev"
self._occ(["config:app:set", "theming", "url", "--value", theming_url])
self._occ(["config:app:set", "theming", "color", "--value", "#0f172a"])
self._occ(["config:app:set", "theming", "disable-user-theming", "--value", "yes"])
self._executor.exec(
["runuser", "-u", "www-data", "--", "php", "/var/www/html/occ", "app:install", "customcss"],
timeout_sec=settings.nextcloud_exec_timeout_sec,
check=False,
)
self._executor.exec(
["runuser", "-u", "www-data", "--", "php", "/var/www/html/occ", "app:enable", "customcss"],
timeout_sec=settings.nextcloud_exec_timeout_sec,
check=False,
)
mail_css = (
".mail-message-body, .mail-message-body pre, .mail-message-body code, .mail-message-body table {\n"
" font-family: \"Inter\", \"Source Sans 3\", \"Helvetica Neue\", Arial, sans-serif;\n"
" font-size: 14px;\n"
" line-height: 1.6;\n"
" color: var(--color-main-text);\n"
"}\n"
".mail-message-body pre {\n"
" background: rgba(15, 23, 42, 0.06);\n"
" padding: 12px;\n"
" border-radius: 8px;\n"
"}\n"
".mail-message-body blockquote {\n"
" border-left: 3px solid var(--color-border);\n"
" padding-left: 12px;\n"
" margin: 8px 0;\n"
" color: var(--color-text-lighter);\n"
"}\n"
".mail-message-body img {\n"
" max-width: 100%;\n"
" border-radius: 6px;\n"
"}\n"
)
self._occ(["config:app:set", "customcss", "css", "--value", mail_css])
self._occ(["config:app:set", "files", "default_quota", "--value", "250 GB"])
payload = self._external_api("GET", "?format=json")
links = payload.get("ocs", {}).get("data", []) if isinstance(payload, dict) else []
for link in links:
link_id = link.get("id") if isinstance(link, dict) else None
if link_id is not None:
self._external_api("DELETE", f"/sites/{link_id}?format=json")
sites = [
("Vaultwarden", "https://vault.bstein.dev"),
("Jellyfin", "https://stream.bstein.dev"),
("Gitea", "https://scm.bstein.dev"),
("Jenkins", "https://ci.bstein.dev"),
("Harbor", "https://registry.bstein.dev"),
("Vault", "https://secret.bstein.dev"),
("Jitsi", "https://meet.bstein.dev"),
("Grafana", "https://metrics.bstein.dev"),
("Chat LLM", "https://chat.ai.bstein.dev"),
("Vision", "https://draw.ai.bstein.dev"),
("STT/TTS", "https://talk.ai.bstein.dev"),
]
for name, url in sites:
self._external_api(
"POST",
"/sites?format=json",
data={
"name": name,
"url": url,
"lang": "",
"type": "link",
"device": "",
"icon": "",
"groups[]": "",
"redirect": "1",
},
)
except (ExecError, PodSelectionError, TimeoutError) as exc:
return {"status": "error", "detail": str(exc)}
except Exception as exc: # noqa: BLE001
return {"status": "error", "detail": str(exc)}
return {"status": "ok", "detail": "maintenance complete"}
nextcloud = NextcloudService() nextcloud = NextcloudService()

View File

@ -0,0 +1,106 @@
"""Mail synchronization helpers for Nextcloud account management."""
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Any
from ..settings import settings
def _extract_attr(attrs: Any, key: str) -> str:
if not isinstance(attrs, dict):
return ""
raw = attrs.get(key)
if isinstance(raw, list):
for item in raw:
if isinstance(item, str) and item.strip():
return item.strip()
return ""
if isinstance(raw, str) and raw.strip():
return raw.strip()
return ""
def _resolve_mailu_email(username: str, user: dict[str, Any]) -> str:
attrs = user.get("attributes")
mailu_email = _extract_attr(attrs, "mailu_email")
if mailu_email:
return mailu_email
email = user.get("email")
if isinstance(email, str) and email.strip():
email = email.strip()
if email.lower().endswith(f"@{settings.mailu_domain.lower()}"):
return email
return f"{username}@{settings.mailu_domain}"
def _parse_mail_export(output: str) -> list[tuple[str, str]]:
accounts: list[tuple[str, str]] = []
account_id = ""
for line in output.splitlines():
line = line.strip()
if not line:
continue
match = re.match(r"^Account\s+(\d+):", line, flags=re.IGNORECASE)
if match:
account_id = match.group(1)
continue
match = re.match(r"^-\s*E-?mail:\s*(\S+)", line, flags=re.IGNORECASE)
if match and account_id:
accounts.append((account_id, match.group(1)))
return accounts
def display_name(user: dict[str, Any]) -> str:
"""Return a human display name from Keycloak first/last name fields."""
first = user.get("firstName") if isinstance(user.get("firstName"), str) else ""
last = user.get("lastName") if isinstance(user.get("lastName"), str) else ""
first = first.strip()
last = last.strip()
if first and last:
return f"{first} {last}"
return last or first
@dataclass(frozen=True)
class NextcloudMailSyncSummary:
processed: int
created: int
updated: int
deleted: int
skipped: int
failures: int
detail: str = ""
@dataclass
class MailSyncCounters:
processed: int = 0
created: int = 0
updated: int = 0
deleted: int = 0
skipped: int = 0
failures: int = 0
last_error: str = ""
def summary(self) -> NextcloudMailSyncSummary:
return NextcloudMailSyncSummary(
processed=self.processed,
created=self.created,
updated=self.updated,
deleted=self.deleted,
skipped=self.skipped,
failures=self.failures,
detail=self.last_error,
)
def status(self) -> str:
return "ok" if self.failures == 0 else "error"
def record_failure(self, detail: str) -> None:
self.failures += 1
if detail and not self.last_error:
self.last_error = detail

View File

@ -0,0 +1,130 @@
"""Nextcloud maintenance task implementation."""
from __future__ import annotations
from typing import Any
from ..k8s.exec import ExecError
from ..k8s.pods import PodSelectionError
from ..settings import settings
def _run_shell(service: Any, script: str, check: bool = True) -> None:
service._executor.exec(
script,
timeout_sec=settings.nextcloud_exec_timeout_sec,
check=check,
)
def run_maintenance(service: Any) -> dict[str, Any]:
"""Run theming, app-link, quota, and filesystem maintenance for Nextcloud."""
if not settings.nextcloud_namespace:
raise RuntimeError("nextcloud maintenance not configured")
try:
_run_shell(
service,
"""
set -euo pipefail
if [ ! -d /var/www/html/lib ] && [ -d /usr/src/nextcloud/lib ]; then
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete --exclude config --exclude data /usr/src/nextcloud/ /var/www/html/
else
cp -a /usr/src/nextcloud/. /var/www/html/
fi
fi
mkdir -p /var/www/html/data
chown 33:33 /var/www/html || true
chmod 775 /var/www/html || true
chown -R 33:33 /var/www/html/apps /var/www/html/custom_apps /var/www/html/data /var/www/html/config 2>/dev/null || true
""",
check=False,
)
service._occ(["config:app:set", "theming", "name", "--value", "Atlas Cloud"])
service._occ(["config:app:set", "theming", "slogan", "--value", "Unified access to Atlas services"])
theming_url = settings.nextcloud_url or "https://cloud.bstein.dev"
service._occ(["config:app:set", "theming", "url", "--value", theming_url])
service._occ(["config:app:set", "theming", "color", "--value", "#0f172a"])
service._occ(["config:app:set", "theming", "disable-user-theming", "--value", "yes"])
service._executor.exec(
["runuser", "-u", "www-data", "--", "php", "/var/www/html/occ", "app:install", "customcss"],
timeout_sec=settings.nextcloud_exec_timeout_sec,
check=False,
)
service._executor.exec(
["runuser", "-u", "www-data", "--", "php", "/var/www/html/occ", "app:enable", "customcss"],
timeout_sec=settings.nextcloud_exec_timeout_sec,
check=False,
)
mail_css = (
".mail-message-body, .mail-message-body pre, .mail-message-body code, .mail-message-body table {\n"
" font-family: \"Inter\", \"Source Sans 3\", \"Helvetica Neue\", Arial, sans-serif;\n"
" font-size: 14px;\n"
" line-height: 1.6;\n"
" color: var(--color-main-text);\n"
"}\n"
".mail-message-body pre {\n"
" background: rgba(15, 23, 42, 0.06);\n"
" padding: 12px;\n"
" border-radius: 8px;\n"
"}\n"
".mail-message-body blockquote {\n"
" border-left: 3px solid var(--color-border);\n"
" padding-left: 12px;\n"
" margin: 8px 0;\n"
" color: var(--color-text-lighter);\n"
"}\n"
".mail-message-body img {\n"
" max-width: 100%;\n"
" border-radius: 6px;\n"
"}\n"
)
service._occ(["config:app:set", "customcss", "css", "--value", mail_css])
service._occ(["config:app:set", "files", "default_quota", "--value", "250 GB"])
payload = service._external_api("GET", "?format=json")
links = payload.get("ocs", {}).get("data", []) if isinstance(payload, dict) else []
for link in links:
link_id = link.get("id") if isinstance(link, dict) else None
if link_id is not None:
service._external_api("DELETE", f"/sites/{link_id}?format=json")
sites = [
("Vaultwarden", "https://vault.bstein.dev"),
("Jellyfin", "https://stream.bstein.dev"),
("Gitea", "https://scm.bstein.dev"),
("Jenkins", "https://ci.bstein.dev"),
("Harbor", "https://registry.bstein.dev"),
("Vault", "https://secret.bstein.dev"),
("Jitsi", "https://meet.bstein.dev"),
("Grafana", "https://metrics.bstein.dev"),
("Chat LLM", "https://chat.ai.bstein.dev"),
("Vision", "https://draw.ai.bstein.dev"),
("STT/TTS", "https://talk.ai.bstein.dev"),
]
for name, url in sites:
service._external_api(
"POST",
"/sites?format=json",
data={
"name": name,
"url": url,
"lang": "",
"type": "link",
"device": "",
"icon": "",
"groups[]": "",
"redirect": "1",
},
)
except (ExecError, PodSelectionError, TimeoutError) as exc:
return {"status": "error", "detail": str(exc)}
except Exception as exc: # noqa: BLE001
return {"status": "error", "detail": str(exc)}
return {"status": "ok", "detail": "maintenance complete"}

View File

@ -3,7 +3,6 @@ ariadne/services/cluster_state.py split planned; service orchestration decomposi
ariadne/app.py split planned; Flask app bootstrap/routes currently co-located ariadne/app.py split planned; Flask app bootstrap/routes currently co-located
ariadne/services/comms.py split planned; comms adapters still consolidated ariadne/services/comms.py split planned; comms adapters still consolidated
ariadne/manager/provisioning.py split planned; provisioning flow modules pending extraction ariadne/manager/provisioning.py split planned; provisioning flow modules pending extraction
ariadne/services/nextcloud.py split planned; provider methods pending partition
ariadne/settings.py split planned; settings schema + helpers pending split ariadne/settings.py split planned; settings schema + helpers pending split
ariadne/services/jenkins_workspace_cleanup.py split planned; job orchestration pending extraction ariadne/services/jenkins_workspace_cleanup.py split planned; job orchestration pending extraction
tests/test_provisioning.py test module split planned; broad provisioning coverage retained meanwhile tests/test_provisioning.py test module split planned; broad provisioning coverage retained meanwhile

1 # path reason
3 ariadne/app.py split planned; Flask app bootstrap/routes currently co-located
4 ariadne/services/comms.py split planned; comms adapters still consolidated
5 ariadne/manager/provisioning.py split planned; provisioning flow modules pending extraction
ariadne/services/nextcloud.py split planned; provider methods pending partition
6 ariadne/settings.py split planned; settings schema + helpers pending split
7 ariadne/services/jenkins_workspace_cleanup.py split planned; job orchestration pending extraction
8 tests/test_provisioning.py test module split planned; broad provisioning coverage retained meanwhile