From d999b4ff8c8ac08a1a17a41b05a17991164e7414 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Apr 2026 01:18:17 -0300 Subject: [PATCH] refactor(ariadne): split nextcloud mail and maintenance helpers --- ariadne/services/nextcloud.py | 219 +--------------------- ariadne/services/nextcloud_mail_models.py | 106 +++++++++++ ariadne/services/nextcloud_maintenance.py | 130 +++++++++++++ ci/loc_hygiene_waivers.tsv | 1 - 4 files changed, 244 insertions(+), 212 deletions(-) create mode 100644 ariadne/services/nextcloud_mail_models.py create mode 100644 ariadne/services/nextcloud_maintenance.py diff --git a/ariadne/services/nextcloud.py b/ariadne/services/nextcloud.py index b6a85d7..375c34f 100644 --- a/ariadne/services/nextcloud.py +++ b/ariadne/services/nextcloud.py @@ -1,8 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import datetime, timezone -import re import time from typing import Any @@ -15,96 +13,17 @@ from ..settings import settings from ..utils.logging import get_logger from ..utils.passwords import random_password 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__) -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: """Synchronize user mail configuration inside the Nextcloud pod.""" @@ -162,15 +81,6 @@ class NextcloudService: result = self._occ_exec(args, check=True) 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( self, username: str, @@ -493,7 +403,7 @@ class NextcloudService: username, user_id, mailu_email, app_pw, full_user = context try: - display_name = self._display_name(full_user) + display_name = _display_name(full_user) self._ensure_nextcloud_user(username, mailu_email, display_name) except Exception as 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} - 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]: if not settings.nextcloud_url: raise RuntimeError("nextcloud url not configured") @@ -589,113 +492,7 @@ class NextcloudService: return {} def run_maintenance(self) -> dict[str, Any]: - if not settings.nextcloud_namespace: - 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"} + return run_nextcloud_maintenance(self) nextcloud = NextcloudService() diff --git a/ariadne/services/nextcloud_mail_models.py b/ariadne/services/nextcloud_mail_models.py new file mode 100644 index 0000000..1c9d931 --- /dev/null +++ b/ariadne/services/nextcloud_mail_models.py @@ -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 diff --git a/ariadne/services/nextcloud_maintenance.py b/ariadne/services/nextcloud_maintenance.py new file mode 100644 index 0000000..18e4252 --- /dev/null +++ b/ariadne/services/nextcloud_maintenance.py @@ -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"} diff --git a/ci/loc_hygiene_waivers.tsv b/ci/loc_hygiene_waivers.tsv index d73dd71..7f0e3f4 100644 --- a/ci/loc_hygiene_waivers.tsv +++ b/ci/loc_hygiene_waivers.tsv @@ -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/services/comms.py split planned; comms adapters still consolidated 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/services/jenkins_workspace_cleanup.py split planned; job orchestration pending extraction tests/test_provisioning.py test module split planned; broad provisioning coverage retained meanwhile