from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from typing import Any import textwrap 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 from .mailu import mailu EXIT_PASSWORD_MATCH = 0 EXIT_PASSWORD_MISMATCH = 1 EXIT_USER_MISSING = 3 WGER_PASSWORD_ATTR = "wger_password" WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at" WGER_PASSWORD_ROTATED_ATTR = "wger_password_rotated_at" logger = get_logger(__name__) _WGER_SYNC_SCRIPT = textwrap.dedent( """ from __future__ import annotations import os import sys import django def _env(name: str, default: str = "") -> str: value = os.getenv(name, default) return value.strip() if isinstance(value, str) else "" def _setup_django() -> None: os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.main") django.setup() def _set_default_gym(user) -> None: try: from wger.gym.models import GymConfig except Exception: return try: config = GymConfig.objects.first() except Exception: return if not config or not getattr(config, "default_gym", None): return profile = getattr(user, "userprofile", None) if not profile or getattr(profile, "gym", None): return profile.gym = config.default_gym profile.save() def _ensure_profile(user) -> None: profile = getattr(user, "userprofile", None) if not profile: return if hasattr(profile, "email_verified") and not profile.email_verified: profile.email_verified = True if hasattr(profile, "is_temporary") and profile.is_temporary: profile.is_temporary = False profile.save() def _ensure_admin(username: str, password: str, email: str) -> None: from django.contrib.auth.models import User if not username or not password: raise RuntimeError("admin username/password missing") user, created = User.objects.get_or_create(username=username) if created: user.is_active = True if not user.is_staff: user.is_staff = True if email: user.email = email user.set_password(password) user.save() _ensure_profile(user) _set_default_gym(user) print(f"ensured admin user {username}") def _ensure_user(username: str, password: str, email: str) -> None: from django.contrib.auth.models import User if not username or not password: raise RuntimeError("username/password missing") user, created = User.objects.get_or_create(username=username) if created: user.is_active = True if email and user.email != email: user.email = email user.set_password(password) user.save() _ensure_profile(user) _set_default_gym(user) action = "created" if created else "updated" print(f"{action} user {username}") def main() -> int: admin_user = _env("WGER_ADMIN_USERNAME") admin_password = _env("WGER_ADMIN_PASSWORD") admin_email = _env("WGER_ADMIN_EMAIL") username = _env("WGER_USERNAME") or _env("ONLY_USERNAME") password = _env("WGER_PASSWORD") email = _env("WGER_EMAIL") if not any([admin_user and admin_password, username and password]): print("no admin or user payload provided; exiting") return 0 _setup_django() if admin_user and admin_password: _ensure_admin(admin_user, admin_password, admin_email) if username and password: _ensure_user(username, password, email) return 0 if __name__ == "__main__": sys.exit(main()) """ ).strip() _WGER_PASSWORD_CHECK_SCRIPT = textwrap.dedent( """ from __future__ import annotations import os import sys import django def _env(name: str, default: str = "") -> str: value = os.getenv(name, default) return value.strip() if isinstance(value, str) else "" def _setup_django() -> None: os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.main") django.setup() def main() -> int: username = _env("WGER_USERNAME") password = _env("WGER_PASSWORD") if not username or not password: print("missing username or password") return 2 _setup_django() from django.contrib.auth.models import User user = User.objects.filter(username=username).first() if not user: print(f"user {username} missing") return 3 if user.check_password(password): print("password match") return 0 print("password mismatch") return 1 if __name__ == "__main__": sys.exit(main()) """ ).strip() def _wger_exec_command() -> str: bootstrap = ". /vault/secrets/wger-env >/dev/null 2>&1 || true" return f"{bootstrap}\npython3 - <<'PY'\n{_WGER_SYNC_SCRIPT}\nPY" def _wger_check_command() -> str: bootstrap = ". /vault/secrets/wger-env >/dev/null 2>&1 || true" return f"{bootstrap}\npython3 - <<'PY'\n{_WGER_PASSWORD_CHECK_SCRIPT}\nPY" @dataclass(frozen=True) class WgerSyncSummary: processed: int synced: int skipped: int failures: int detail: str = "" @dataclass class WgerSyncCounters: processed: int = 0 synced: int = 0 skipped: int = 0 failures: int = 0 def status(self) -> str: return "ok" if self.failures == 0 else "error" def summary(self, detail: str = "") -> WgerSyncSummary: return WgerSyncSummary( processed=self.processed, synced=self.synced, skipped=self.skipped, failures=self.failures, detail=detail, ) @dataclass(frozen=True) class UserSyncOutcome: status: str detail: str = "" @dataclass(frozen=True) class UserIdentity: username: str user_id: str @dataclass(frozen=True) class WgerSyncInput: username: str mailu_email: str password: str password_generated: bool updated_at: str rotated_at: str 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 _should_skip_user(user: dict[str, Any], username: str) -> bool: if not username or user.get("enabled") is False: return True if user.get("serviceAccountClientId") or username.startswith("service-account-"): return True return False def _load_attrs(user_id: str, user: dict[str, Any]) -> dict[str, Any] | None: attrs = user.get("attributes") if isinstance(attrs, dict): return attrs try: full = keycloak_admin.get_user(user_id) except Exception: return None attrs = full.get("attributes") if isinstance(full, dict) else {} return attrs if isinstance(attrs, dict) else {} def _ensure_mailu_email(username: str, attrs: dict[str, Any], fallback_email: str) -> str | None: mailu_email = mailu.resolve_mailu_email(username, attrs, fallback_email) if not mailu_email: return None if _extract_attr(attrs, "mailu_email"): return mailu_email try: keycloak_admin.set_user_attribute(username, "mailu_email", mailu_email) except Exception: return None return mailu_email def _ensure_wger_password(username: str, attrs: dict[str, Any]) -> tuple[str | None, bool]: password = _extract_attr(attrs, WGER_PASSWORD_ATTR) if password: return password, False password = random_password(20) try: keycloak_admin.set_user_attribute(username, WGER_PASSWORD_ATTR, password) except Exception: return None, False return password, True def _set_wger_updated_at(username: str) -> bool: now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") try: keycloak_admin.set_user_attribute(username, WGER_PASSWORD_UPDATED_ATTR, now_iso) except Exception: return False return True def _set_wger_rotated_at(username: str) -> bool: now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") try: keycloak_admin.set_user_attribute(username, WGER_PASSWORD_ROTATED_ATTR, now_iso) except Exception: return False return True def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]: username = user.get("username") if isinstance(user.get("username"), str) else "" if _should_skip_user(user, username): return UserSyncOutcome("skipped"), None user_id = user.get("id") if isinstance(user.get("id"), str) else "" if not user_id: return UserSyncOutcome("failed", "missing user id"), None return None, UserIdentity(username, user_id) def _load_attrs_or_outcome( identity: UserIdentity, user: dict[str, Any], ) -> tuple[dict[str, Any] | None, UserSyncOutcome | None]: attrs = _load_attrs(identity.user_id, user) if attrs is None: return None, UserSyncOutcome("failed", "missing attributes") return attrs, None def _mailu_email_or_outcome( identity: UserIdentity, attrs: dict[str, Any], user: dict[str, Any], ) -> tuple[str | None, UserSyncOutcome | None]: mailu_email = _ensure_mailu_email( identity.username, attrs, user.get("email") if isinstance(user.get("email"), str) else "", ) if not mailu_email: return None, UserSyncOutcome("failed", "missing mailu email") return mailu_email, None def _wger_password_or_outcome( identity: UserIdentity, attrs: dict[str, Any], ) -> tuple[str | None, bool, UserSyncOutcome | None]: password, generated = _ensure_wger_password(identity.username, attrs) if not password: return None, generated, UserSyncOutcome("failed", "missing wger password") return password, generated, None def _should_skip_sync(password_generated: bool, updated_at: str) -> bool: return not password_generated and bool(updated_at) def _build_sync_input(user: dict[str, Any]) -> WgerSyncInput | UserSyncOutcome: outcome, identity = _normalize_user(user) attrs = None mailu_email = None password = None password_generated = False if outcome is None and identity is not None: attrs, outcome = _load_attrs_or_outcome(identity, user) if outcome is None and attrs is not None: mailu_email, outcome = _mailu_email_or_outcome(identity, attrs, user) if outcome is None and mailu_email: password, password_generated, outcome = _wger_password_or_outcome(identity, attrs) if outcome: return outcome if identity is None: return UserSyncOutcome("failed", "missing identity") if attrs is None: return UserSyncOutcome("failed", "missing attributes") if not mailu_email: return UserSyncOutcome("failed", "missing mailu email") if not password: return UserSyncOutcome("failed", "missing wger password") updated_at = _extract_attr(attrs, WGER_PASSWORD_UPDATED_ATTR) rotated_at = _extract_attr(attrs, WGER_PASSWORD_ROTATED_ATTR) return WgerSyncInput( username=identity.username, mailu_email=mailu_email, password=password, password_generated=password_generated, updated_at=updated_at, rotated_at=rotated_at, ) def _rotation_result(status: str, detail: str = "", rotated: bool | None = None) -> dict[str, Any]: result = {"status": status} if status == "ok": result["rotated"] = bool(rotated) elif detail: result["detail"] = detail return result def _rotation_check_input(username: str) -> tuple[WgerSyncInput | UserSyncOutcome | None, str]: if not username: return None, "missing username" if not keycloak_admin.ready(): return None, "keycloak admin not configured" user = keycloak_admin.find_user(username) if not isinstance(user, dict): return None, "user not found" user_id = user.get("id") if isinstance(user.get("id"), str) else "" if not user_id: return None, "missing user id" full = keycloak_admin.get_user(user_id) return _build_sync_input(full), "" class WgerService: def __init__(self) -> None: self._executor = PodExecutor( settings.wger_namespace, settings.wger_pod_label, settings.wger_container, ) def check_rotation_for_user(self, username: str) -> dict[str, Any]: cleaned = (username or "").strip() prepared, error = _rotation_check_input(cleaned) if error: return _rotation_result("error", error) if isinstance(prepared, UserSyncOutcome): if prepared.status == "skipped": return _rotation_result("ok", rotated=False) return _rotation_result("error", prepared.detail or "") outcome = self._rotation_outcome(prepared) if outcome.status == "synced": return _rotation_result("ok", rotated=True) if outcome.status == "skipped": return _rotation_result("ok", rotated=False) return _rotation_result("error", outcome.detail or "rotation check failed") def sync_user(self, username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]: username = (username or "").strip() if not username: raise RuntimeError("missing username") if not password: raise RuntimeError("missing password") if not settings.wger_namespace: raise RuntimeError("wger sync not configured") env = { "WGER_USERNAME": username, "WGER_EMAIL": email, "WGER_PASSWORD": password, } try: result = self._executor.exec( _wger_exec_command(), env=env, timeout_sec=settings.wger_user_sync_wait_timeout_sec, check=True, ) except (ExecError, PodSelectionError, TimeoutError) as exc: return {"status": "error", "detail": str(exc)} output = (result.stdout or result.stderr).strip() return {"status": "ok", "detail": output} def check_password(self, username: str, password: str) -> dict[str, Any]: username = (username or "").strip() if not username: raise RuntimeError("missing username") if not password: raise RuntimeError("missing password") if not settings.wger_namespace: raise RuntimeError("wger sync not configured") env = { "WGER_USERNAME": username, "WGER_PASSWORD": password, } try: result = self._executor.exec( _wger_check_command(), env=env, timeout_sec=settings.wger_user_sync_wait_timeout_sec, check=False, ) except (ExecError, PodSelectionError, TimeoutError) as exc: return {"status": "error", "detail": str(exc)} detail = (result.stdout or result.stderr).strip() if result.exit_code == EXIT_PASSWORD_MATCH: return {"status": "match", "detail": detail} if result.exit_code == EXIT_PASSWORD_MISMATCH: return {"status": "mismatch", "detail": detail} if result.exit_code == EXIT_USER_MISSING: return {"status": "missing", "detail": detail or "user missing"} return {"status": "error", "detail": detail or f"exit_code={result.exit_code}"} def _rotation_outcome(self, prepared: WgerSyncInput) -> UserSyncOutcome: if prepared.rotated_at: return UserSyncOutcome("skipped") check = self.check_password(prepared.username, prepared.password) status = check.get("status") if isinstance(check, dict) else "error" if status == "match": return UserSyncOutcome("skipped") if status == "mismatch": if not _set_wger_rotated_at(prepared.username): return UserSyncOutcome("failed", "failed to set rotated_at") return UserSyncOutcome("synced") detail = check.get("detail") if isinstance(check, dict) else "" detail = detail or "password check failed" return UserSyncOutcome("failed", detail) def ensure_admin(self, wait: bool = False) -> dict[str, Any]: if not settings.wger_namespace: raise RuntimeError("wger admin sync not configured") if not settings.wger_admin_username or not settings.wger_admin_password: return {"status": "error", "detail": "admin credentials missing"} env = { "WGER_ADMIN_USERNAME": settings.wger_admin_username, "WGER_ADMIN_PASSWORD": settings.wger_admin_password, "WGER_ADMIN_EMAIL": settings.wger_admin_email, } try: result = self._executor.exec( _wger_exec_command(), env=env, timeout_sec=settings.wger_user_sync_wait_timeout_sec, check=True, ) except (ExecError, PodSelectionError, TimeoutError) as exc: return {"status": "error", "detail": str(exc)} output = (result.stdout or result.stderr).strip() return {"status": "ok", "detail": output} def _sync_user_entry(self, user: dict[str, Any]) -> UserSyncOutcome: prepared = _build_sync_input(user) if isinstance(prepared, UserSyncOutcome): return prepared if _should_skip_sync(prepared.password_generated, prepared.updated_at): return self._rotation_outcome(prepared) 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": 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") return UserSyncOutcome("synced") def sync_users(self) -> dict[str, Any]: if not keycloak_admin.ready(): return {"status": "error", "detail": "keycloak admin not configured"} if not settings.wger_namespace: raise RuntimeError("wger sync not configured") counters = WgerSyncCounters() users = keycloak_admin.iter_users(page_size=200, brief=False) for user in users: outcome = self._sync_user_entry(user) if outcome.status == "synced": counters.processed += 1 counters.synced += 1 elif outcome.status == "skipped": counters.skipped += 1 else: counters.failures += 1 summary = counters.summary() logger.info( "wger user sync finished", extra={ "event": "wger_user_sync", "status": counters.status(), "processed": summary.processed, "synced": summary.synced, "skipped": summary.skipped, "failures": summary.failures, }, ) return {"status": counters.status(), "summary": summary} wger = WgerService()