From 2777bbfc4b67f182c193b9f879602a41d31a1326 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 21 Jan 2026 21:45:44 -0300 Subject: [PATCH] fix: harden nextcloud and wger provisioning --- ariadne/manager/provisioning.py | 12 ++- ariadne/services/nextcloud.py | 139 +++++++++++++++++++++++++------- ariadne/services/wger.py | 7 +- tests/test_nextcloud_sync.py | 70 ++++++++++++++++ 4 files changed, 194 insertions(+), 34 deletions(-) diff --git a/ariadne/manager/provisioning.py b/ariadne/manager/provisioning.py index 272e8d4..ec6835f 100644 --- a/ariadne/manager/provisioning.py +++ b/ariadne/manager/provisioning.py @@ -664,7 +664,13 @@ class ProvisioningManager: self._task_ok(conn, ctx.request_code, "nextcloud_mail_sync", None, start) return status_val = result.get("status") if isinstance(result, dict) else "error" - detail = str(status_val) + summary = result.get("summary") if isinstance(result, dict) else None + detail = "" + if summary is not None: + detail = getattr(summary, "detail", "") or "" + if not detail and isinstance(result, dict): + detail = str(result.get("detail") or "") + detail = detail or str(status_val) self._task_error(conn, ctx.request_code, "nextcloud_mail_sync", detail, start) except Exception as exc: detail = safe_error_detail(exc, "failed to sync nextcloud") @@ -686,7 +692,9 @@ class ProvisioningManager: result = wger.sync_user(ctx.username, ctx.mailu_email, wger_password, wait=True) status_val = result.get("status") if isinstance(result, dict) else "error" if status_val != "ok": - raise RuntimeError(f"wger sync {status_val}") + detail = result.get("detail") if isinstance(result, dict) else "" + detail = detail or f"wger sync {status_val}" + raise RuntimeError(detail) now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") keycloak_admin.set_user_attribute(ctx.username, WGER_PASSWORD_UPDATED_ATTR, now_iso) diff --git a/ariadne/services/nextcloud.py b/ariadne/services/nextcloud.py index a69c24b..996bd68 100644 --- a/ariadne/services/nextcloud.py +++ b/ariadne/services/nextcloud.py @@ -13,6 +13,7 @@ from ..k8s.exec import ExecError, PodExecutor from ..k8s.pods import PodSelectionError from ..settings import settings from ..utils.logging import get_logger +from ..utils.passwords import random_password from .keycloak_admin import keycloak_admin @@ -82,6 +83,7 @@ class MailSyncCounters: deleted: int = 0 skipped: int = 0 failures: int = 0 + last_error: str = "" def summary(self) -> NextcloudMailSyncSummary: return NextcloudMailSyncSummary( @@ -91,11 +93,17 @@ class MailSyncCounters: 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: def __init__(self) -> None: @@ -105,28 +113,88 @@ class NextcloudService: settings.nextcloud_container, ) - def _exec_with_fallback(self, primary: list[str], fallback: list[str]) -> ExecResult: + def _exec_with_fallback( + self, + primary: list[str], + fallback: list[str], + env: dict[str, str] | None = None, + check: bool = True, + ) -> ExecResult: try: - return self._executor.exec( + result = self._executor.exec( primary, + env=env, timeout_sec=settings.nextcloud_exec_timeout_sec, - check=True, + check=check, ) except ExecError as exc: if "runuser: may not be used by non-root users" not in str(exc): raise - return self._executor.exec( - fallback, - timeout_sec=settings.nextcloud_exec_timeout_sec, - check=True, - ) + return self._executor.exec( + fallback, + env=env, + timeout_sec=settings.nextcloud_exec_timeout_sec, + check=check, + ) - def _occ(self, args: list[str]) -> str: + if not result.ok and "runuser: may not be used by non-root users" in result.stderr: + return self._executor.exec( + fallback, + env=env, + timeout_sec=settings.nextcloud_exec_timeout_sec, + check=check, + ) + return result + + def _occ_exec( + self, + args: list[str], + env: dict[str, str] | None = None, + check: bool = True, + ) -> ExecResult: command = ["runuser", "-u", "www-data", "--", "php", "/var/www/html/occ", *args] fallback = ["php", "/var/www/html/occ", *args] - result = self._exec_with_fallback(command, fallback) + return self._exec_with_fallback(command, fallback, env=env, check=check) + + def _occ(self, args: list[str]) -> str: + 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, + mailu_email: str, + display_name: str, + ) -> None: + result = self._occ_exec(["user:info", username], check=False) + if result.ok: + return + + detail = f"{result.stdout}\n{result.stderr}".strip().lower() + missing_markers = ("not found", "not exist", "does not exist", "unknown user") + if detail and not any(marker in detail for marker in missing_markers): + raise RuntimeError(f"nextcloud user lookup failed: {detail[:200]}") + + password = random_password(24) + env = {"OC_PASS": password} + args = ["user:add", "--password-from-env"] + name = display_name or username + if name: + args += ["--display-name", name] + if mailu_email: + args += ["--email", mailu_email] + args.append(username) + self._occ_exec(args, env=env, check=True) + def run_cron(self) -> dict[str, Any]: if not settings.nextcloud_namespace: raise RuntimeError("nextcloud cron not configured") @@ -219,10 +287,11 @@ class NextcloudService: try: return self._list_mail_accounts(username) except Exception as exc: - counters.failures += 1 + detail = f"mail export failed: {exc}" + counters.record_failure(detail) logger.info( "nextcloud mail export failed", - extra={"event": "nextcloud_mail_export", "status": "error", "detail": str(exc)}, + extra={"event": "nextcloud_mail_export", "status": "error", "detail": detail}, ) return None @@ -249,7 +318,7 @@ class NextcloudService: primary_id: str, mailu_email: str, app_pw: str, - ) -> bool: + ) -> str | None: try: self._occ( [ @@ -284,11 +353,11 @@ class NextcloudService: "password", ] ) - return True - except Exception: - return False + return None + except Exception as exc: + return str(exc) - def _create_mail_account(self, username: str, mailu_email: str, app_pw: str) -> bool: + def _create_mail_account(self, username: str, mailu_email: str, app_pw: str) -> str | None: try: self._occ( [ @@ -310,9 +379,9 @@ class NextcloudService: "password", ] ) - return True - except Exception: - return False + return None + except Exception as exc: + return str(exc) def _delete_extra_accounts( self, @@ -327,8 +396,8 @@ class NextcloudService: try: self._occ(["mail:account:delete", "-q", account_id]) deleted += 1 - except Exception: - counters.failures += 1 + except Exception as exc: + counters.record_failure(f"mail account delete failed: {exc}") return deleted def _mailu_accounts(self, accounts: list[tuple[str, str]]) -> list[tuple[str, str]]: @@ -360,7 +429,7 @@ class NextcloudService: self, user: dict[str, Any], counters: MailSyncCounters, - ) -> tuple[str, str, str, str] | None: + ) -> tuple[str, str, str, str, dict[str, Any]] | None: normalized = self._normalize_user(user) if not normalized: counters.skipped += 1 @@ -377,7 +446,7 @@ class NextcloudService: keycloak_admin.set_user_attribute(username, "mailu_email", mailu_email) except Exception: pass - return username, user_id, mailu_email, app_pw + return username, user_id, mailu_email, app_pw, full_user def _sync_mail_accounts( self, @@ -390,14 +459,16 @@ class NextcloudService: mailu_accounts = self._mailu_accounts(accounts) if mailu_accounts: primary_id, _primary_email = self._select_primary_account(mailu_accounts, mailu_email) - if not self._update_mail_account(username, primary_id, mailu_email, app_pw): - counters.failures += 1 + error = self._update_mail_account(username, primary_id, mailu_email, app_pw) + if error: + counters.record_failure(f"mail account update failed: {error}") return False counters.updated += 1 counters.deleted += self._delete_extra_accounts(mailu_accounts, primary_id, counters) else: - if not self._create_mail_account(username, mailu_email, app_pw): - counters.failures += 1 + error = self._create_mail_account(username, mailu_email, app_pw) + if error: + counters.record_failure(f"mail account create failed: {error}") return False counters.created += 1 return True @@ -417,7 +488,14 @@ class NextcloudService: context = self._mail_sync_context(user, counters) if not context: return - username, user_id, mailu_email, app_pw = context + username, user_id, mailu_email, app_pw, full_user = context + + try: + display_name = self._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}") + return accounts = self._list_mail_accounts_safe(username, counters) if accounts is None: @@ -465,10 +543,11 @@ class NextcloudService: "deleted_count": counters.deleted, "skipped_count": counters.skipped, "failures_count": counters.failures, + "detail": summary.detail, }, ) - return {"status": counters.status(), "summary": summary} + return {"status": counters.status(), "summary": summary, "detail": summary.detail} def _run_shell(self, script: str, check: bool = True) -> None: self._executor.exec( diff --git a/ariadne/services/wger.py b/ariadne/services/wger.py index 5e29056..50717e4 100644 --- a/ariadne/services/wger.py +++ b/ariadne/services/wger.py @@ -144,7 +144,8 @@ _WGER_SYNC_SCRIPT = textwrap.dedent( def _wger_exec_command() -> str: - return f"python3 - <<'PY'\n{_WGER_SYNC_SCRIPT}\nPY" + bootstrap = "if [ -f /vault/secrets/wger-env ]; then . /vault/secrets/wger-env; fi" + return f"{bootstrap}\npython3 - <<'PY'\n{_WGER_SYNC_SCRIPT}\nPY" @dataclass(frozen=True) @@ -419,7 +420,9 @@ class WgerService: result = self.sync_user(prepared.username, prepared.mailu_email, prepared.password, wait=True) result_status = result.get("status") if isinstance(result, dict) else "error" if result_status != "ok": - return UserSyncOutcome("failed", f"sync {result_status}") + detail = result.get("detail") if isinstance(result, dict) else "" + detail = detail or f"sync {result_status}" + return UserSyncOutcome("failed", detail) if not _set_wger_updated_at(prepared.username): return UserSyncOutcome("failed", "failed to set updated_at") diff --git a/tests/test_nextcloud_sync.py b/tests/test_nextcloud_sync.py index e0f06ee..4adad04 100644 --- a/tests/test_nextcloud_sync.py +++ b/tests/test_nextcloud_sync.py @@ -2,6 +2,8 @@ from __future__ import annotations import types +import pytest + from ariadne.k8s.exec import ExecError from ariadne.services import nextcloud as nextcloud_module from ariadne.services.nextcloud import NextcloudService, _parse_mail_export @@ -62,6 +64,7 @@ def test_nextcloud_sync_mail_create(monkeypatch) -> None: svc = NextcloudService() monkeypatch.setattr(svc, "_occ", fake_occ) + monkeypatch.setattr(svc, "_ensure_nextcloud_user", lambda *_args, **_kwargs: None) monkeypatch.setattr(svc, "_list_mail_accounts", lambda *_args, **_kwargs: list_calls.pop(0)) monkeypatch.setattr(svc, "_set_editor_mode_richtext", lambda *_args, **_kwargs: None) monkeypatch.setattr(svc, "_set_user_mail_meta", lambda *_args, **_kwargs: None) @@ -142,6 +145,7 @@ def test_nextcloud_sync_mail_update_and_delete(monkeypatch) -> None: svc = NextcloudService() monkeypatch.setattr(svc, "_occ", fake_occ) + monkeypatch.setattr(svc, "_ensure_nextcloud_user", lambda *_args, **_kwargs: None) monkeypatch.setattr(svc, "_list_mail_accounts", lambda *_args, **_kwargs: list_calls.pop(0)) monkeypatch.setattr(svc, "_set_editor_mode_richtext", lambda *_args, **_kwargs: None) monkeypatch.setattr(svc, "_set_user_mail_meta", lambda *_args, **_kwargs: None) @@ -227,6 +231,72 @@ def test_nextcloud_occ_fallback(monkeypatch) -> None: assert executor.calls[1][0] == "php" +def test_nextcloud_ensure_user_created(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + nextcloud_mail_sync_wait_timeout_sec=90.0, + nextcloud_mail_sync_job_ttl_sec=3600, + nextcloud_pod_label="app=nextcloud", + nextcloud_container="nextcloud", + nextcloud_exec_timeout_sec=30.0, + nextcloud_db_host="", + nextcloud_db_port=5432, + nextcloud_db_name="nextcloud", + nextcloud_db_user="nextcloud", + nextcloud_db_password="", + mailu_domain="bstein.dev", + mailu_host="mail.bstein.dev", + ) + monkeypatch.setattr(nextcloud_module, "settings", dummy_settings) + monkeypatch.setattr(nextcloud_module, "random_password", lambda *_args, **_kwargs: "pw") + + svc = NextcloudService() + calls: list[tuple[list[str], dict[str, str] | None, bool]] = [] + + def fake_occ_exec(args, env=None, check=True): + calls.append((args, env, check)) + if args[0] == "user:info": + return types.SimpleNamespace(stdout="User does not exist", stderr="", exit_code=1, ok=False) + return types.SimpleNamespace(stdout="created", stderr="", exit_code=0, ok=True) + + monkeypatch.setattr(svc, "_occ_exec", fake_occ_exec) + + svc._ensure_nextcloud_user("alice", "alice@bstein.dev", "Alice") + assert any(call[0][0] == "user:add" for call in calls) + assert calls[-1][1]["OC_PASS"] == "pw" + + +def test_nextcloud_ensure_user_lookup_error(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + nextcloud_mail_sync_wait_timeout_sec=90.0, + nextcloud_mail_sync_job_ttl_sec=3600, + nextcloud_pod_label="app=nextcloud", + nextcloud_container="nextcloud", + nextcloud_exec_timeout_sec=30.0, + nextcloud_db_host="", + nextcloud_db_port=5432, + nextcloud_db_name="nextcloud", + nextcloud_db_user="nextcloud", + nextcloud_db_password="", + mailu_domain="bstein.dev", + mailu_host="mail.bstein.dev", + ) + monkeypatch.setattr(nextcloud_module, "settings", dummy_settings) + + svc = NextcloudService() + + def fake_occ_exec(args, env=None, check=True): + return types.SimpleNamespace(stdout="permission denied", stderr="", exit_code=1, ok=False) + + monkeypatch.setattr(svc, "_occ_exec", fake_occ_exec) + + with pytest.raises(RuntimeError): + svc._ensure_nextcloud_user("alice", "alice@bstein.dev", "Alice") + + def test_nextcloud_run_maintenance(monkeypatch) -> None: dummy_settings = types.SimpleNamespace( nextcloud_namespace="nextcloud",