docs(bstein-home): document full source gate surface

This commit is contained in:
codex 2026-04-21 07:25:40 -03:00
parent 839c2586a2
commit 2f703005fc
26 changed files with 338 additions and 9 deletions

View File

@ -13,12 +13,16 @@ logger = logging.getLogger(__name__)
class AriadneError(Exception): class AriadneError(Exception):
"""Carry an upstream-facing error message and HTTP status code."""
def __init__(self, message: str, status_code: int = 502) -> None: def __init__(self, message: str, status_code: int = 502) -> None:
super().__init__(message) super().__init__(message)
self.status_code = status_code self.status_code = status_code
def enabled() -> bool: def enabled() -> bool:
"""Return whether Ariadne proxying is configured for this portal."""
return bool(settings.ARIADNE_URL) return bool(settings.ARIADNE_URL)
@ -40,6 +44,12 @@ def request_raw(
payload: Any | None = None, payload: Any | None = None,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> httpx.Response: ) -> httpx.Response:
"""Send one authenticated request to Ariadne and return the raw response.
WHY: callers need the exact upstream status/body so local routes can act as
a transparent compatibility proxy while still applying retry telemetry.
"""
if not enabled(): if not enabled():
raise AriadneError("ariadne not configured", 503) raise AriadneError("ariadne not configured", 503)
@ -84,6 +94,12 @@ def proxy(
payload: Any | None = None, payload: Any | None = None,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> tuple[Any, int]: ) -> tuple[Any, int]:
"""Proxy an Ariadne response through Flask as JSON plus status code.
WHY: route handlers should share upstream error normalization instead of
duplicating JSON parsing and outage handling at each call site.
"""
try: try:
resp = request_raw(method, path, payload=payload, params=params) resp = request_raw(method, path, payload=payload, params=params)
except AriadneError as exc: except AriadneError as exc:

View File

