feat: add dashboard API with quick links and widgets
Build and Push / build (release) Successful in 1m28s
Build and Push / build (release) Successful in 1m28s
- Dashboard domain with Quick Links CRUD + reorder endpoints - Dashboard widgets management endpoints - Database migrations for quick_links and dashboard_widgets tables - Static file controller for Organizr widgets - Default local user when OIDC is disabled - Domain-based architecture refactor (src/domains/, src/shared/) - Test suite updated for new structure (285 tests passing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
e85c9a123d
commit
381d43b60b
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Shared utilities for Core-API
|
||||
|
||||
Contains common base classes, configuration, database, logging utilities,
|
||||
and API clients used across all domains.
|
||||
"""
|
||||
from src.shared.config import get_settings, Settings
|
||||
from src.shared.database import Base, get_database, get_async_session
|
||||
from src.shared.logging import get_logger, setup_logging
|
||||
from src.shared.base import BaseController, BaseSchema
|
||||
from src.shared.security import initialize_oidc
|
||||
|
||||
# Re-export clients for convenience
|
||||
from src.shared.clients import (
|
||||
PortainerClient,
|
||||
get_portainer_client,
|
||||
NPMClient,
|
||||
get_npm_client,
|
||||
HomeAssistantClient,
|
||||
get_homeassistant_client,
|
||||
AuthentikClient,
|
||||
get_authentik_client,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"get_settings",
|
||||
"Settings",
|
||||
# Database
|
||||
"Base",
|
||||
"get_database",
|
||||
"get_async_session",
|
||||
# Logging
|
||||
"get_logger",
|
||||
"setup_logging",
|
||||
# Base classes
|
||||
"BaseController",
|
||||
"BaseSchema",
|
||||
# Security
|
||||
"initialize_oidc",
|
||||
# Clients
|
||||
"PortainerClient",
|
||||
"get_portainer_client",
|
||||
"NPMClient",
|
||||
"get_npm_client",
|
||||
"HomeAssistantClient",
|
||||
"get_homeassistant_client",
|
||||
"AuthentikClient",
|
||||
"get_authentik_client",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Base classes for Core-API
|
||||
|
||||
Provides common base classes for controllers and schemas.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from abc import ABC, abstractmethod
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class BaseController(ABC):
|
||||
"""
|
||||
Base controller class with common functionality
|
||||
|
||||
All controllers should inherit from this class and implement
|
||||
the create_router() method to define their endpoints.
|
||||
"""
|
||||
|
||||
def __init__(self, prefix: str, tags: list[str]):
|
||||
"""
|
||||
Initialize base controller
|
||||
|
||||
Args:
|
||||
prefix: URL prefix for this controller's routes
|
||||
tags: OpenAPI tags for documentation grouping
|
||||
"""
|
||||
self.prefix = prefix
|
||||
self.tags = tags
|
||||
self._router = None
|
||||
|
||||
@abstractmethod
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the FastAPI router for this controller"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def router(self) -> APIRouter:
|
||||
"""Get the router instance, creating it if needed"""
|
||||
if self._router is None:
|
||||
self._router = self.create_router()
|
||||
return self._router
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""
|
||||
Base Pydantic model with standardized configuration
|
||||
|
||||
All schemas should inherit from this to ensure consistent behavior.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
strict=False,
|
||||
populate_by_name=True,
|
||||
use_enum_values=True,
|
||||
validate_assignment=True,
|
||||
json_encoders={
|
||||
datetime: lambda v: v.isoformat() if v else None
|
||||
}
|
||||
)
|
||||
|
||||
def dict_without_none(self) -> dict[str, Any]:
|
||||
"""Return model as dict, excluding None values"""
|
||||
return {k: v for k, v in self.model_dump().items() if v is not None}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
API Clients for Core-API
|
||||
|
||||
Provides HTTP/WebSocket clients for external infrastructure services.
|
||||
"""
|
||||
from src.shared.clients.portainer_client import PortainerClient, get_portainer_client
|
||||
from src.shared.clients.npm_client import NPMClient, get_npm_client
|
||||
from src.shared.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client
|
||||
from src.shared.clients.authentik_client import AuthentikClient, get_authentik_client
|
||||
|
||||
__all__ = [
|
||||
"PortainerClient",
|
||||
"get_portainer_client",
|
||||
"NPMClient",
|
||||
"get_npm_client",
|
||||
"HomeAssistantClient",
|
||||
"get_homeassistant_client",
|
||||
"AuthentikClient",
|
||||
"get_authentik_client",
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Authentik API Client
|
||||
|
||||
Provides methods for interacting with Authentik Identity Provider API.
|
||||
Used for managing applications, providers, and authentication flows.
|
||||
"""
|
||||
import httpx
|
||||
from typing import Dict, List, Any, Optional
|
||||
from functools import lru_cache
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AuthentikClient:
|
||||
"""Client for Authentik API operations"""
|
||||
|
||||
def __init__(self, base_url: str, api_token: str):
|
||||
"""
|
||||
Initialize Authentik client
|
||||
|
||||
Args:
|
||||
base_url: Authentik base URL (e.g., http://authentik-server:9000)
|
||||
api_token: API token for authentication
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_token = api_token
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
|
||||
"""Make authenticated API request using token auth"""
|
||||
headers = kwargs.pop("headers", {})
|
||||
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||
|
||||
response = await self.client.request(
|
||||
method,
|
||||
f"{self.base_url}/api/v3/{endpoint.lstrip('/')}",
|
||||
headers=headers,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if not response.is_success:
|
||||
logger.error(f"API request failed: {response.status_code}")
|
||||
logger.error(f"Response body: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Authentik is accessible"""
|
||||
try:
|
||||
response = await self.client.get(f"{self.base_url}/-/health/live/")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Authentik health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def create_oauth2_provider(
|
||||
self,
|
||||
name: str,
|
||||
client_id: str,
|
||||
redirect_uris: List[str],
|
||||
authorization_flow_slug: str = "default-provider-authorization-implicit-consent",
|
||||
signing_key: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create an OAuth2/OIDC provider
|
||||
|
||||
Args:
|
||||
name: Provider name
|
||||
client_id: OAuth2 client ID
|
||||
redirect_uris: List of allowed redirect URIs
|
||||
authorization_flow_slug: Authorization flow slug (will be resolved to UUID)
|
||||
signing_key: Signing key UUID (defaults to auto-selected)
|
||||
|
||||
Returns:
|
||||
Created provider data including client_secret
|
||||
"""
|
||||
# Get authorization flow UUID from slug
|
||||
flows = await self.list_flows()
|
||||
auth_flow_uuid = None
|
||||
invalidation_flow_uuid = None
|
||||
|
||||
for flow in flows:
|
||||
if flow.get("slug") == authorization_flow_slug:
|
||||
auth_flow_uuid = flow.get("pk")
|
||||
if flow.get("slug") == "default-provider-invalidation-flow":
|
||||
invalidation_flow_uuid = flow.get("pk")
|
||||
|
||||
if not auth_flow_uuid:
|
||||
raise ValueError(f"Authorization flow '{authorization_flow_slug}' not found")
|
||||
if not invalidation_flow_uuid:
|
||||
raise ValueError("Invalidation flow not found")
|
||||
|
||||
# Get signing key if not provided
|
||||
if not signing_key:
|
||||
keys = await self._request("GET", "crypto/certificatekeypairs/")
|
||||
# Find the self-signed cert
|
||||
for key in keys.get("results", []):
|
||||
if "authentik" in key.get("name", "").lower():
|
||||
signing_key = key.get("pk")
|
||||
break
|
||||
|
||||
if not signing_key and keys.get("results"):
|
||||
signing_key = keys["results"][0]["pk"]
|
||||
|
||||
# Format redirect URIs as objects with matching_mode
|
||||
formatted_redirect_uris = [
|
||||
{"url": uri, "matching_mode": "strict"}
|
||||
for uri in redirect_uris
|
||||
]
|
||||
|
||||
provider_data = {
|
||||
"name": name,
|
||||
"authorization_flow": auth_flow_uuid,
|
||||
"invalidation_flow": invalidation_flow_uuid,
|
||||
"client_type": "confidential",
|
||||
"client_id": client_id,
|
||||
"redirect_uris": formatted_redirect_uris,
|
||||
"signing_key": signing_key,
|
||||
"sub_mode": "hashed_user_id",
|
||||
"include_claims_in_id_token": True,
|
||||
"issuer_mode": "per_provider",
|
||||
"access_token_validity": "minutes=60",
|
||||
"refresh_token_validity": "days=30",
|
||||
"property_mappings": [] # Will use default mappings
|
||||
}
|
||||
|
||||
result = await self._request("POST", "providers/oauth2/", json=provider_data)
|
||||
logger.info(f"Created OAuth2 provider: {name} (ID: {result.get('pk')})")
|
||||
return result
|
||||
|
||||
async def create_application(
|
||||
self,
|
||||
name: str,
|
||||
slug: str,
|
||||
provider_pk: int,
|
||||
launch_url: Optional[str] = None,
|
||||
icon_url: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create an application
|
||||
|
||||
Args:
|
||||
name: Application display name
|
||||
slug: Application slug (URL-safe identifier)
|
||||
provider_pk: Primary key of the provider to use
|
||||
launch_url: Optional launch URL
|
||||
icon_url: Optional icon URL
|
||||
|
||||
Returns:
|
||||
Created application data
|
||||
"""
|
||||
app_data = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"provider": provider_pk,
|
||||
"meta_launch_url": launch_url or "",
|
||||
"meta_icon": icon_url or "",
|
||||
"policy_engine_mode": "any",
|
||||
"open_in_new_tab": False
|
||||
}
|
||||
|
||||
result = await self._request("POST", "core/applications/", json=app_data)
|
||||
logger.info(f"Created application: {name} (slug: {slug})")
|
||||
return result
|
||||
|
||||
async def get_provider_by_name(self, name: str) -> Optional[Dict]:
|
||||
"""Get OAuth2 provider by name"""
|
||||
providers = await self._request("GET", "providers/oauth2/", params={"name": name})
|
||||
results = providers.get("results", [])
|
||||
return results[0] if results else None
|
||||
|
||||
async def get_application_by_slug(self, slug: str) -> Optional[Dict]:
|
||||
"""Get application by slug"""
|
||||
apps = await self._request("GET", "core/applications/", params={"slug": slug})
|
||||
results = apps.get("results", [])
|
||||
return results[0] if results else None
|
||||
|
||||
async def list_flows(self) -> List[Dict]:
|
||||
"""List all authentication flows"""
|
||||
result = await self._request("GET", "flows/instances/")
|
||||
return result.get("results", [])
|
||||
|
||||
async def create_proxy_provider(
|
||||
self,
|
||||
name: str,
|
||||
external_host: str,
|
||||
authorization_flow_slug: str = "default-provider-authorization-implicit-consent",
|
||||
mode: str = "forward_single",
|
||||
token_validity: int = 480 # 8 hours in minutes
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a Proxy Provider for forward authentication
|
||||
|
||||
Args:
|
||||
name: Provider name
|
||||
external_host: External URL (e.g., https://auth.schweitz.net)
|
||||
authorization_flow_slug: Authorization flow slug
|
||||
mode: Proxy mode (forward_single for forward auth)
|
||||
token_validity: Token validity in minutes (default: 480 = 8 hours)
|
||||
|
||||
Returns:
|
||||
Created provider data
|
||||
"""
|
||||
# Get authorization flow UUID from slug
|
||||
flows = await self.list_flows()
|
||||
auth_flow_uuid = None
|
||||
invalidation_flow_uuid = None
|
||||
|
||||
for flow in flows:
|
||||
if flow.get("slug") == authorization_flow_slug:
|
||||
auth_flow_uuid = flow.get("pk")
|
||||
if flow.get("slug") == "default-provider-invalidation-flow":
|
||||
invalidation_flow_uuid = flow.get("pk")
|
||||
|
||||
if not auth_flow_uuid:
|
||||
raise ValueError(f"Authorization flow '{authorization_flow_slug}' not found")
|
||||
if not invalidation_flow_uuid:
|
||||
raise ValueError("Invalidation flow not found")
|
||||
|
||||
provider_data = {
|
||||
"name": name,
|
||||
"authorization_flow": auth_flow_uuid,
|
||||
"invalidation_flow": invalidation_flow_uuid,
|
||||
"mode": mode,
|
||||
"external_host": external_host,
|
||||
"access_token_validity": f"minutes={token_validity}",
|
||||
"refresh_token_validity": f"minutes={token_validity}",
|
||||
"session_duration": f"seconds={token_validity * 60}",
|
||||
"cookie_domain": "", # Will use the domain of each proxied site
|
||||
"property_mappings": []
|
||||
}
|
||||
|
||||
result = await self._request("POST", "providers/proxy/", json=provider_data)
|
||||
logger.info(f"Created Proxy provider: {name} (ID: {result.get('pk')})")
|
||||
return result
|
||||
|
||||
async def get_provider_by_name_proxy(self, name: str) -> Optional[Dict]:
|
||||
"""Get Proxy provider by name"""
|
||||
providers = await self._request("GET", "providers/proxy/", params={"name": name})
|
||||
results = providers.get("results", [])
|
||||
return results[0] if results else None
|
||||
|
||||
async def create_outpost(
|
||||
self,
|
||||
name: str,
|
||||
type: str,
|
||||
providers: List[int],
|
||||
config: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create an Authentik Outpost
|
||||
|
||||
Args:
|
||||
name: Outpost name
|
||||
type: Outpost type (e.g., "proxy")
|
||||
providers: List of provider PKs
|
||||
config: Optional configuration overrides
|
||||
|
||||
Returns:
|
||||
Created outpost data
|
||||
"""
|
||||
outpost_data = {
|
||||
"name": name,
|
||||
"type": type,
|
||||
"providers": providers,
|
||||
"config": config or {},
|
||||
"service_connection": None # Will use local Docker
|
||||
}
|
||||
|
||||
result = await self._request("POST", "outposts/instances/", json=outpost_data)
|
||||
logger.info(f"Created outpost: {name} (ID: {result.get('pk')})")
|
||||
return result
|
||||
|
||||
async def get_outpost_by_name(self, name: str) -> Optional[Dict]:
|
||||
"""Get outpost by name"""
|
||||
outposts = await self._request("GET", "outposts/instances/", params={"name": name})
|
||||
results = outposts.get("results", [])
|
||||
return results[0] if results else None
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_authentik_client() -> AuthentikClient:
|
||||
"""Get cached Authentik client instance"""
|
||||
# Import credentials from gitignored module
|
||||
try:
|
||||
from src.credentials import AUTHENTIK_URL, AUTHENTIK_CORE_API_TOKEN
|
||||
except ImportError:
|
||||
# Fallback to environment variables if credentials.py doesn't exist
|
||||
import os
|
||||
AUTHENTIK_URL = os.getenv("AUTHENTIK_URL", "http://authentik-server:9000")
|
||||
AUTHENTIK_CORE_API_TOKEN = os.getenv("AUTHENTIK_API_TOKEN", "")
|
||||
|
||||
return AuthentikClient(
|
||||
base_url=AUTHENTIK_URL,
|
||||
api_token=AUTHENTIK_CORE_API_TOKEN
|
||||
)
|
||||
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Home Assistant REST API Client
|
||||
|
||||
Provides interface to Home Assistant REST API for home automation control.
|
||||
Uses long-lived access token authentication.
|
||||
API Reference: https://developers.home-assistant.io/docs/api/rest/
|
||||
"""
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from src.shared.logging import get_logger
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class HomeAssistantClient:
|
||||
"""
|
||||
HTTP client for Home Assistant REST API
|
||||
|
||||
Uses long-lived access token authentication via Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
timeout: int = 30
|
||||
):
|
||||
"""
|
||||
Initialize Home Assistant client
|
||||
|
||||
Args:
|
||||
base_url: Home Assistant base URL (default from settings)
|
||||
token: Long-lived access token (default from settings)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
|
||||
self.token = token or settings.homeassistant_token
|
||||
self.timeout = timeout
|
||||
|
||||
if not self.token:
|
||||
logger.warning("Home Assistant token not configured")
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get request headers with Bearer token authentication"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
# Health & Discovery
|
||||
# ========================================================================
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Check Home Assistant API connectivity and get version info
|
||||
|
||||
HA Endpoint: GET /api/
|
||||
|
||||
Returns:
|
||||
Dict with connected status, platform name, and version
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"connected": True,
|
||||
"platform": "home_assistant",
|
||||
"version": data.get("version", "unknown")
|
||||
}
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"connected": False,
|
||||
"platform": "home_assistant",
|
||||
"error": f"HTTP {response.status_code}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Home Assistant health check failed: {e}")
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"connected": False,
|
||||
"platform": "home_assistant",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def get_states(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all entity states
|
||||
|
||||
HA Endpoint: GET /api/states
|
||||
|
||||
Returns:
|
||||
List of all entity states
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_state(self, entity_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get state of a specific entity
|
||||
|
||||
HA Endpoint: GET /api/states/<entity_id>
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (e.g., "light.living_room")
|
||||
|
||||
Returns:
|
||||
Entity state dict or None if not found
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states/{entity_id}",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get Home Assistant configuration (includes areas)
|
||||
|
||||
HA Endpoint: GET /api/config
|
||||
|
||||
Returns:
|
||||
Configuration dict including components, location, etc.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/config",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================================================
|
||||
# Device Control
|
||||
# ========================================================================
|
||||
|
||||
async def call_service(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
entity_id: Optional[str] = None,
|
||||
service_data: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Call a Home Assistant service
|
||||
|
||||
HA Endpoint: POST /api/services/<domain>/<service>
|
||||
|
||||
Args:
|
||||
domain: Service domain (e.g., "light", "switch", "scene")
|
||||
service: Service name (e.g., "turn_on", "turn_off", "toggle")
|
||||
entity_id: Target entity ID (optional for some services)
|
||||
service_data: Additional service data/attributes
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
payload = service_data.copy() if service_data else {}
|
||||
if entity_id:
|
||||
payload["entity_id"] = entity_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/services/{domain}/{service}",
|
||||
headers=self._get_headers(),
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def turn_on(
|
||||
self,
|
||||
entity_id: str,
|
||||
**attributes
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Turn on an entity with optional attributes
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (e.g., "light.living_room")
|
||||
**attributes: Additional attributes (brightness, color_temp, etc.)
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="turn_on",
|
||||
entity_id=entity_id,
|
||||
service_data=attributes if attributes else None
|
||||
)
|
||||
|
||||
async def turn_off(self, entity_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Turn off an entity
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="turn_off",
|
||||
entity_id=entity_id
|
||||
)
|
||||
|
||||
async def toggle(self, entity_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Toggle an entity
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="toggle",
|
||||
entity_id=entity_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scenes
|
||||
# ========================================================================
|
||||
|
||||
async def activate_scene(self, scene_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Activate a scene
|
||||
|
||||
Args:
|
||||
scene_id: Scene entity ID (e.g., "scene.movie_night")
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="scene",
|
||||
service="turn_on",
|
||||
entity_id=scene_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scripts
|
||||
# ========================================================================
|
||||
|
||||
async def run_script(
|
||||
self,
|
||||
script_id: str,
|
||||
variables: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a script with optional variables
|
||||
|
||||
Args:
|
||||
script_id: Script entity ID (e.g., "script.bedtime_routine")
|
||||
variables: Script variables
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
service_data = {"variables": variables} if variables else None
|
||||
return await self.call_service(
|
||||
domain="script",
|
||||
service="turn_on",
|
||||
entity_id=script_id,
|
||||
service_data=service_data
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Automations
|
||||
# ========================================================================
|
||||
|
||||
async def enable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Enable an automation
|
||||
|
||||
Args:
|
||||
automation_id: Automation entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="automation",
|
||||
service="turn_on",
|
||||
entity_id=automation_id
|
||||
)
|
||||
|
||||
async def disable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Disable an automation
|
||||
|
||||
Args:
|
||||
automation_id: Automation entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="automation",
|
||||
service="turn_off",
|
||||
entity_id=automation_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# History
|
||||
# ========================================================================
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
entity_id: str,
|
||||
hours: int = 24
|
||||
) -> List[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get state history for an entity
|
||||
|
||||
HA Endpoint: GET /api/history/period/<timestamp>
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID to get history for
|
||||
hours: Number of hours of history (default 24)
|
||||
|
||||
Returns:
|
||||
List of state history entries
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
timestamp = start_time.isoformat()
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/history/period/{timestamp}",
|
||||
headers=self._get_headers(),
|
||||
params={
|
||||
"filter_entity_id": entity_id,
|
||||
"minimal_response": "true"
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================================================
|
||||
# Areas (via template API)
|
||||
# ========================================================================
|
||||
|
||||
async def get_areas(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get all areas/rooms
|
||||
|
||||
Note: The REST API doesn't have a direct areas endpoint.
|
||||
This uses the template API to render area data.
|
||||
|
||||
HA Endpoint: POST /api/template
|
||||
|
||||
Returns:
|
||||
List of area dicts with id and name
|
||||
"""
|
||||
template = """
|
||||
{% set areas_list = [] %}
|
||||
{% for area in areas() %}
|
||||
{% set areas_list = areas_list + [{"id": area, "name": area_name(area)}] %}
|
||||
{% endfor %}
|
||||
{{ areas_list | tojson }}
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/template",
|
||||
headers=self._get_headers(),
|
||||
json={"template": template}
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Response is rendered template as string
|
||||
return json.loads(response.text)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_homeassistant_client: Optional[HomeAssistantClient] = None
|
||||
|
||||
|
||||
def get_homeassistant_client() -> HomeAssistantClient:
|
||||
"""Get singleton Home Assistant client instance"""
|
||||
global _homeassistant_client
|
||||
if _homeassistant_client is None:
|
||||
_homeassistant_client = HomeAssistantClient()
|
||||
return _homeassistant_client
|
||||
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Nginx Proxy Manager API Client
|
||||
|
||||
Provides interface to NPM REST API for proxy host and SSL certificate management.
|
||||
"""
|
||||
import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta
|
||||
from src.shared.logging import get_logger
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class NPMClient:
|
||||
"""
|
||||
HTTP client for Nginx Proxy Manager API
|
||||
|
||||
Uses JWT Bearer token authentication with automatic token refresh.
|
||||
Tokens expire after ~24 hours.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
timeout: int = 30
|
||||
):
|
||||
"""
|
||||
Initialize NPM client
|
||||
|
||||
Args:
|
||||
base_url: NPM base URL (default from settings)
|
||||
email: NPM admin email (default from settings)
|
||||
password: NPM admin password (default from settings)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = (base_url or settings.npm_url).rstrip("/")
|
||||
self.email = email or settings.npm_email
|
||||
self.password = password or settings.npm_password
|
||||
self.timeout = timeout
|
||||
|
||||
self._token: Optional[str] = None
|
||||
self._token_expires: Optional[datetime] = None
|
||||
|
||||
if not self.email or not self.password:
|
||||
logger.warning("NPM credentials not configured")
|
||||
|
||||
async def _ensure_token(self):
|
||||
"""Ensure we have a valid token, refresh if needed"""
|
||||
if self._token and self._token_expires:
|
||||
# If token expires in less than 1 hour, refresh it
|
||||
if datetime.now() + timedelta(hours=1) < self._token_expires:
|
||||
return
|
||||
|
||||
# Get new token
|
||||
await self._refresh_token()
|
||||
|
||||
async def _refresh_token(self):
|
||||
"""Get a new authentication token"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/tokens",
|
||||
json={
|
||||
"identity": self.email,
|
||||
"secret": self.password
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
self._token = data.get("token")
|
||||
# Assume 23-hour expiration to be safe
|
||||
self._token_expires = datetime.now() + timedelta(hours=23)
|
||||
|
||||
logger.info("NPM token refreshed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh NPM token: {e}")
|
||||
raise
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get request headers with authentication"""
|
||||
if not self._token:
|
||||
raise RuntimeError("No NPM token available. Call _ensure_token() first.")
|
||||
|
||||
return {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if NPM API is accessible
|
||||
|
||||
Returns:
|
||||
True if accessible, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
response = await client.get(f"{self.base_url}/api")
|
||||
# Accept any successful response (2xx) or redirect (3xx) as healthy
|
||||
# A redirect indicates the service is up and responding
|
||||
return 200 <= response.status_code < 400
|
||||
except Exception as e:
|
||||
logger.error(f"NPM health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_proxy_hosts(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all proxy hosts
|
||||
|
||||
Returns:
|
||||
List of proxy host configurations
|
||||
"""
|
||||
await self._ensure_token()
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/nginx/proxy-hosts",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_proxy_host(self, host_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get details of a specific proxy host
|
||||
|
||||
Args:
|
||||
host_id: Proxy host identifier
|
||||
|
||||
Returns:
|
||||
Proxy host configuration
|
||||
"""
|
||||
await self._ensure_token()
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/nginx/proxy-hosts/{host_id}",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def create_proxy_host(
|
||||
self,
|
||||
domain_names: List[str],
|
||||
forward_host: str,
|
||||
forward_port: int,
|
||||
forward_scheme: str = "http",
|
||||
certificate_id: int = 0,
|
||||
ssl_forced: bool = False,
|
||||
block_exploits: bool = True,
|
||||
caching_enabled: bool = True,
|
||||
websocket_upgrade: bool = True,
|
||||
http2_support: bool = True,
|
||||
hsts_enabled: bool = True,
|
||||
advanced_config: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new proxy host
|
||||
|
||||
Args:
|
||||
domain_names: List of domain names for this proxy
|
||||
forward_host: Target host to proxy to
|
||||
forward_port: Target port to proxy to
|
||||
forward_scheme: http or https
|
||||
certificate_id: SSL certificate ID (0 for none)
|
||||
ssl_forced: Force HTTPS redirect
|
||||
block_exploits: Enable exploit blocking
|
||||
caching_enabled: Enable response caching
|
||||
websocket_upgrade: Allow WebSocket upgrades
|
||||
http2_support: Enable HTTP/2
|
||||
hsts_enabled: Enable HSTS headers
|
||||
advanced_config: Custom nginx configuration
|
||||
|
||||
Returns:
|
||||
Created proxy host details
|
||||
"""
|
||||
await self._ensure_token()
|
||||
|
||||
payload = {
|
||||
"domain_names": domain_names,
|
||||
"forward_scheme": forward_scheme,
|
||||
"forward_host": forward_host,
|
||||
"forward_port": forward_port,
|
||||
"certificate_id": certificate_id,
|
||||
"ssl_forced": ssl_forced,
|
||||
"block_exploits": block_exploits,
|
||||
"caching_enabled": caching_enabled,
|
||||
"allow_websocket_upgrade": websocket_upgrade,
|
||||
"http2_support": http2_support,
|
||||
"hsts_enabled": hsts_enabled,
|
||||
"hsts_subdomains": False,
|
||||
"advanced_config": advanced_config,
|
||||
"access_list_id": 0,
|
||||
"meta": {}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/nginx/proxy-hosts",
|
||||
headers=self._get_headers(),
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def update_proxy_host(
|
||||
self,
|
||||
proxy_id: int,
|
||||
config: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update an existing proxy host configuration
|
||||
|
||||
Args:
|
||||
proxy_id: Proxy host ID to update
|
||||
config: Full proxy host configuration (get from get_proxy_host, modify, then update)
|
||||
|
||||
Returns:
|
||||
Updated proxy host details
|
||||
"""
|
||||
await self._ensure_token()
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.put(
|
||||
f"{self.base_url}/api/nginx/proxy-hosts/{proxy_id}",
|
||||
headers=self._get_headers(),
|
||||
json=config
|
||||
)
|
||||
|
||||
if not response.is_success:
|
||||
logger.error(f"Update failed: {response.status_code}")
|
||||
logger.error(f"Response: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def enable_authentik_forward_auth(
|
||||
self,
|
||||
proxy_id: int,
|
||||
authentik_url: str = "http://authentik-server:9000"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Enable Authentik forward authentication on a proxy host
|
||||
|
||||
Args:
|
||||
proxy_id: Proxy host ID to update
|
||||
authentik_url: Authentik server URL (default: http://authentik-server:9000)
|
||||
|
||||
Returns:
|
||||
Updated proxy host details
|
||||
"""
|
||||
# Get current config
|
||||
proxy_host = await self.get_proxy_host(proxy_id)
|
||||
|
||||
# Authentik forward auth configuration
|
||||
auth_config = f"""# Authentik Forward Authentication
|
||||
# Send authentication requests to Authentik
|
||||
auth_request /outpost.goauthentik.io/auth/nginx;
|
||||
|
||||
# Preserve authentication cookies
|
||||
auth_request_set $auth_cookie $upstream_http_set_cookie;
|
||||
add_header Set-Cookie $auth_cookie;
|
||||
|
||||
# Get user information from Authentik
|
||||
auth_request_set $authentik_username $upstream_http_x_authentik_username;
|
||||
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
|
||||
auth_request_set $authentik_email $upstream_http_x_authentik_email;
|
||||
auth_request_set $authentik_name $upstream_http_x_authentik_name;
|
||||
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
|
||||
|
||||
# Pass user info to backend
|
||||
proxy_set_header X-authentik-username $authentik_username;
|
||||
proxy_set_header X-authentik-groups $authentik_groups;
|
||||
proxy_set_header X-authentik-email $authentik_email;
|
||||
proxy_set_header X-authentik-name $authentik_name;
|
||||
proxy_set_header X-authentik-uid $authentik_uid;
|
||||
|
||||
# On authentication failure, redirect to Authentik login
|
||||
error_page 401 = @authentik_proxy_signin;
|
||||
|
||||
location @authentik_proxy_signin {{
|
||||
internal;
|
||||
add_header Set-Cookie $auth_cookie;
|
||||
return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
|
||||
}}
|
||||
|
||||
# Authentik authentication endpoint
|
||||
location /outpost.goauthentik.io {{
|
||||
proxy_pass {authentik_url}/outpost.goauthentik.io;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
}}
|
||||
"""
|
||||
|
||||
# Update the advanced config
|
||||
proxy_host["advanced_config"] = auth_config
|
||||
|
||||
# Remove read-only fields that NPM doesn't accept in updates
|
||||
readonly_fields = [
|
||||
"id", "created_on", "modified_on", "owner", "owner_user_id",
|
||||
"certificate", "use_default_location", "ipv6", "meta", "nginx_online",
|
||||
"nginx_err", "access_list", "certificate_id"
|
||||
]
|
||||
|
||||
clean_config = {k: v for k, v in proxy_host.items() if k not in readonly_fields}
|
||||
|
||||
# Ensure locations is an array (required field)
|
||||
if "locations" not in clean_config or clean_config["locations"] is None:
|
||||
clean_config["locations"] = []
|
||||
|
||||
# Update the proxy host
|
||||
return await self.update_proxy_host(proxy_id, clean_config)
|
||||
|
||||
async def get_certificates(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all SSL certificates
|
||||
|
||||
Returns:
|
||||
List of certificate details
|
||||
"""
|
||||
await self._ensure_token()
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/nginx/certificates",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def create_certificate(
|
||||
self,
|
||||
domain_names: List[str],
|
||||
provider: str = "letsencrypt"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Request a new SSL certificate from Let's Encrypt
|
||||
|
||||
Args:
|
||||
domain_names: List of domains for the certificate
|
||||
provider: Certificate provider (default: letsencrypt)
|
||||
|
||||
Returns:
|
||||
Certificate details
|
||||
"""
|
||||
await self._ensure_token()
|
||||
|
||||
payload = {
|
||||
"provider": provider,
|
||||
"domain_names": domain_names,
|
||||
"meta": {
|
||||
"dns_challenge": False
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/nginx/certificates",
|
||||
headers=self._get_headers(),
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_npm_client: Optional[NPMClient] = None
|
||||
|
||||
|
||||
def get_npm_client() -> NPMClient:
|
||||
"""Get singleton NPM client instance"""
|
||||
global _npm_client
|
||||
if _npm_client is None:
|
||||
_npm_client = NPMClient()
|
||||
return _npm_client
|
||||
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
Portainer API Client
|
||||
|
||||
Provides interface to Portainer REST API for stack and container management.
|
||||
Includes fallback to Docker socket for containers not managed by Portainer.
|
||||
"""
|
||||
import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
from src.shared.logging import get_logger
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class PortainerClient:
|
||||
"""
|
||||
HTTP client for Portainer API
|
||||
|
||||
Uses access token authentication (X-API-Key header)
|
||||
for long-lived API access without session management.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 30
|
||||
):
|
||||
"""
|
||||
Initialize Portainer client
|
||||
|
||||
Args:
|
||||
base_url: Portainer base URL (default from settings)
|
||||
api_key: Portainer API access token (default from settings)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = (base_url or settings.portainer_url).rstrip("/")
|
||||
self.api_key = api_key or settings.portainer_api_key
|
||||
self.timeout = timeout
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("Portainer API key not configured")
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get request headers with authentication"""
|
||||
return {
|
||||
"X-API-Key": self.api_key,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Portainer API is accessible
|
||||
|
||||
Returns:
|
||||
True if accessible, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(f"{self.base_url}/api/status")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Portainer health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_endpoints(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all Portainer endpoints (Docker environments)
|
||||
|
||||
Returns:
|
||||
List of endpoint configurations
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/endpoints",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_stacks(self, endpoint_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all stacks
|
||||
|
||||
Args:
|
||||
endpoint_id: Filter by specific endpoint (optional)
|
||||
|
||||
Returns:
|
||||
List of stack configurations
|
||||
"""
|
||||
params = {}
|
||||
if endpoint_id:
|
||||
params["endpointId"] = endpoint_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/stacks",
|
||||
headers=self._get_headers(),
|
||||
params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_stack(self, stack_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get details of a specific stack
|
||||
|
||||
Args:
|
||||
stack_id: Stack identifier
|
||||
|
||||
Returns:
|
||||
Stack configuration details
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/stacks/{stack_id}",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def create_stack(
|
||||
self,
|
||||
name: str,
|
||||
stack_file_content: str,
|
||||
endpoint_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new stack from compose file content
|
||||
|
||||
Args:
|
||||
name: Stack name
|
||||
stack_file_content: Docker Compose YAML content
|
||||
endpoint_id: Portainer endpoint to deploy to
|
||||
|
||||
Returns:
|
||||
Created stack details
|
||||
"""
|
||||
payload = {
|
||||
"name": name,
|
||||
"stackFileContent": stack_file_content
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/stacks/create/standalone/string",
|
||||
headers=self._get_headers(),
|
||||
params={"endpointId": endpoint_id},
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def update_stack(
|
||||
self,
|
||||
stack_id: int,
|
||||
stack_file_content: str,
|
||||
endpoint_id: int,
|
||||
prune: bool = False,
|
||||
pull_image: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update an existing stack
|
||||
|
||||
Args:
|
||||
stack_id: Stack identifier
|
||||
stack_file_content: New Docker Compose YAML content
|
||||
endpoint_id: Portainer endpoint
|
||||
prune: Remove services no longer defined
|
||||
pull_image: Pull latest images before deployment
|
||||
|
||||
Returns:
|
||||
Updated stack details
|
||||
"""
|
||||
payload = {
|
||||
"stackFileContent": stack_file_content,
|
||||
"prune": prune,
|
||||
"pullImage": pull_image
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.put(
|
||||
f"{self.base_url}/api/stacks/{stack_id}",
|
||||
headers=self._get_headers(),
|
||||
params={"endpointId": endpoint_id},
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def delete_stack(self, stack_id: int, endpoint_id: int) -> bool:
|
||||
"""
|
||||
Delete a stack
|
||||
|
||||
Args:
|
||||
stack_id: Stack identifier
|
||||
endpoint_id: Portainer endpoint
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.delete(
|
||||
f"{self.base_url}/api/stacks/{stack_id}",
|
||||
headers=self._get_headers(),
|
||||
params={"endpointId": endpoint_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
async def get_stack_file(self, stack_id: int) -> str:
|
||||
"""
|
||||
Get the compose file content for a stack
|
||||
|
||||
Args:
|
||||
stack_id: Stack identifier
|
||||
|
||||
Returns:
|
||||
Docker Compose YAML content as string
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/stacks/{stack_id}/file",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("StackFileContent", "")
|
||||
|
||||
async def redeploy_stack(
|
||||
self,
|
||||
stack_id: int,
|
||||
endpoint_id: int,
|
||||
pull_image: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Redeploy a stack with its current configuration
|
||||
|
||||
Args:
|
||||
stack_id: Stack identifier
|
||||
endpoint_id: Portainer endpoint
|
||||
pull_image: Pull latest images before deployment
|
||||
|
||||
Returns:
|
||||
Updated stack details
|
||||
"""
|
||||
# Get current stack file content
|
||||
stack_content = await self.get_stack_file(stack_id)
|
||||
|
||||
# Get current stack to preserve env vars
|
||||
stack = await self.get_stack(stack_id)
|
||||
env_vars = stack.get("Env", [])
|
||||
|
||||
payload = {
|
||||
"stackFileContent": stack_content,
|
||||
"env": env_vars,
|
||||
"prune": False,
|
||||
"pullImage": pull_image
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.put(
|
||||
f"{self.base_url}/api/stacks/{stack_id}",
|
||||
headers=self._get_headers(),
|
||||
params={"endpointId": endpoint_id},
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def update_stack_env(
|
||||
self,
|
||||
stack_id: int,
|
||||
endpoint_id: int,
|
||||
env_vars: List[Dict[str, str]]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update stack environment variables
|
||||
|
||||
Args:
|
||||
stack_id: Stack identifier
|
||||
endpoint_id: Portainer endpoint
|
||||
env_vars: List of {"name": "VAR_NAME", "value": "var_value"} dicts
|
||||
|
||||
Returns:
|
||||
Updated stack details
|
||||
"""
|
||||
# Get current stack file content (required for update)
|
||||
stack_content = await self.get_stack_file(stack_id)
|
||||
|
||||
payload = {
|
||||
"stackFileContent": stack_content,
|
||||
"env": env_vars,
|
||||
"prune": False,
|
||||
"pullImage": False
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.put(
|
||||
f"{self.base_url}/api/stacks/{stack_id}",
|
||||
headers=self._get_headers(),
|
||||
params={"endpointId": endpoint_id},
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def delete_container(
|
||||
self,
|
||||
endpoint_id: int,
|
||||
container_id: str,
|
||||
force: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a container
|
||||
|
||||
Args:
|
||||
endpoint_id: Portainer endpoint identifier
|
||||
container_id: Container ID or name
|
||||
force: Force remove running container
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
params = {"force": "true" if force else "false"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.delete(
|
||||
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}",
|
||||
headers=self._get_headers(),
|
||||
params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Deleted container {container_id}")
|
||||
return True
|
||||
|
||||
async def restart_container(self, endpoint_id: int, container_id: str) -> bool:
|
||||
"""
|
||||
Restart a container
|
||||
|
||||
Args:
|
||||
endpoint_id: Portainer endpoint identifier
|
||||
container_id: Container ID or name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/restart",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Restarted container {container_id}")
|
||||
return True
|
||||
|
||||
async def get_containers(self, endpoint_id: int, all_containers: bool = True) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List containers on a specific endpoint
|
||||
|
||||
Args:
|
||||
endpoint_id: Portainer endpoint identifier
|
||||
all_containers: Include stopped containers (default: True)
|
||||
|
||||
Returns:
|
||||
List of container details
|
||||
"""
|
||||
params = {"all": 1 if all_containers else 0}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/json",
|
||||
headers=self._get_headers(),
|
||||
params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_container(self, endpoint_id: int, container_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about a specific container
|
||||
|
||||
Args:
|
||||
endpoint_id: Portainer endpoint identifier
|
||||
container_id: Container ID or name
|
||||
|
||||
Returns:
|
||||
Container details including network and port information
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/json",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def stop_container(self, endpoint_id: int, container_id: str) -> bool:
|
||||
"""
|
||||
Stop a container
|
||||
|
||||
Args:
|
||||
endpoint_id: Portainer endpoint identifier
|
||||
container_id: Container ID or name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/stop",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Stopped container {container_id}")
|
||||
return True
|
||||
|
||||
async def start_container(self, endpoint_id: int, container_id: str) -> bool:
|
||||
"""
|
||||
Start a container
|
||||
|
||||
Args:
|
||||
endpoint_id: Portainer endpoint identifier
|
||||
container_id: Container ID or name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/start",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Started container {container_id}")
|
||||
return True
|
||||
|
||||
# ========================================================================
|
||||
# Helper methods for agent tools (auto-detect endpoint)
|
||||
# ========================================================================
|
||||
|
||||
async def list_containers(self, all_containers: bool = True) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List containers using auto-detected endpoint
|
||||
|
||||
This is a convenience wrapper that automatically uses the first/default endpoint.
|
||||
|
||||
Args:
|
||||
all_containers: Include stopped containers (default: True)
|
||||
|
||||
Returns:
|
||||
List of container details
|
||||
"""
|
||||
endpoints = await self.get_endpoints()
|
||||
if not endpoints:
|
||||
raise RuntimeError("No Portainer endpoints available")
|
||||
|
||||
endpoint_id = endpoints[0]["Id"]
|
||||
return await self.get_containers(endpoint_id, all_containers)
|
||||
|
||||
async def inspect_container(self, container_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Inspect a container by name using auto-detected endpoint
|
||||
|
||||
This is a convenience wrapper that automatically uses the first/default endpoint.
|
||||
|
||||
Args:
|
||||
container_name: Container name (e.g., "jellyfin", "ollama")
|
||||
|
||||
Returns:
|
||||
Container details or None if not found
|
||||
"""
|
||||
endpoints = await self.get_endpoints()
|
||||
if not endpoints:
|
||||
raise RuntimeError("No Portainer endpoints available")
|
||||
|
||||
endpoint_id = endpoints[0]["Id"]
|
||||
|
||||
# List all containers to find the one matching the name
|
||||
all_containers = await self.get_containers(endpoint_id, all_containers=True)
|
||||
|
||||
for container in all_containers:
|
||||
# Container names come as array like ['/jellyfin']
|
||||
names = container.get('Names', [])
|
||||
for name in names:
|
||||
clean_name = name.lstrip('/')
|
||||
if clean_name == container_name or clean_name.lower() == container_name.lower():
|
||||
# Get detailed info using container ID
|
||||
container_id = container['Id']
|
||||
return await self.get_container(endpoint_id, container_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_portainer_client: Optional[PortainerClient] = None
|
||||
|
||||
|
||||
def get_portainer_client() -> PortainerClient:
|
||||
"""Get singleton Portainer client instance"""
|
||||
global _portainer_client
|
||||
if _portainer_client is None:
|
||||
_portainer_client = PortainerClient()
|
||||
return _portainer_client
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Global configuration for Core Code API
|
||||
|
||||
All configuration is loaded from environment variables or .env file.
|
||||
See .env.example for available settings.
|
||||
"""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
def _get_version_from_pyproject() -> str:
|
||||
"""Load version from pyproject.toml"""
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return data.get("project", {}).get("version", "0.0.0")
|
||||
except FileNotFoundError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
__version__ = _get_version_from_pyproject()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Global application settings"""
|
||||
|
||||
# Application
|
||||
app_name: str = "Core Code API"
|
||||
app_version: str = __version__
|
||||
debug: bool = False
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8083
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["*"]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
# Logging
|
||||
log_level: str = "DEBUG"
|
||||
|
||||
# Ollama Configuration (for AI orchestration)
|
||||
ollama_base_url: str # Required - set OLLAMA_BASE_URL in .env
|
||||
ollama_timeout: int = 300 # 5 minutes
|
||||
|
||||
# Model Configuration
|
||||
default_model: str = "mistral-nemo-large:latest"
|
||||
agent_model: str = "mistral-nemo-large:latest"
|
||||
code_models: str = "mistral-nemo-large:latest"
|
||||
|
||||
# System Prompt Variant (for A/B testing)
|
||||
system_prompt_variant: str = "v8_holistic"
|
||||
|
||||
# Agent Configuration
|
||||
agent_fallback_enabled: bool = True
|
||||
|
||||
# Model Aliases (OpenAI → Local)
|
||||
alias_gpt35: str = "gemma:7b"
|
||||
alias_gpt4: str = "mistral:7b"
|
||||
alias_gpt4_turbo: str = "mixtral:8x7b"
|
||||
alias_gpt4_code: str = "codestral:latest"
|
||||
|
||||
# Memory Configuration
|
||||
memory_tier1_max_turns: int = 10
|
||||
memory_consolidation_threshold: int = 10
|
||||
|
||||
# Qdrant Configuration
|
||||
qdrant_host: str = "qdrant"
|
||||
qdrant_port: int = 6333
|
||||
qdrant_collection_conversations: str = "core_api_conversations"
|
||||
qdrant_collection_documents: str = "core_api_documents"
|
||||
qdrant_collection_user_facts: str = "core_api_user_facts"
|
||||
|
||||
# Embeddings (using Ollama)
|
||||
embedding_model: str = "nomic-embed-text"
|
||||
embedding_dimension: int = 768
|
||||
embedding_batch_size: int = 32
|
||||
|
||||
# Search Configuration
|
||||
search_provider: str = "searxng"
|
||||
searxng_url: str # Required - set SEARXNG_URL in .env
|
||||
|
||||
# Infrastructure Management (Portainer)
|
||||
portainer_url: str # Required
|
||||
portainer_api_key: str # Required
|
||||
|
||||
# Infrastructure Management (Nginx Proxy Manager)
|
||||
npm_url: str # Required
|
||||
npm_email: str # Required
|
||||
npm_password: str # Required
|
||||
|
||||
# Home Assistant Configuration
|
||||
homeassistant_url: str # Required
|
||||
homeassistant_token: str # Required
|
||||
homeassistant_timeout: int = 30
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres_host: str # Required
|
||||
postgres_user: str = "core_api"
|
||||
postgres_password: str # Required
|
||||
postgres_database: str = "core_api"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Construct database URL from components"""
|
||||
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}/{self.postgres_database}"
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
oidc_enabled: bool = False
|
||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
||||
oidc_audience: str = "core-api"
|
||||
|
||||
# Authentik API (for token validation and user management)
|
||||
authentik_url: str = "https://auth.schweitz.net"
|
||||
authentik_username: str = ""
|
||||
authentik_password: str = ""
|
||||
|
||||
@property
|
||||
def model_aliases(self) -> dict:
|
||||
"""Computed property for model aliases"""
|
||||
return {
|
||||
"gpt-3.5-turbo": self.alias_gpt35,
|
||||
"gpt-4": self.alias_gpt4,
|
||||
"gpt-4-turbo": self.alias_gpt4_turbo,
|
||||
"gpt-4-code": self.alias_gpt4_code,
|
||||
}
|
||||
|
||||
def get_code_models(self) -> list[str]:
|
||||
"""Parse comma-separated code models"""
|
||||
return [m.strip().strip('"').strip("'") for m in self.code_models.split(",") if m.strip()]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings instance"""
|
||||
return Settings()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Database Connection Module
|
||||
|
||||
Provides async PostgreSQL connectivity using SQLAlchemy 2.0 with asyncpg driver.
|
||||
"""
|
||||
from typing import AsyncGenerator, Optional
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
AsyncEngine,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""
|
||||
SQLAlchemy declarative base for all models
|
||||
|
||||
All database models should inherit from this class.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Database:
|
||||
"""
|
||||
Async database connection manager
|
||||
|
||||
Provides async engine and session factory for PostgreSQL connections.
|
||||
"""
|
||||
|
||||
def __init__(self, database_url: Optional[str] = None):
|
||||
"""Initialize database connection manager"""
|
||||
url = database_url or settings.database_url
|
||||
if url.startswith("postgresql://"):
|
||||
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
self._url = url
|
||||
self._engine: Optional[AsyncEngine] = None
|
||||
self._session_factory: Optional[async_sessionmaker[AsyncSession]] = None
|
||||
|
||||
@property
|
||||
def engine(self) -> AsyncEngine:
|
||||
"""Get or create the async database engine"""
|
||||
if self._engine is None:
|
||||
self._engine = create_async_engine(
|
||||
self._url,
|
||||
echo=settings.debug,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
logger.info(f"Database engine created for {self._url.split('@')[-1]}")
|
||||
return self._engine
|
||||
|
||||
@property
|
||||
def session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||
"""Get or create the async session factory"""
|
||||
if self._session_factory is None:
|
||||
self._session_factory = async_sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
return self._session_factory
|
||||
|
||||
async def create_tables(self) -> None:
|
||||
"""Create all database tables (dev/testing only)"""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Database tables created")
|
||||
|
||||
async def drop_tables(self) -> None:
|
||||
"""Drop all database tables (WARNING: destroys data)"""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
logger.warning("Database tables dropped")
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if database connection is healthy"""
|
||||
try:
|
||||
async with self.session_factory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close database connections"""
|
||||
if self._engine is not None:
|
||||
await self._engine.dispose()
|
||||
self._engine = None
|
||||
self._session_factory = None
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_database: Optional[Database] = None
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
"""Get singleton database instance"""
|
||||
global _database
|
||||
if _database is None:
|
||||
_database = Database()
|
||||
return _database
|
||||
|
||||
|
||||
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
FastAPI dependency for database sessions
|
||||
|
||||
Usage:
|
||||
@router.get("/items")
|
||||
async def get_items(session: AsyncSession = Depends(get_async_session)):
|
||||
result = await session.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
"""
|
||||
database = get_database()
|
||||
async with database.session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Logging configuration for Core Code API
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def setup_logging(log_level: str = "INFO") -> None:
|
||||
"""
|
||||
Configure logging for the application
|
||||
|
||||
Args:
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
"""
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(log_dir / "app.log", encoding="utf-8")
|
||||
]
|
||||
)
|
||||
|
||||
# Set specific log levels for third-party libraries
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance"""
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Security initialization module
|
||||
|
||||
Handles OIDC configuration and authentication setup
|
||||
"""
|
||||
from src.shared.config import Settings
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def initialize_oidc(settings: Settings) -> None:
|
||||
"""
|
||||
Initialize OIDC authentication configuration
|
||||
|
||||
Args:
|
||||
settings: Application settings containing OIDC configuration
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from src.auth.oidc import oidc_config
|
||||
|
||||
oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuer=settings.oidc_issuer,
|
||||
audience=settings.oidc_audience
|
||||
)
|
||||
|
||||
if settings.oidc_enabled:
|
||||
logger.info(f"OIDC authentication enabled (issuer: {settings.oidc_issuer})")
|
||||
else:
|
||||
logger.info("OIDC authentication disabled - API is publicly accessible")
|
||||
Reference in New Issue
Block a user