fix: harden nextcloud and wger provisioning

This commit is contained in:
Brad Stein 2026-01-21 21:45:44 -03:00
parent a2ae62bb23
commit 2777bbfc4b
4 changed files with 194 additions and 34 deletions

View File

@ -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)

View File

@ -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,
env=env,
timeout_sec=settings.nextcloud_exec_timeout_sec,
check=True,
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(

View File

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

View File

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