ariadne/ariadne/app.py

256 lines
11 KiB
Python

from __future__ import annotations
import json
import sys
from typing import Any
from fastapi import Body, Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from .app_account_routes import _register_account_routes
from .app_admin_routes import _register_admin_routes
from .auth.keycloak import AuthContext, authenticator
from .db.database import Database, DatabaseConfig
from .db.storage import Storage
from .manager.provisioning import ProvisioningManager
from .metrics.metrics import record_task_run
from .scheduler.cron import CronScheduler
from .services.cluster_state import run_cluster_state
from .services.comms import comms
from .services.firefly import firefly
from .services.image_sweeper import image_sweeper
from .services.jenkins_build_weather import collect_jenkins_build_weather
from .services.jenkins_workspace_cleanup import cleanup_jenkins_workspace_storage
from .services.keycloak_admin import keycloak_admin
from .services.keycloak_profile import run_profile_sync
from .services.mailu import mailu
from .services.mailu_events import mailu_events
from .services.metis import metis
from .services.metis_token_sync import metis_token_sync
from .services.nextcloud import nextcloud
from .services.opensearch_prune import prune_indices
from .services.platform_quality_probe import platform_quality_probe
from .services.pod_cleaner import clean_finished_pods
from .services.vault import vault
from .services.vaultwarden_sync import run_vaultwarden_sync
from .services.wger import wger
from .settings import settings
from .utils.http import extract_bearer_token
from .utils.logging import LogConfig, configure_logging, get_logger
from .utils.passwords import random_password
configure_logging(LogConfig(level=settings.log_level))
logger = get_logger(__name__)
portal_db = Database(
settings.portal_database_url,
DatabaseConfig(
pool_min=settings.ariadne_db_pool_min,
pool_max=settings.ariadne_db_pool_max,
connect_timeout_sec=settings.ariadne_db_connect_timeout_sec,
lock_timeout_sec=settings.ariadne_db_lock_timeout_sec,
statement_timeout_sec=settings.ariadne_db_statement_timeout_sec,
idle_in_tx_timeout_sec=settings.ariadne_db_idle_in_tx_timeout_sec,
application_name="ariadne_portal",
),
)
ariadne_db = Database(
settings.ariadne_database_url,
DatabaseConfig(
pool_min=settings.ariadne_db_pool_min,
pool_max=settings.ariadne_db_pool_max,
connect_timeout_sec=settings.ariadne_db_connect_timeout_sec,
lock_timeout_sec=settings.ariadne_db_lock_timeout_sec,
statement_timeout_sec=settings.ariadne_db_statement_timeout_sec,
idle_in_tx_timeout_sec=settings.ariadne_db_idle_in_tx_timeout_sec,
application_name="ariadne",
),
)
storage = Storage(ariadne_db, portal_db)
provisioning = ProvisioningManager(portal_db, storage)
scheduler = CronScheduler(storage, settings.schedule_tick_sec)
app = FastAPI(title=settings.app_name)
def _record_event(event_type: str, detail: dict[str, Any] | str | None) -> None:
try:
storage.record_event(event_type, detail)
except Exception:
pass
def _parse_event_detail(detail: str | None) -> Any:
if not isinstance(detail, str) or not detail:
return ""
try:
return json.loads(detail)
except Exception:
return detail
def _require_auth(request: Request) -> AuthContext:
token = extract_bearer_token(request)
if not token:
raise HTTPException(status_code=401, detail="missing bearer token")
try:
return authenticator.authenticate(token)
except Exception:
raise HTTPException(status_code=401, detail="invalid token")
def _require_admin(ctx: AuthContext) -> None:
if ctx.username and ctx.username in settings.portal_admin_users:
return
if settings.portal_admin_groups and set(ctx.groups).intersection(settings.portal_admin_groups):
return
raise HTTPException(status_code=403, detail="forbidden")
def _require_account_access(ctx: AuthContext) -> None:
if not settings.account_allowed_groups:
return
if not ctx.groups:
return
if set(ctx.groups).intersection(settings.account_allowed_groups):
return
raise HTTPException(status_code=403, detail="forbidden")
async def _read_json_payload(request: Request) -> dict[str, Any]:
try:
payload = await request.json()
except Exception:
return {}
return payload if isinstance(payload, dict) else {}
def _note_from_payload(payload: dict[str, Any]) -> str | None:
note = payload.get("note") if isinstance(payload, dict) else None
return str(note).strip() if isinstance(note, str) and note.strip() else None
def _flags_from_payload(payload: dict[str, Any]) -> list[str]:
flags_raw = payload.get("flags") if isinstance(payload, dict) else None
return [flag for flag in flags_raw if isinstance(flag, str)] if isinstance(flags_raw, list) else []
def _allowed_flag_groups() -> list[str]:
if not keycloak_admin.ready():
return settings.allowed_flag_groups
try:
return keycloak_admin.list_group_names(exclude={"admin"})
except Exception:
return settings.allowed_flag_groups
def _app_module() -> Any:
return sys.modules[__name__]
@app.on_event("startup")
def _startup() -> None:
provisioning.start()
scheduler.add_task("schedule.mailu_sync", settings.mailu_sync_cron, lambda: mailu.sync("ariadne_schedule"))
scheduler.add_task("schedule.nextcloud_sync", settings.nextcloud_sync_cron, lambda: nextcloud.sync_mail(wait=False))
scheduler.add_task("schedule.nextcloud_cron", settings.nextcloud_cron, lambda: nextcloud.run_cron())
scheduler.add_task("schedule.nextcloud_maintenance", settings.nextcloud_maintenance_cron, lambda: nextcloud.run_maintenance())
scheduler.add_task("schedule.vaultwarden_sync", settings.vaultwarden_sync_cron, run_vaultwarden_sync)
scheduler.add_task("schedule.keycloak_profile", settings.keycloak_profile_cron, run_profile_sync)
scheduler.add_task("schedule.wger_user_sync", settings.wger_user_sync_cron, lambda: wger.sync_users())
scheduler.add_task("schedule.wger_admin", settings.wger_admin_cron, lambda: wger.ensure_admin(wait=False))
scheduler.add_task("schedule.firefly_user_sync", settings.firefly_user_sync_cron, lambda: firefly.sync_users())
scheduler.add_task("schedule.firefly_cron", settings.firefly_cron, lambda: firefly.run_cron())
scheduler.add_task("schedule.pod_cleaner", settings.pod_cleaner_cron, clean_finished_pods)
scheduler.add_task("schedule.opensearch_prune", settings.opensearch_prune_cron, prune_indices)
scheduler.add_task("schedule.image_sweeper", settings.image_sweeper_cron, lambda: image_sweeper.run(wait=True))
scheduler.add_task("schedule.metis_sentinel_watch", settings.metis_sentinel_watch_cron, lambda: metis.watch_sentinel())
scheduler.add_task("schedule.metis_k3s_token_sync", settings.metis_k3s_token_sync_cron, lambda: metis_token_sync.run(wait=True))
scheduler.add_task(
"schedule.platform_quality_suite_probe",
settings.platform_quality_suite_probe_cron,
lambda: platform_quality_probe.run(wait=True),
)
scheduler.add_task("schedule.jenkins_build_weather", settings.jenkins_build_weather_cron, collect_jenkins_build_weather)
scheduler.add_task("schedule.jenkins_workspace_cleanup", settings.jenkins_workspace_cleanup_cron, cleanup_jenkins_workspace_storage)
scheduler.add_task("schedule.vault_k8s_auth", settings.vault_k8s_auth_cron, lambda: vault.sync_k8s_auth(wait=True))
scheduler.add_task("schedule.vault_oidc", settings.vault_oidc_cron, lambda: vault.sync_oidc(wait=True))
scheduler.add_task("schedule.comms_guest_name", settings.comms_guest_name_cron, lambda: comms.run_guest_name_randomizer(wait=True))
scheduler.add_task("schedule.comms_pin_invite", settings.comms_pin_invite_cron, lambda: comms.run_pin_invite(wait=True))
scheduler.add_task("schedule.comms_reset_room", settings.comms_reset_room_cron, lambda: comms.run_reset_room(wait=True))
scheduler.add_task("schedule.comms_seed_room", settings.comms_seed_room_cron, lambda: comms.run_seed_room(wait=True))
scheduler.add_task("schedule.cluster_state", settings.cluster_state_cron, lambda: run_cluster_state(storage))
scheduler.start()
logger.info(
"ariadne started",
extra={
"event": "startup",
"mailu_cron": settings.mailu_sync_cron,
"nextcloud_mail_cron": settings.nextcloud_sync_cron,
"nextcloud_cron": settings.nextcloud_cron,
"nextcloud_maintenance_cron": settings.nextcloud_maintenance_cron,
"vaultwarden_cron": settings.vaultwarden_sync_cron,
"wger_user_sync_cron": settings.wger_user_sync_cron,
"wger_admin_cron": settings.wger_admin_cron,
"firefly_user_sync_cron": settings.firefly_user_sync_cron,
"firefly_cron": settings.firefly_cron,
"pod_cleaner_cron": settings.pod_cleaner_cron,
"opensearch_prune_cron": settings.opensearch_prune_cron,
"image_sweeper_cron": settings.image_sweeper_cron,
"metis_sentinel_watch_cron": settings.metis_sentinel_watch_cron,
"metis_k3s_token_sync_cron": settings.metis_k3s_token_sync_cron,
"platform_quality_suite_probe_cron": settings.platform_quality_suite_probe_cron,
"jenkins_build_weather_cron": settings.jenkins_build_weather_cron,
"jenkins_base_url": settings.jenkins_base_url,
"jenkins_workspace_cleanup_cron": settings.jenkins_workspace_cleanup_cron,
"jenkins_workspace_cleanup_dry_run": settings.jenkins_workspace_cleanup_dry_run,
"jenkins_workspace_cleanup_max_deletions_per_run": settings.jenkins_workspace_cleanup_max_deletions_per_run,
"vault_k8s_auth_cron": settings.vault_k8s_auth_cron,
"vault_oidc_cron": settings.vault_oidc_cron,
"comms_guest_name_cron": settings.comms_guest_name_cron,
"comms_pin_invite_cron": settings.comms_pin_invite_cron,
"comms_reset_room_cron": settings.comms_reset_room_cron,
"comms_seed_room_cron": settings.comms_seed_room_cron,
"keycloak_profile_cron": settings.keycloak_profile_cron,
"cluster_state_cron": settings.cluster_state_cron,
},
)
@app.on_event("shutdown")
def _shutdown() -> None:
scheduler.stop()
provisioning.stop()
portal_db.close()
ariadne_db.close()
logger.info("ariadne stopped", extra={"event": "shutdown"})
@app.get("/health")
def health() -> dict[str, Any]:
"""Return a minimal liveness response for probes and operators."""
return {"ok": True}
@app.get(settings.metrics_path)
def metrics() -> Response:
"""Expose Prometheus metrics generated by Ariadne runtime tasks."""
payload = generate_latest()
return Response(payload, media_type=CONTENT_TYPE_LATEST)
@app.post("/events")
def mailu_event_listener(payload: dict[str, Any] | None = Body(default=None)) -> Response:
"""Accept Mailu webhook events and dispatch mapped account actions."""
status_code, response = mailu_events.handle_event(payload)
return JSONResponse(response, status_code=status_code)
_register_admin_routes(app, _require_auth, _app_module)
_register_account_routes(app, _require_auth, _app_module)