@ -15,10 +15,14 @@ _pool: ConnectionPool | None = None
def configured() -> bool: def configured() -> bool:
"""Return whether the portal has enough database configuration to connect."""
return bool(settings.PORTAL_DATABASE_URL) return bool(settings.PORTAL_DATABASE_URL)
def _pool_kwargs() -> dict[str, Any]: def _pool_kwargs() -> dict[str, Any]:
"""Build shared psycopg pool options for Atlas portal connections."""
options = ( options = (
f"-c lock_timeout={settings.PORTAL_DB_LOCK_TIMEOUT_SEC}s " f"-c lock_timeout={settings.PORTAL_DB_LOCK_TIMEOUT_SEC}s "
f"-c statement_timeout={settings.PORTAL_DB_STATEMENT_TIMEOUT_SEC}s " f"-c statement_timeout={settings.PORTAL_DB_STATEMENT_TIMEOUT_SEC}s "
@ -33,6 +37,8 @@ def _pool_kwargs() -> dict[str, Any]:
def _get_pool() -> ConnectionPool: def _get_pool() -> ConnectionPool:
"""Return the singleton Postgres connection pool for request handlers."""
global _pool global _pool
if _pool is None: if _pool is None:
if not settings.PORTAL_DATABASE_URL: if not settings.PORTAL_DATABASE_URL:
@ -48,6 +54,8 @@ def _get_pool() -> ConnectionPool:
@contextmanager @contextmanager
def connect() -> Iterator[psycopg.Connection[Any]]: def connect() -> Iterator[psycopg.Connection[Any]]:
"""Yield a dict-row Postgres connection from the shared pool."""
if not settings.PORTAL_DATABASE_URL: if not settings.PORTAL_DATABASE_URL:
raise RuntimeError("portal database not configured") raise RuntimeError("portal database not configured")
with _get_pool().connection() as conn: with _get_pool().connection() as conn:
@ -70,6 +78,12 @@ def _release_advisory_lock(conn: psycopg.Connection[Any], lock_id: int) -> None:
def run_migrations() -> None: def run_migrations() -> None:
"""Create and upgrade the portal schema using an advisory lock.
WHY: every replica may start concurrently, so schema changes must be safe
to run repeatedly without allowing multiple pods to migrate at once.
"""
if not settings.PORTAL_DATABASE_URL or not settings.PORTAL_RUN_MIGRATIONS: if not settings.PORTAL_DATABASE_URL or not settings.PORTAL_RUN_MIGRATIONS:
return return
with connect() as conn: with connect() as conn:
@ -193,4 +207,6 @@ def run_migrations() -> None:
def ensure_schema() -> None: def ensure_schema() -> None:
"""Run startup migrations through a small semantic wrapper."""
run_migrations() run_migrations()

View File

@ -21,6 +21,8 @@ def _job_from_cronjob(
email: str, email: str,
password: str, password: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Render a one-off Firefly user sync Job from the managed CronJob template."""
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {} spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {} jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {} job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
@ -70,6 +72,8 @@ def _job_from_cronjob(
def _job_succeeded(job: dict[str, Any]) -> bool: def _job_succeeded(job: dict[str, Any]) -> bool:
"""Return whether Kubernetes reports the sync Job as successfully complete."""
status = job.get("status") if isinstance(job.get("status"), dict) else {} status = job.get("status") if isinstance(job.get("status"), dict) else {}
if int(status.get("succeeded") or 0) > 0: if int(status.get("succeeded") or 0) > 0:
return True return True
@ -83,6 +87,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool:
def _job_failed(job: dict[str, Any]) -> bool: def _job_failed(job: dict[str, Any]) -> bool:
"""Return whether Kubernetes reports the sync Job as failed."""
status = job.get("status") if isinstance(job.get("status"), dict) else {} status = job.get("status") if isinstance(job.get("status"), dict) else {}
if int(status.get("failed") or 0) > 0: if int(status.get("failed") or 0) > 0:
return True return True
@ -96,6 +102,12 @@ def _job_failed(job: dict[str, Any]) -> bool:
def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]: def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]:
"""Start the Firefly sync Job for one user and optionally wait for completion.
WHY: account self-service should be able to repair one user without waiting
for the broader scheduled reconciliation loop.
"""
username = (username or "").strip() username = (username or "").strip()
if not username: if not username:
raise RuntimeError("missing username") raise RuntimeError("missing username")

View File

@ -24,6 +24,8 @@ def _read_service_account() -> tuple[str, str]:
def get_json(path: str) -> dict[str, Any]: def get_json(path: str) -> dict[str, Any]:
"""Fetch a Kubernetes API object as JSON using the pod service account."""
token, ca_path = _read_service_account() token, ca_path = _read_service_account()
url = f"{_K8S_BASE_URL}{path}" url = f"{_K8S_BASE_URL}{path}"
with httpx.Client( with httpx.Client(
@ -40,6 +42,8 @@ def get_json(path: str) -> dict[str, Any]:
def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]: def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Post a JSON payload to Kubernetes using the pod service account."""
token, ca_path = _read_service_account() token, ca_path = _read_service_account()
url = f"{_K8S_BASE_URL}{path}" url = f"{_K8S_BASE_URL}{path}"
with httpx.Client( with httpx.Client(
@ -53,4 +57,3 @@ def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(data, dict): if not isinstance(data, dict):
raise RuntimeError("unexpected kubernetes response") raise RuntimeError("unexpected kubernetes response")
return data return data

View File

@ -14,6 +14,8 @@ from . import settings
class KeycloakOIDC: class KeycloakOIDC:
"""Verify user-facing Keycloak tokens for portal requests."""
def __init__(self) -> None: def __init__(self) -> None:
self._jwk_client: PyJWKClient | None = None self._jwk_client: PyJWKClient | None = None
@ -23,6 +25,12 @@ class KeycloakOIDC:
return self._jwk_client return self._jwk_client
def verify(self, token: str) -> dict[str, Any]: def verify(self, token: str) -> dict[str, Any]:
"""Validate a bearer token and return decoded claims.
WHY: the portal trusts Keycloak groups and usernames only after issuer
and client ownership are checked locally.
"""
if not settings.KEYCLOAK_ENABLED: if not settings.KEYCLOAK_ENABLED:
raise ValueError("keycloak not enabled") raise ValueError("keycloak not enabled")
@ -50,6 +58,8 @@ class KeycloakOIDC:
class KeycloakAdminClient: class KeycloakAdminClient:
"""Perform service-account Keycloak admin operations for provisioning."""
def __init__(self) -> None: def __init__(self) -> None:
self._token: str = "" self._token: str = ""
self._expires_at: float = 0.0 self._expires_at: float = 0.0
@ -57,6 +67,12 @@ class KeycloakAdminClient:
@staticmethod @staticmethod
def _safe_update_payload(full: dict[str, Any]) -> dict[str, Any]: def _safe_update_payload(full: dict[str, Any]) -> dict[str, Any]:
"""Extract mutable fields from a full Keycloak user document.
WHY: partial updates can accidentally clear profile or attribute data,
so callers merge desired changes into a safe copy first.
"""
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
username = full.get("username") username = full.get("username")
if isinstance(username, str): if isinstance(username, str):
@ -86,9 +102,13 @@ class KeycloakAdminClient:
return payload return payload
def ready(self) -> bool: def ready(self) -> bool:
"""Return whether admin-client credentials are available."""
return bool(settings.KEYCLOAK_ADMIN_CLIENT_ID and settings.KEYCLOAK_ADMIN_CLIENT_SECRET) return bool(settings.KEYCLOAK_ADMIN_CLIENT_ID and settings.KEYCLOAK_ADMIN_CLIENT_SECRET)
def _get_token(self) -> str: def _get_token(self) -> str:
"""Return a cached service-account token, refreshing before expiry."""
if not self.ready(): if not self.ready():
raise RuntimeError("keycloak admin client not configured") raise RuntimeError("keycloak admin client not configured")
@ -120,9 +140,13 @@ class KeycloakAdminClient:
return {"Authorization": f"Bearer {self._get_token()}"} return {"Authorization": f"Bearer {self._get_token()}"}
def headers(self) -> dict[str, str]: def headers(self) -> dict[str, str]:
"""Return authorization headers for callers that need raw admin access."""
return self._headers() return self._headers()
def find_user(self, username: str) -> dict[str, Any] | None: def find_user(self, username: str) -> dict[str, Any] | None:
"""Look up one Keycloak user by exact username."""
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users" url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
# Keycloak 26.x in our environment intermittently 400s on filtered user queries unless `max` is set. # Keycloak 26.x in our environment intermittently 400s on filtered user queries unless `max` is set.
# Use `max=1` and exact username match to keep admin calls reliable for portal provisioning. # Use `max=1` and exact username match to keep admin calls reliable for portal provisioning.
@ -137,6 +161,8 @@ class KeycloakAdminClient:
return user if isinstance(user, dict) else None return user if isinstance(user, dict) else None
def find_user_by_email(self, email: str) -> dict[str, Any] | None: def find_user_by_email(self, email: str) -> dict[str, Any] | None:
"""Look up one Keycloak user by exact email address."""
email = (email or "").strip() email = (email or "").strip()
if not email: if not email:
return None return None
@ -161,6 +187,8 @@ class KeycloakAdminClient:
return None return None
def get_user(self, user_id: str) -> dict[str, Any]: def get_user(self, user_id: str) -> dict[str, Any]:
"""Fetch a full Keycloak user representation by ID."""
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, headers=self._headers()) resp = client.get(url, headers=self._headers())
@ -171,12 +199,16 @@ class KeycloakAdminClient:
return data return data
def update_user(self, user_id: str, payload: dict[str, Any]) -> None: def update_user(self, user_id: str, payload: dict[str, Any]) -> None:
"""Replace a Keycloak user document with the supplied payload."""
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload) resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status() resp.raise_for_status()
def update_user_safe(self, user_id: str, payload: dict[str, Any]) -> None: def update_user_safe(self, user_id: str, payload: dict[str, Any]) -> None:
"""Merge selected user changes into the current Keycloak document."""
full = self.get_user(user_id) full = self.get_user(user_id)
merged = self._safe_update_payload(full) merged = self._safe_update_payload(full)
for key, value in payload.items(): for key, value in payload.items():
@ -192,6 +224,8 @@ class KeycloakAdminClient:
self.update_user(user_id, merged) self.update_user(user_id, merged)
def create_user(self, payload: dict[str, Any]) -> str: def create_user(self, payload: dict[str, Any]) -> str:
"""Create a Keycloak user and return the generated user ID."""
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users" url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.post(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload) resp = client.post(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
@ -202,6 +236,8 @@ class KeycloakAdminClient:
raise RuntimeError("failed to determine created user id") raise RuntimeError("failed to determine created user id")
def reset_password(self, user_id: str, password: str, temporary: bool = True) -> None: def reset_password(self, user_id: str, password: str, temporary: bool = True) -> None:
"""Set a Keycloak password credential for a user."""
url = ( url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
f"/users/{quote(user_id, safe='')}/reset-password" f"/users/{quote(user_id, safe='')}/reset-password"
@ -212,6 +248,8 @@ class KeycloakAdminClient:
resp.raise_for_status() resp.raise_for_status()
def set_user_attribute(self, username: str, key: str, value: str) -> None: def set_user_attribute(self, username: str, key: str, value: str) -> None:
"""Set one single-value Keycloak user attribute by username."""
user = self.find_user(username) user = self.find_user(username)
if not user: if not user:
raise RuntimeError("user not found") raise RuntimeError("user not found")
@ -230,6 +268,8 @@ class KeycloakAdminClient:
self.update_user(user_id, payload) self.update_user(user_id, payload)
def get_group_id(self, group_name: str) -> str | None: def get_group_id(self, group_name: str) -> str | None:
"""Resolve and cache the Keycloak ID for a group name."""
cached = self._group_id_cache.get(group_name) cached = self._group_id_cache.get(group_name)
if cached: if cached:
return cached return cached
@ -252,6 +292,8 @@ class KeycloakAdminClient:
return None return None
def list_group_names(self) -> list[str]: def list_group_names(self) -> list[str]:
"""Return all Keycloak group names visible to the admin client."""
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/groups" url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/groups"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, headers=self._headers()) resp = client.get(url, headers=self._headers())
@ -263,6 +305,8 @@ class KeycloakAdminClient:
names: set[str] = set() names: set[str] = set()
def walk(groups: list[Any]) -> None: def walk(groups: list[Any]) -> None:
"""Visit nested Keycloak group records and collect names."""
for group in groups: for group in groups:
if not isinstance(group, dict): if not isinstance(group, dict):
continue continue
@ -277,6 +321,8 @@ class KeycloakAdminClient:
return sorted(names) return sorted(names)
def list_user_groups(self, user_id: str) -> list[str]: def list_user_groups(self, user_id: str) -> list[str]:
"""Return group names assigned to one Keycloak user."""
url = ( url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
f"/users/{quote(user_id, safe='')}/groups" f"/users/{quote(user_id, safe='')}/groups"
@ -297,6 +343,8 @@ class KeycloakAdminClient:
return names return names
def add_user_to_group(self, user_id: str, group_id: str) -> None: def add_user_to_group(self, user_id: str, group_id: str) -> None:
"""Attach one Keycloak user to one group by ID."""
url = ( url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
f"/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}" f"/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}"
@ -306,6 +354,8 @@ class KeycloakAdminClient:
resp.raise_for_status() resp.raise_for_status()
def execute_actions_email(self, user_id: str, actions: list[str], redirect_uri: str) -> None: def execute_actions_email(self, user_id: str, actions: list[str], redirect_uri: str) -> None:
"""Ask Keycloak to email required-account-action links to a user."""
url = ( url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
f"/users/{quote(user_id, safe='')}/execute-actions-email" f"/users/{quote(user_id, safe='')}/execute-actions-email"
@ -321,6 +371,8 @@ class KeycloakAdminClient:
resp.raise_for_status() resp.raise_for_status()
def get_user_credentials(self, user_id: str) -> list[dict[str, Any]]: def get_user_credentials(self, user_id: str) -> list[dict[str, Any]]:
"""Return credential metadata for one Keycloak user."""
url = ( url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
f"/users/{quote(user_id, safe='')}/credentials" f"/users/{quote(user_id, safe='')}/credentials"
@ -339,6 +391,8 @@ _ADMIN: KeycloakAdminClient | None = None
def oidc_client() -> KeycloakOIDC: def oidc_client() -> KeycloakOIDC:
"""Return the singleton OIDC verifier."""
global _OIDC global _OIDC
if _OIDC is None: if _OIDC is None:
_OIDC = KeycloakOIDC() _OIDC = KeycloakOIDC()
@ -346,6 +400,8 @@ def oidc_client() -> KeycloakOIDC:
def admin_client() -> KeycloakAdminClient: def admin_client() -> KeycloakAdminClient:
"""Return the singleton Keycloak admin client."""
global _ADMIN global _ADMIN
if _ADMIN is None: if _ADMIN is None:
_ADMIN = KeycloakAdminClient() _ADMIN = KeycloakAdminClient()
@ -364,6 +420,8 @@ def _normalize_groups(groups: Any) -> list[str]:
def _extract_bearer_token() -> str | None: def _extract_bearer_token() -> str | None:
"""Extract a bearer token from the current Flask request."""
header = request.headers.get("Authorization", "") header = request.headers.get("Authorization", "")
if not header: if not header:
return None return None
@ -377,8 +435,12 @@ def _extract_bearer_token() -> str | None:
def require_auth(fn): def require_auth(fn):
"""Decorate a Flask route so it requires a valid Keycloak bearer token."""
@wraps(fn) @wraps(fn)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
"""Validate the request token and place normalized claims on Flask globals."""
token = _extract_bearer_token() token = _extract_bearer_token()
if not token: if not token:
return jsonify({"error": "missing bearer token"}), 401 return jsonify({"error": "missing bearer token"}), 401
@ -397,6 +459,8 @@ def require_auth(fn):
def require_portal_admin() -> tuple[bool, Any]: def require_portal_admin() -> tuple[bool, Any]:
"""Return whether the authenticated user can use portal admin actions."""
if not settings.KEYCLOAK_ENABLED: if not settings.KEYCLOAK_ENABLED:
return False, (jsonify({"error": "keycloak not enabled"}), 503) return False, (jsonify({"error": "keycloak not enabled"}), 503)
@ -411,6 +475,8 @@ def require_portal_admin() -> tuple[bool, Any]:
def require_account_access() -> tuple[bool, Any]: def require_account_access() -> tuple[bool, Any]:
"""Return whether the authenticated user can use self-service account pages."""
if not settings.KEYCLOAK_ENABLED: if not settings.KEYCLOAK_ENABLED:
return False, (jsonify({"error": "keycloak not enabled"}), 503) return False, (jsonify({"error": "keycloak not enabled"}), 503)
if not settings.ACCOUNT_ALLOWED_GROUPS: if not settings.ACCOUNT_ALLOWED_GROUPS:

View File

@ -7,10 +7,14 @@ from . import settings
class MailerError(RuntimeError): class MailerError(RuntimeError):
"""Signal a mail delivery problem without leaking provider internals."""
pass pass
def send_text_email(*, to_addr: str, subject: str, body: str) -> None: def send_text_email(*, to_addr: str, subject: str, body: str) -> None:
"""Send a plaintext email through the configured SMTP relay."""
if not to_addr: if not to_addr:
raise MailerError("missing recipient") raise MailerError("missing recipient")
if not settings.SMTP_HOST: if not settings.SMTP_HOST:
@ -35,6 +39,8 @@ def send_text_email(*, to_addr: str, subject: str, body: str) -> None:
def access_request_verification_body(*, request_code: str, verify_url: str) -> str: def access_request_verification_body(*, request_code: str, verify_url: str) -> str:
"""Render the access-request email verification message body."""
return "\n".join( return "\n".join(
[ [
"Atlas — confirm your email", "Atlas — confirm your email",
@ -50,4 +56,3 @@ def access_request_verification_body(*, request_code: str, verify_url: str) -> s
"", "",
] ]
) )

View File

@ -4,6 +4,8 @@ from .db import run_migrations
def main() -> None: def main() -> None:
"""Run database migrations when invoked as a module or script."""
run_migrations() run_migrations()

View File

@ -16,6 +16,8 @@ def _safe_name_fragment(value: str, max_len: int = 24) -> str:
def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]: def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]:
"""Render a one-off Nextcloud mail sync Job from the CronJob template."""
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {} spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {} jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {} job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
@ -61,6 +63,8 @@ def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]:
def _job_succeeded(job: dict[str, Any]) -> bool: def _job_succeeded(job: dict[str, Any]) -> bool:
"""Return whether Kubernetes reports the sync Job as successfully complete."""
status = job.get("status") if isinstance(job.get("status"), dict) else {} status = job.get("status") if isinstance(job.get("status"), dict) else {}
if int(status.get("succeeded") or 0) > 0: if int(status.get("succeeded") or 0) > 0:
return True return True
@ -74,6 +78,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool:
def _job_failed(job: dict[str, Any]) -> bool: def _job_failed(job: dict[str, Any]) -> bool:
"""Return whether Kubernetes reports the sync Job as failed."""
status = job.get("status") if isinstance(job.get("status"), dict) else {} status = job.get("status") if isinstance(job.get("status"), dict) else {}
if int(status.get("failed") or 0) > 0: if int(status.get("failed") or 0) > 0:
return True return True
@ -87,6 +93,12 @@ def _job_failed(job: dict[str, Any]) -> bool:
def trigger(username: str, wait: bool = True) -> dict[str, Any]: def trigger(username: str, wait: bool = True) -> dict[str, Any]:
"""Start the Nextcloud mail sync Job for one user and optionally wait.
WHY: onboarding and account actions need a targeted sync repair path that
reuses the same template as scheduled cluster automation.
"""
username = (username or "").strip() username = (username or "").strip()
if not username: if not username:
raise RuntimeError("missing username") raise RuntimeError("missing username")
@ -120,4 +132,3 @@ def trigger(username: str, wait: bool = True) -> dict[str, Any]:
last_state = "running" last_state = "running"
return {"job": job_name, "status": last_state} return {"job": job_name, "status": last_state}

View File

@ -11,6 +11,13 @@ def register_access_request_onboarding(app, deps) -> None:
@app.route("/api/access/request/onboarding/attest", methods=["POST"]) @app.route("/api/access/request/onboarding/attest", methods=["POST"])
def request_access_onboarding_attest() -> Any: def request_access_onboarding_attest() -> Any:
"""Record or clear a user-attested onboarding step.
WHY: onboarding mixes manual tasks with Keycloak-managed tasks, so this
route enforces prerequisites and only accepts attestations for UI-owned
steps.
"""
if not deps.configured(): if not deps.configured():
return jsonify({"error": "server not deps.configured"}), 503 return jsonify({"error": "server not deps.configured"}), 503
@ -161,6 +168,8 @@ def register_access_request_onboarding(app, deps) -> None:
@app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"]) @app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"])
def request_access_onboarding_keycloak_password_rotate() -> Any: def request_access_onboarding_keycloak_password_rotate() -> Any:
"""Request Keycloak password rotation for an onboarding user."""
if not deps.configured(): if not deps.configured():
return jsonify({"error": "server not deps.configured"}), 503 return jsonify({"error": "server not deps.configured"}), 503

View File

@ -101,6 +101,8 @@ def _send_verification_email(*, request_code: str, email: str, token: str) -> No
class VerificationError(Exception): class VerificationError(Exception):
"""Describe an email verification failure with an HTTP status."""
def __init__(self, status_code: int, message: str) -> None: def __init__(self, status_code: int, message: str) -> None:
super().__init__(message) super().__init__(message)
self.status_code = status_code self.status_code = status_code
@ -108,6 +110,7 @@ class VerificationError(Exception):
def _verify_request(conn, code: str, token: str) -> str: def _verify_request(conn, code: str, token: str) -> str:
"""Validate email proof and atomically advance a pending request."""
row = conn.execute( row = conn.execute(
""" """
SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at
@ -161,6 +164,7 @@ def _normalize_status(status: str) -> str:
def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]: def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
"""Return manually attested onboarding steps for one request."""
rows = conn.execute( rows = conn.execute(
"SELECT step FROM access_request_onboarding_steps WHERE request_code = %s", "SELECT step FROM access_request_onboarding_steps WHERE request_code = %s",
(request_code,), (request_code,),
@ -182,6 +186,7 @@ def _normalize_flag_list(raw: Any) -> set[str]:
def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]: def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]:
"""Return approval flags and contact email used by onboarding decisions."""
row = conn.execute( row = conn.execute(
"SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s", "SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s",
(request_code,), (request_code,),
@ -194,6 +199,7 @@ def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], s
def _user_in_group(username: str, group_name: str) -> bool: def _user_in_group(username: str, group_name: str) -> bool:
"""Return whether a Keycloak user belongs to a named group."""
if not username or not group_name: if not username or not group_name:
return False return False
if not admin_client().ready(): if not admin_client().ready():
@ -219,6 +225,7 @@ def _vaultwarden_grandfathered(conn, request_code: str, username: str) -> tuple[
def _resolve_recovery_email(username: str, fallback: str) -> str: def _resolve_recovery_email(username: str, fallback: str) -> str:
"""Find the best recovery email for Vaultwarden onboarding."""
if username and admin_client().ready(): if username and admin_client().ready():
try: try:
user = admin_client().find_user(username) or {} user = admin_client().find_user(username) or {}
@ -234,6 +241,7 @@ def _resolve_recovery_email(username: str, fallback: str) -> str:
def _password_rotation_requested(conn, request_code: str) -> bool: def _password_rotation_requested(conn, request_code: str) -> bool:
"""Return whether Keycloak password rotation was requested for this request."""
row = conn.execute( row = conn.execute(
""" """
SELECT 1 SELECT 1
@ -247,6 +255,7 @@ def _password_rotation_requested(conn, request_code: str) -> bool:
def _request_keycloak_password_rotation(conn, request_code: str, username: str) -> None: def _request_keycloak_password_rotation(conn, request_code: str, username: str) -> None:
"""Require Keycloak password rotation and persist the request marker."""
if not username: if not username:
raise ValueError("username missing") raise ValueError("username missing")
if not admin_client().ready(): if not admin_client().ready():
@ -277,6 +286,7 @@ def _request_keycloak_password_rotation(conn, request_code: str, username: str)
def _extract_attr(attrs: Any, key: str) -> str: def _extract_attr(attrs: Any, key: str) -> str:
"""Return the first string value for a Keycloak attribute."""
if not isinstance(attrs, dict): if not isinstance(attrs, dict):
return "" return ""
raw = attrs.get(key) raw = attrs.get(key)
@ -291,6 +301,7 @@ def _extract_attr(attrs: Any, key: str) -> str:
def _vaultwarden_status_for_user(username: str) -> str: def _vaultwarden_status_for_user(username: str) -> str:
"""Read the Vaultwarden lifecycle status mirrored on a Keycloak user."""
if not username: if not username:
return "" return ""
if not admin_client().ready(): if not admin_client().ready():
@ -308,6 +319,7 @@ def _vaultwarden_status_for_user(username: str) -> str:
def _auto_completed_service_steps(attrs: Any) -> set[str]: def _auto_completed_service_steps(attrs: Any) -> set[str]:
"""Infer onboarding steps completed by backend service automation."""
completed: set[str] = set() completed: set[str] = set()
if not isinstance(attrs, dict): if not isinstance(attrs, dict):
return completed return completed
@ -333,6 +345,7 @@ def _auto_completed_service_steps(attrs: Any) -> set[str]:
def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]: def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]:
"""Infer onboarding steps from Keycloak profile state."""
if not username: if not username:
return set() return set()
if not admin_client().ready(): if not admin_client().ready():
@ -389,6 +402,7 @@ def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[s
def _automation_ready(conn, request_code: str, username: str) -> bool: def _automation_ready(conn, request_code: str, username: str) -> bool:
"""Return whether account provisioning has finished enough for onboarding."""
if not username: if not username:
return False return False
if not admin_client().ready(): if not admin_client().ready():
@ -423,6 +437,7 @@ def _automation_ready(conn, request_code: str, username: str) -> bool:
def _advance_status(conn, request_code: str, username: str, status: str) -> str: def _advance_status(conn, request_code: str, username: str, status: str) -> str:
"""Advance an access request through automatic status transitions."""
status = _normalize_status(status) status = _normalize_status(status)
if status == "accounts_building" and _automation_ready(conn, request_code, username): if status == "accounts_building" and _automation_ready(conn, request_code, username):
@ -450,6 +465,7 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str:
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]: def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
"""Build the onboarding progress payload returned to the frontend."""
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username)) completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
password_rotation_requested = _password_rotation_requested(conn, request_code) password_rotation_requested = _password_rotation_requested(conn, request_code)
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username) grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)

View File

@ -11,6 +11,12 @@ def register_access_request_status(app, deps) -> None:
@app.route("/api/access/request/status", methods=["POST"]) @app.route("/api/access/request/status", methods=["POST"])
def request_access_status() -> Any: def request_access_status() -> Any:
"""Return current provisioning and onboarding status for a request.
WHY: this endpoint is polled by the public flow, so it also advances
safe automatic transitions before rendering the latest state.
"""
if not deps.settings.ACCESS_REQUEST_ENABLED: if not deps.settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not deps.configured(): if not deps.configured():
@ -142,6 +148,8 @@ def register_access_request_status(app, deps) -> None:
@app.route("/api/access/request/retry", methods=["POST"]) @app.route("/api/access/request/retry", methods=["POST"])
def request_access_retry() -> Any: def request_access_retry() -> Any:
"""Retry failed provisioning tasks for an access request."""
if not deps.settings.ACCESS_REQUEST_ENABLED: if not deps.settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not deps.configured(): if not deps.configured():

View File

@ -13,6 +13,8 @@ def register_access_request_submission(app, deps) -> None:
@app.route("/api/access/request/availability", methods=["GET"]) @app.route("/api/access/request/availability", methods=["GET"])
def request_access_availability() -> Any: def request_access_availability() -> Any:
"""Report whether a requested username can start access signup."""
if not deps.settings.ACCESS_REQUEST_ENABLED: if not deps.settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not deps.configured(): if not deps.configured():
@ -55,6 +57,12 @@ def register_access_request_submission(app, deps) -> None:
@app.route("/api/access/request", methods=["POST"]) @app.route("/api/access/request", methods=["POST"])
def request_access() -> Any: def request_access() -> Any:
"""Create or refresh an email-verified access request.
WHY: submissions are anonymous, so this route validates names, rate
limits by client/request, and emails a proof token before queuing work.
"""
if not deps.settings.ACCESS_REQUEST_ENABLED: if not deps.settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not deps.configured(): if not deps.configured():
@ -202,6 +210,8 @@ def register_access_request_submission(app, deps) -> None:
@app.route("/api/access/request/verify", methods=["POST"]) @app.route("/api/access/request/verify", methods=["POST"])
def request_access_verify() -> Any: def request_access_verify() -> Any:
"""Verify a submitted access request using a request code and token."""
if not deps.settings.ACCESS_REQUEST_ENABLED: if not deps.settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not deps.configured(): if not deps.configured():
@ -246,6 +256,8 @@ def register_access_request_submission(app, deps) -> None:
@app.route("/api/access/request/verify-link", methods=["GET"]) @app.route("/api/access/request/verify-link", methods=["GET"])
def request_access_verify_link() -> Any: def request_access_verify_link() -> Any:
"""Verify an emailed access-request link and redirect to the UI."""
if not deps.settings.ACCESS_REQUEST_ENABLED: if not deps.settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not deps.configured(): if not deps.configured():
@ -267,6 +279,8 @@ def register_access_request_submission(app, deps) -> None:
@app.route("/api/access/request/resend", methods=["POST"]) @app.route("/api/access/request/resend", methods=["POST"])
def request_access_resend() -> Any: def request_access_resend() -> Any:
"""Send a fresh verification token for a pending access request."""
if not deps.settings.ACCESS_REQUEST_ENABLED: if not deps.settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not deps.configured(): if not deps.configured():

View File

@ -34,6 +34,8 @@ def register_account_actions(app) -> None:
@app.route("/api/account/mailu/rotate", methods=["POST"]) @app.route("/api/account/mailu/rotate", methods=["POST"])
@require_auth @require_auth
def account_mailu_rotate() -> Any: def account_mailu_rotate() -> Any:
"""Rotate the user's Mailu app password and trigger dependent syncs."""
ok, resp = require_account_access() ok, resp = require_account_access()
if not ok: if not ok:
return resp return resp
@ -87,6 +89,8 @@ def register_account_actions(app) -> None:
@app.route("/api/account/wger/reset", methods=["POST"]) @app.route("/api/account/wger/reset", methods=["POST"])
@require_auth @require_auth
def account_wger_reset() -> Any: def account_wger_reset() -> Any:
"""Reset the user's Wger password through the sync Job path."""
ok, resp = require_account_access() ok, resp = require_account_access()
if not ok: if not ok:
return resp return resp
@ -140,6 +144,8 @@ def register_account_actions(app) -> None:
@app.route("/api/account/wger/rotation/check", methods=["POST"]) @app.route("/api/account/wger/rotation/check", methods=["POST"])
@require_auth @require_auth
def account_wger_rotation_check() -> Any: def account_wger_rotation_check() -> Any:
"""Proxy or reject Wger rotation status checks for this account."""
ok, resp = require_account_access() ok, resp = require_account_access()
if not ok: if not ok:
return resp return resp
@ -150,6 +156,8 @@ def register_account_actions(app) -> None:
@app.route("/api/account/firefly/reset", methods=["POST"]) @app.route("/api/account/firefly/reset", methods=["POST"])
@require_auth @require_auth
def account_firefly_reset() -> Any: def account_firefly_reset() -> Any:
"""Reset the user's Firefly password through the sync Job path."""
ok, resp = require_account_access() ok, resp = require_account_access()
if not ok: if not ok:
return resp return resp
@ -203,6 +211,8 @@ def register_account_actions(app) -> None:
@app.route("/api/account/firefly/rotation/check", methods=["POST"]) @app.route("/api/account/firefly/rotation/check", methods=["POST"])
@require_auth @require_auth
def account_firefly_rotation_check() -> Any: def account_firefly_rotation_check() -> Any:
"""Proxy or reject Firefly rotation status checks for this account."""
ok, resp = require_account_access() ok, resp = require_account_access()
if not ok: if not ok:
return resp return resp
@ -213,6 +223,8 @@ def register_account_actions(app) -> None:
@app.route("/api/account/nextcloud/mail/sync", methods=["POST"]) @app.route("/api/account/nextcloud/mail/sync", methods=["POST"])
@require_auth @require_auth
def account_nextcloud_mail_sync() -> Any: def account_nextcloud_mail_sync() -> Any:
"""Trigger a targeted Nextcloud mail sync for the signed-in user."""
ok, resp = require_account_access() ok, resp = require_account_access()
if not ok: if not ok:
return resp return resp

View File

@ -34,6 +34,12 @@ def register_account_overview(app) -> None:
@app.route("/api/account/overview", methods=["GET"]) @app.route("/api/account/overview", methods=["GET"])
@require_auth @require_auth
def account_overview() -> Any: def account_overview() -> Any:
"""Build the signed-in user's self-service account dashboard state.
WHY: the UI needs one coherent status payload assembled from Keycloak,
service sync markers, and legacy fallback checks.
"""
ok, resp = require_account_access() ok, resp = require_account_access()
if not ok: if not ok:
return resp return resp

View File

@ -12,9 +12,13 @@ from ..provisioning import provision_access_request
def register(app) -> None: def register(app) -> None:
"""Register administrator routes for access-request decisions."""
@app.route("/api/admin/access/requests", methods=["GET"]) @app.route("/api/admin/access/requests", methods=["GET"])
@require_auth @require_auth
def admin_list_requests() -> Any: def admin_list_requests() -> Any:
"""List pending access requests for portal administrators."""
ok, resp = require_portal_admin() ok, resp = require_portal_admin()
if not ok: if not ok:
return resp return resp
@ -56,6 +60,8 @@ def register(app) -> None:
@app.route("/api/admin/access/flags", methods=["GET"]) @app.route("/api/admin/access/flags", methods=["GET"])
@require_auth @require_auth
def admin_list_flags() -> Any: def admin_list_flags() -> Any:
"""List Keycloak groups that can be applied as approval flags."""
ok, resp = require_portal_admin() ok, resp = require_portal_admin()
if not ok: if not ok:
return resp return resp
@ -74,6 +80,12 @@ def register(app) -> None:
@app.route("/api/admin/access/requests/<username>/approve", methods=["POST"]) @app.route("/api/admin/access/requests/<username>/approve", methods=["POST"])
@require_auth @require_auth
def admin_approve_request(username: str) -> Any: def admin_approve_request(username: str) -> Any:
"""Approve one verified access request and start provisioning.
WHY: approval should atomically record the admin decision before
best-effort provisioning so status polling can surface any later issue.
"""
ok, resp = require_portal_admin() ok, resp = require_portal_admin()
if not ok: if not ok:
return resp return resp
@ -125,6 +137,8 @@ def register(app) -> None:
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"]) @app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
@require_auth @require_auth
def admin_deny_request(username: str) -> Any: def admin_deny_request(username: str) -> Any:
"""Deny one pending access request with optional admin context."""
ok, resp = require_portal_admin() ok, resp = require_portal_admin()
if not ok: if not ok:
return resp return resp

View File

@ -13,9 +13,13 @@ from .. import settings
def register(app) -> None: def register(app) -> None:
"""Register the Atlas AI chat and model-info endpoints."""
@app.route("/api/chat", methods=["POST"]) @app.route("/api/chat", methods=["POST"])
@app.route("/api/ai/chat", methods=["POST"]) @app.route("/api/ai/chat", methods=["POST"])
def ai_chat() -> Any: def ai_chat() -> Any:
"""Return an Atlasbot answer or a budget-aware fallback message."""
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
user_message = (payload.get("message") or "").strip() user_message = (payload.get("message") or "").strip()
profile = (payload.get("profile") or payload.get("mode") or "atlas-quick").strip().lower() profile = (payload.get("profile") or payload.get("mode") or "atlas-quick").strip().lower()
@ -61,6 +65,8 @@ def register(app) -> None:
@app.route("/api/chat/info", methods=["GET"]) @app.route("/api/chat/info", methods=["GET"])
@app.route("/api/ai/info", methods=["GET"]) @app.route("/api/ai/info", methods=["GET"])
def ai_info() -> Any: def ai_info() -> Any:
"""Return model and placement metadata for the requested AI profile."""
profile = (request.args.get("profile") or "atlas-quick").strip().lower() profile = (request.args.get("profile") or "atlas-quick").strip().lower()
meta = _discover_ai_meta(profile) meta = _discover_ai_meta(profile)
return jsonify(meta) return jsonify(meta)
@ -69,6 +75,8 @@ def register(app) -> None:
def _atlasbot_answer(message: str, mode: str, conversation_id: str) -> str: def _atlasbot_answer(message: str, mode: str, conversation_id: str) -> str:
"""Ask Atlasbot for one answer and return an empty string on soft failure."""
endpoint = settings.AI_ATLASBOT_ENDPOINT endpoint = settings.AI_ATLASBOT_ENDPOINT
if not endpoint: if not endpoint:
return "" return ""
@ -99,6 +107,12 @@ def _atlasbot_timeout_sec(mode: str) -> float:
def _discover_ai_meta(profile: str) -> dict[str, str]: def _discover_ai_meta(profile: str) -> dict[str, str]:
"""Discover AI model metadata from settings and the running Kubernetes pod.
WHY: the frontend needs a human-readable model/GPU hint even when the model
image or GPU placement changes outside the portal code.
"""
meta = { meta = {
"node": settings.AI_NODE_NAME, "node": settings.AI_NODE_NAME,
"gpu": settings.AI_GPU_DESC, "gpu": settings.AI_GPU_DESC,
@ -168,10 +182,14 @@ def _discover_ai_meta(profile: str) -> dict[str, str]:
def _start_keep_warm() -> None: def _start_keep_warm() -> None:
"""Start the optional background keep-warm loop for the chat backend."""
if not settings.AI_WARM_ENABLED or settings.AI_WARM_INTERVAL_SEC <= 0: if not settings.AI_WARM_ENABLED or settings.AI_WARM_INTERVAL_SEC <= 0:
return return
def loop() -> None: def loop() -> None:
"""Periodically send a tiny chat request so the backend stays warm."""
while True: while True:
time.sleep(settings.AI_WARM_INTERVAL_SEC) time.sleep(settings.AI_WARM_INTERVAL_SEC)
try: try:

View File

@ -15,6 +15,8 @@ _LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
def _vm_query(expr: str) -> float | None: def _vm_query(expr: str) -> float | None:
"""Run one instant VictoriaMetrics query and return the largest value."""
url = f"{settings.VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}" url = f"{settings.VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}"
with urlopen(url, timeout=settings.VM_QUERY_TIMEOUT_SEC) as resp: with urlopen(url, timeout=settings.VM_QUERY_TIMEOUT_SEC) as resp:
payload = json.loads(resp.read().decode("utf-8")) payload = json.loads(resp.read().decode("utf-8"))
@ -40,6 +42,8 @@ def _vm_query(expr: str) -> float | None:
def _http_ok(url: str, expect_substring: str | None = None) -> bool: def _http_ok(url: str, expect_substring: str | None = None) -> bool:
"""Return whether a URL responds successfully and optionally contains text."""
try: try:
with urlopen(url, timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as resp: with urlopen(url, timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as resp:
if getattr(resp, "status", 200) != 200: if getattr(resp, "status", 200) != 200:
@ -53,8 +57,12 @@ def _http_ok(url: str, expect_substring: str | None = None) -> bool:
def register(app) -> None: def register(app) -> None:
"""Register the lightweight lab connectivity status endpoint."""
@app.route("/api/lab/status") @app.route("/api/lab/status")
def lab_status() -> Any: def lab_status() -> Any:
"""Return cached Atlas/Oceanus health hints for the home page."""
now = time.time() now = time.time()
cached = _LAB_STATUS_CACHE.get("value") cached = _LAB_STATUS_CACHE.get("value")
if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC): if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC):

View File

@ -28,6 +28,8 @@ def _read_service_account() -> tuple[str, str]:
def _k8s_get_json(path: str) -> dict[str, Any]: def _k8s_get_json(path: str) -> dict[str, Any]:
"""Fetch a Kubernetes object as JSON through the pod service account."""
token, ca_path = _read_service_account() token, ca_path = _read_service_account()
url = f"{_K8S_BASE_URL}{path}" url = f"{_K8S_BASE_URL}{path}"
with httpx.Client( with httpx.Client(
@ -44,12 +46,16 @@ def _k8s_get_json(path: str) -> dict[str, Any]:
def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str: def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str:
"""Find a usable Vaultwarden pod IP for direct admin fallback."""
data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/pods?labelSelector={label_selector}") data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/pods?labelSelector={label_selector}")
items = data.get("items") or [] items = data.get("items") or []
if not isinstance(items, list) or not items: if not isinstance(items, list) or not items:
raise RuntimeError("no vaultwarden pods found") raise RuntimeError("no vaultwarden pods found")
def _pod_ready(pod: dict[str, Any]) -> bool: def _pod_ready(pod: dict[str, Any]) -> bool:
"""Return whether a listed pod is running and ready enough to contact."""
status = pod.get("status") if isinstance(pod.get("status"), dict) else {} status = pod.get("status") if isinstance(pod.get("status"), dict) else {}
if status.get("phase") != "Running": if status.get("phase") != "Running":
return False return False
@ -74,6 +80,8 @@ def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str:
def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str: def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str:
"""Read and decode one Kubernetes Secret value."""
data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/secrets/{name}") data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/secrets/{name}")
blob = data.get("data") if isinstance(data.get("data"), dict) else {} blob = data.get("data") if isinstance(data.get("data"), dict) else {}
raw = blob.get(key) raw = blob.get(key)
@ -90,6 +98,8 @@ def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str:
@dataclass(frozen=True) @dataclass(frozen=True)
class VaultwardenInvite: class VaultwardenInvite:
"""Describe the result of attempting to create a Vaultwarden invite."""
ok: bool ok: bool
status: str status: str
detail: str = "" detail: str = ""
@ -103,6 +113,12 @@ _ADMIN_RATE_LIMITED_UNTIL: float = 0.0
def _admin_session(base_url: str) -> httpx.Client: def _admin_session(base_url: str) -> httpx.Client:
"""Return a cached authenticated Vaultwarden admin session.
WHY: Vaultwarden rate-limits admin login attempts, so invite creation must
reuse a short-lived session instead of logging in for every user action.
"""
global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL, _ADMIN_RATE_LIMITED_UNTIL global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL, _ADMIN_RATE_LIMITED_UNTIL
now = time.time() now = time.time()
with _ADMIN_LOCK: with _ADMIN_LOCK:
@ -146,6 +162,8 @@ def _admin_session(base_url: str) -> httpx.Client:
def invite_user(email: str) -> VaultwardenInvite: def invite_user(email: str) -> VaultwardenInvite:
"""Invite one email address to Vaultwarden through the admin UI."""
global _ADMIN_RATE_LIMITED_UNTIL global _ADMIN_RATE_LIMITED_UNTIL
email = (email or "").strip() email = (email or "").strip()
if not email or "@" not in email: if not email or "@" not in email:

View File

@ -21,6 +21,8 @@ def _job_from_cronjob(
email: str, email: str,
password: str, password: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Render a one-off Wger user sync Job from the managed CronJob template."""
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {} spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {} jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {} job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
@ -71,6 +73,8 @@ def _job_from_cronjob(
def _job_succeeded(job: dict[str, Any]) -> bool: def _job_succeeded(job: dict[str, Any]) -> bool:
"""Return whether Kubernetes reports the sync Job as successfully complete."""
status = job.get("status") if isinstance(job.get("status"), dict) else {} status = job.get("status") if isinstance(job.get("status"), dict) else {}
if int(status.get("succeeded") or 0) > 0: if int(status.get("succeeded") or 0) > 0:
return True return True
@ -84,6 +88,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool:
def _job_failed(job: dict[str, Any]) -> bool: def _job_failed(job: dict[str, Any]) -> bool:
"""Return whether Kubernetes reports the sync Job as failed."""
status = job.get("status") if isinstance(job.get("status"), dict) else {} status = job.get("status") if isinstance(job.get("status"), dict) else {}
if int(status.get("failed") or 0) > 0: if int(status.get("failed") or 0) > 0:
return True return True
@ -97,6 +103,12 @@ def _job_failed(job: dict[str, Any]) -> bool:
def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]: def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]:
"""Start the Wger sync Job for one user and optionally wait for completion.
WHY: account actions need an immediate per-user repair path without
mutating the reusable CronJob template that Flux owns.
"""
username = (username or "").strip() username = (username or "").strip()
if not username: if not username:
raise RuntimeError("missing username") raise RuntimeError("missing username")

View File

@ -256,6 +256,7 @@ export function useAccountDashboard() {
return Array.isArray(selected) && selected.includes(flag); return Array.isArray(selected) && selected.includes(flag);
} }
// WHY: admins need readable applicant names even when optional fields are missing; @returns display name.
function formatName(req) { function formatName(req) {
if (!req) return "unknown"; if (!req) return "unknown";
const parts = []; const parts = [];
@ -380,6 +381,7 @@ export function useAccountDashboard() {
} }
} }
// WHY: Safari/private-mode clipboard support varies; @returns whether fallback copy completed.
function fallbackCopy(text) { function fallbackCopy(text) {
const textarea = document.createElement("textarea"); const textarea = document.createElement("textarea");
textarea.value = text; textarea.value = text;

View File

@ -67,6 +67,11 @@ const renderDiagram = async () => {
} }
}; };
/**
* Cancel pending Mermaid rendering work before scheduling a replacement.
*
* @returns {void}
*/
function cancelScheduledRender() { function cancelScheduledRender() {
if (!scheduledHandle) return; if (!scheduledHandle) return;
if (scheduledKind === "idle" && window.cancelIdleCallback) { if (scheduledKind === "idle" && window.cancelIdleCallback) {
@ -78,6 +83,14 @@ function cancelScheduledRender() {
scheduledKind = ""; scheduledKind = "";
} }
/**
* Schedule Mermaid rendering during idle time when the browser supports it.
*
* WHY: diagrams are decorative and can be expensive, so rendering should not
* compete with first paint or input responsiveness.
*
* @returns {void}
*/
function scheduleRenderDiagram() { function scheduleRenderDiagram() {
cancelScheduledRender(); cancelScheduledRender();
if (!props.diagram) return; if (!props.diagram) return;

View File

@ -1,4 +1,9 @@
/** Display labels and pill classes for onboarding status values. */ /**
* Convert an onboarding status code into display copy.
*
* @param {string} value - Raw status returned by the backend.
* @returns {string} Human-readable status label.
*/
export function statusLabel(value) { export function statusLabel(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "pending_email_verification") return "confirm email"; if (key === "pending_email_verification") return "confirm email";
@ -8,8 +13,14 @@ export function statusLabel(value) {
if (key === "ready") return "ready"; if (key === "ready") return "ready";
if (key === "denied") return "rejected"; if (key === "denied") return "rejected";
return key || "unknown"; return key || "unknown";
} }
/**
* Convert an onboarding status code into the matching pill class.
*
* @param {string} value - Raw status returned by the backend.
* @returns {string} CSS class for status emphasis.
*/
export function statusPillClass(value) { export function statusPillClass(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "pending_email_verification") return "pill-warn"; if (key === "pending_email_verification") return "pill-warn";
@ -19,11 +30,17 @@ export function statusPillClass(value) {
if (key === "ready") return "pill-info"; if (key === "ready") return "pill-info";
if (key === "denied") return "pill-bad"; if (key === "denied") return "pill-bad";
return "pill-warn"; return "pill-warn";
} }
/**
* Convert a provisioning task status into a pill class.
*
* @param {string} value - Raw task status returned by the backend.
* @returns {string} CSS class for task emphasis.
*/
export function taskPillClass(value) { export function taskPillClass(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "ok") return "pill-ok"; if (key === "ok") return "pill-ok";
if (key === "error") return "pill-bad"; if (key === "error") return "pill-bad";
return "pill-warn"; return "pill-warn";
} }

View File

@ -7,11 +7,9 @@ import { SECTION_DEFS, STEP_PREREQS, VAULTWARDEN_TEMP_STEP } from "./onboardingS
/** /**
* Build the Onboarding page state machine. * Build the Onboarding page state machine.
*
* WHY: onboarding coordinates request status, guide media, password reveal, * WHY: onboarding coordinates request status, guide media, password reveal,
* and service attestation flow; isolating that state keeps the view focused * and service attestation flow; isolating that state keeps the view focused
* on layout and makes the workflow independently testable. * on layout and makes the workflow independently testable.
*
* @param {import("vue-router").RouteLocationNormalizedLoaded} route - active route with optional request code query params. * @param {import("vue-router").RouteLocationNormalizedLoaded} route - active route with optional request code query params.
* @returns {object} reactive onboarding state and event handlers. * @returns {object} reactive onboarding state and event handlers.
*/ */
@ -118,6 +116,7 @@ export function useOnboardingFlow(route) {
return prereqs.some((req) => !isStepDone(req)); return prereqs.some((req) => !isStepDone(req));
} }
// WHY: service login rules vary by step; @returns optional helper copy.
function stepNote(step) { function stepNote(step) {
if (step.id === "vaultwarden_master_password") { if (step.id === "vaultwarden_master_password") {
return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmailLower.value} to sign in.`; return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmailLower.value} to sign in.`;
@ -134,6 +133,7 @@ export function useOnboardingFlow(route) {
return ""; return "";
} }
// WHY: step state combines completion, prerequisites, and backend automation; @returns pill text.
function stepPillLabel(step) { function stepPillLabel(step) {
if (isStepDone(step.id)) return "done"; if (isStepDone(step.id)) return "done";
if (isStepBlocked(step.id)) return "blocked"; if (isStepBlocked(step.id)) return "blocked";

View File

@ -12,6 +12,12 @@ export function useOnboardingGuides({ isStepDone, isStepBlocked }) {
const guidePage = ref({}); const guidePage = ref({});
const lightboxShot = ref(null); const lightboxShot = ref(null);
/**
* Return screenshot groups for a step, honoring configured head/tail limits.
*
* @param {object} step - Onboarding step definition with optional guide metadata.
* @returns {Array<object>} Screenshot groups to render for the guide carousel.
*/
function guideGroups(step) { function guideGroups(step) {
if (!step.guide) return []; if (!step.guide) return [];
const service = step.guide.service; const service = step.guide.service;

View File

@ -11,6 +11,12 @@ import { onMounted, reactive, ref, watch } from "vue";
* @returns {object} reactive state and event handlers used by the view template. * @returns {object} reactive state and event handlers used by the view template.
*/ */
export function useRequestAccessFlow(route) { export function useRequestAccessFlow(route) {
/**
* Convert backend request status into copy for the public flow.
*
* @param {string} value - Raw access request status.
* @returns {string} Human-readable status label.
*/
function statusLabel(value) { function statusLabel(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "pending_email_verification") return "confirm email"; if (key === "pending_email_verification") return "confirm email";
@ -22,6 +28,12 @@ export function useRequestAccessFlow(route) {
return key || "unknown"; return key || "unknown";
} }
/**
* Convert backend request status into the matching pill class.
*
* @param {string} value - Raw access request status.
* @returns {string} CSS class for status emphasis.
*/
function statusPillClass(value) { function statusPillClass(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "pending_email_verification") return "pill-warn"; if (key === "pending_email_verification") return "pill-warn";
@ -87,6 +99,13 @@ export function useRequestAccessFlow(route) {
availability.blockSubmit = false; availability.blockSubmit = false;
} }
/**
* Update username availability UI state from a normalized backend result.
*
* @param {string} state - Availability state key.
* @param {string} detail - Optional human-readable explanation.
* @returns {void}
*/
function setAvailability(state, detail = "") { function setAvailability(state, detail = "") {
availability.detail = detail; availability.detail = detail;
availability.blockSubmit = false; availability.blockSubmit = false;

View File

@ -119,6 +119,12 @@ const chatWindow = ref(null);
const copied = ref(false); const copied = ref(false);
const conversationIds = reactive({}); const conversationIds = reactive({});
/**
* Return a stable local conversation ID for one AI profile.
*
* @param {string} profile - Active AI profile key.
* @returns {string} Stable conversation identifier persisted in local storage.
*/
function ensureConversationId(profile) { function ensureConversationId(profile) {
if (conversationIds[profile]) return conversationIds[profile]; if (conversationIds[profile]) return conversationIds[profile];
const key = `atlas-ai-conversation:${profile}`; const key = `atlas-ai-conversation:${profile}`;