fix: harden nextcloud and wger provisioning
This commit is contained in:
parent
a2ae62bb23
commit
2777bbfc4b
@ -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)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user