Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ help:
# Config
HOMESEC_CONFIG ?= config/config.yaml
HOMESEC_LOG_LEVEL ?= INFO
DB_DSN ?= postgresql+asyncpg://homesec:homesec@localhost:5432/homesec
DOCKER_IMAGE ?= homesec
DOCKER_TAG ?= latest
DOCKERHUB_USER ?= $(shell echo $${DOCKERHUB_USER:-})
Expand Down Expand Up @@ -61,8 +62,8 @@ docker-push: docker-build
# Local dev
run:
@echo "Running database migrations..."
@uv run alembic -c alembic.ini upgrade head
uv run python -m homesec.cli run --config $(HOMESEC_CONFIG) --log_level $(HOMESEC_LOG_LEVEL)
@DB_DSN=$(DB_DSN) uv run alembic -c alembic.ini upgrade head
DB_DSN=$(DB_DSN) uv run python -m homesec.cli run --config $(HOMESEC_CONFIG) --log_level $(HOMESEC_LOG_LEVEL)

db:
docker compose up -d postgres
Expand Down
1 change: 1 addition & 0 deletions src/homesec/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class APIErrorCode(StrEnum):
CAMERA_NOT_FOUND = "CAMERA_NOT_FOUND"
CAMERA_ALREADY_EXISTS = "CAMERA_ALREADY_EXISTS"
CAMERA_CONFIG_INVALID = "CAMERA_CONFIG_INVALID"
STORAGE_CONFIG_INVALID = "STORAGE_CONFIG_INVALID"
CLIP_NOT_FOUND = "CLIP_NOT_FOUND"
CLIP_MEDIA_UNAVAILABLE = "CLIP_MEDIA_UNAVAILABLE"
CLIP_MEDIA_FETCH_FAILED = "CLIP_MEDIA_FETCH_FAILED"
Expand Down
17 changes: 16 additions & 1 deletion src/homesec/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@
verify_api_key,
verify_media_access,
)
from homesec.api.routes import cameras, clips, config, health, media, onvif, runtime, setup, stats
from homesec.api.routes import (
cameras,
clips,
config,
health,
media,
onvif,
runtime,
setup,
stats,
storage,
)


def register_routes(app: FastAPI) -> None:
Expand All @@ -21,6 +32,10 @@ def register_routes(app: FastAPI) -> None:
config.router,
dependencies=[Depends(verify_api_key), Depends(require_normal_mode)],
)
app.include_router(
storage.router,
dependencies=[Depends(verify_api_key)],
)
app.include_router(
cameras.router,
dependencies=[Depends(verify_api_key), Depends(require_normal_mode)],
Expand Down
46 changes: 4 additions & 42 deletions src/homesec/api/routes/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@
from homesec.api.dependencies import get_homesec_app
from homesec.api.errors import APIError, APIErrorCode
from homesec.api.redaction import redact_config
from homesec.api.runtime_reload import RuntimeReloadResponse, reload_runtime_if_requested
from homesec.config.errors import (
CameraAlreadyExistsError,
CameraConfigInvalidError,
CameraMutationError,
CameraNotFoundError,
)
from homesec.models.config import CameraConfig
from homesec.runtime.errors import RuntimeReloadConfigError

if TYPE_CHECKING:
from homesec.app import Application
Expand Down Expand Up @@ -48,12 +48,6 @@ class CameraResponse(BaseModel):
source_config: dict[str, object]


class RuntimeReloadResponse(BaseModel):
accepted: bool
message: str
target_generation: int


class ConfigChangeResponse(BaseModel):
restart_required: bool = True
camera: CameraResponse | None = None
Expand Down Expand Up @@ -116,38 +110,6 @@ def _map_camera_config_error(exc: CameraMutationError) -> APIError:
)


async def _reload_runtime_if_requested(
*,
apply_changes: bool,
app: Application,
) -> RuntimeReloadResponse | None:
if not apply_changes:
return None

try:
request = await app.request_runtime_reload()
except RuntimeReloadConfigError as exc:
raise APIError(
str(exc),
status_code=exc.status_code,
error_code=exc.error_code,
) from exc

if not request.accepted:
raise APIError(
request.message,
status_code=status.HTTP_409_CONFLICT,
error_code=APIErrorCode.RELOAD_IN_PROGRESS,
extra={"target_generation": request.target_generation},
)

return RuntimeReloadResponse(
accepted=True,
message="Runtime reload accepted",
target_generation=request.target_generation,
)


@router.get("/api/v1/cameras", response_model=list[CameraResponse])
async def list_cameras(app: Application = Depends(get_homesec_app)) -> list[CameraResponse]:
"""List all cameras."""
Expand Down Expand Up @@ -188,7 +150,7 @@ async def create_camera(

config = await asyncio.to_thread(app.config_manager.get_config)
camera = next((cam for cam in config.cameras if cam.name == payload.name), None)
runtime_reload = await _reload_runtime_if_requested(apply_changes=apply_changes, app=app)
runtime_reload = await reload_runtime_if_requested(apply_changes=apply_changes, app=app)
return ConfigChangeResponse(
restart_required=False if runtime_reload is not None else result.restart_required,
camera=_camera_response(app, camera) if camera else None,
Expand Down Expand Up @@ -223,7 +185,7 @@ async def update_camera(
error_code=APIErrorCode.CAMERA_NOT_FOUND,
)

runtime_reload = await _reload_runtime_if_requested(apply_changes=apply_changes, app=app)
runtime_reload = await reload_runtime_if_requested(apply_changes=apply_changes, app=app)
return ConfigChangeResponse(
restart_required=False if runtime_reload is not None else result.restart_required,
camera=_camera_response(app, camera),
Expand All @@ -243,7 +205,7 @@ async def delete_camera(
except CameraMutationError as exc:
raise _map_camera_config_error(exc) from exc

runtime_reload = await _reload_runtime_if_requested(apply_changes=apply_changes, app=app)
runtime_reload = await reload_runtime_if_requested(apply_changes=apply_changes, app=app)
return ConfigChangeResponse(
restart_required=False if runtime_reload is not None else result.restart_required,
camera=None,
Expand Down
34 changes: 3 additions & 31 deletions src/homesec/api/routes/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@
from datetime import datetime
from typing import TYPE_CHECKING

from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends
from pydantic import BaseModel

from homesec.api.dependencies import get_homesec_app
from homesec.api.errors import APIError, APIErrorCode
from homesec.runtime.errors import RuntimeReloadConfigError
from homesec.api.runtime_reload import RuntimeReloadResponse, request_runtime_reload
from homesec.runtime.models import RuntimeState

if TYPE_CHECKING:
Expand All @@ -28,12 +27,6 @@ class RuntimeStatusResponse(BaseModel):
last_reload_error: str | None


class RuntimeReloadResponse(BaseModel):
accepted: bool
message: str
target_generation: int


@router.get("/api/v1/runtime/status", response_model=RuntimeStatusResponse)
async def get_runtime_status(app: Application = Depends(get_homesec_app)) -> RuntimeStatusResponse:
"""Return runtime-manager status."""
Expand All @@ -53,25 +46,4 @@ async def reload_runtime(
app: Application = Depends(get_homesec_app),
) -> RuntimeReloadResponse:
"""Trigger runtime reload and return async acceptance outcome."""
try:
request = await app.request_runtime_reload()
except RuntimeReloadConfigError as exc:
raise APIError(
str(exc),
status_code=exc.status_code,
error_code=exc.error_code,
) from exc

if not request.accepted:
raise APIError(
request.message,
status_code=status.HTTP_409_CONFLICT,
error_code=APIErrorCode.RELOAD_IN_PROGRESS,
extra={"target_generation": request.target_generation},
)

return RuntimeReloadResponse(
accepted=True,
message="Runtime reload accepted",
target_generation=request.target_generation,
)
return await request_runtime_reload(app)
173 changes: 173 additions & 0 deletions src/homesec/api/routes/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""Storage configuration endpoints."""

from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING, cast

from fastapi import APIRouter, Depends
from pydantic import BaseModel

from homesec.api.dependencies import get_homesec_app, require_normal_mode
from homesec.api.errors import APIError, APIErrorCode
from homesec.api.redaction import is_sensitive_key, redact_config
from homesec.api.runtime_reload import RuntimeReloadResponse, reload_runtime_if_requested
from homesec.config.errors import StorageMutationError
from homesec.models.config import StorageConfig
from homesec.plugins import discover_all_plugins
from homesec.plugins.registry import PluginType, get_plugin_config_model, get_plugin_names

if TYPE_CHECKING:
from homesec.app import Application

router = APIRouter(tags=["storage"])


class StorageResponse(BaseModel):
backend: str
config: dict[str, object]
paths: dict[str, object]


class StorageUpdate(BaseModel):
backend: str | None = None
config: dict[str, object] | None = None


class StorageFieldMetadata(BaseModel):
name: str
type: str
required: bool
description: str | None = None
default: object | None = None
secret: bool = False


class StorageBackendMetadata(BaseModel):
backend: str
label: str
description: str
config_schema: dict[str, object]
fields: list[StorageFieldMetadata]
secret_fields: list[str]


class StorageChangeResponse(BaseModel):
restart_required: bool = True
storage: StorageResponse | None = None
runtime_reload: RuntimeReloadResponse | None = None


def _storage_response(storage: StorageConfig) -> StorageResponse:
redacted_storage = redact_config(storage.config)
if not isinstance(redacted_storage, dict):
redacted_storage = {}
paths_payload = storage.paths.model_dump(mode="json")
return StorageResponse(
backend=storage.backend,
config=cast(dict[str, object], redacted_storage),
paths=cast(dict[str, object], paths_payload),
)


def _field_type(property_schema: dict[str, object]) -> str:
schema_type = property_schema.get("type")
if isinstance(schema_type, str):
return schema_type
if isinstance(schema_type, list):
rendered = [item for item in schema_type if isinstance(item, str)]
if rendered:
return "|".join(rendered)
return "object"


def _backend_description(backend: str, schema: dict[str, object]) -> str:
raw_description = schema.get("description")
if isinstance(raw_description, str) and raw_description.strip():
return raw_description.strip()
return f"{backend.replace('_', ' ').title()} storage backend."


def _backend_metadata(backend: str) -> StorageBackendMetadata:
config_model = get_plugin_config_model(PluginType.STORAGE, backend)
schema = config_model.model_json_schema()
properties_raw = schema.get("properties", {})
properties = properties_raw if isinstance(properties_raw, dict) else {}
required_raw = schema.get("required", [])
required = {item for item in required_raw if isinstance(item, str)}

fields: list[StorageFieldMetadata] = []
for field_name, field_schema in properties.items():
if not isinstance(field_name, str):
continue
parsed_schema = field_schema if isinstance(field_schema, dict) else {}
fields.append(
StorageFieldMetadata(
name=field_name,
type=_field_type(parsed_schema),
required=field_name in required,
description=cast(str | None, parsed_schema.get("description")),
default=parsed_schema.get("default"),
secret=is_sensitive_key(field_name),
)
)

fields.sort(key=lambda field: field.name)
return StorageBackendMetadata(
backend=backend,
label=backend.replace("_", " ").title(),
description=_backend_description(backend, cast(dict[str, object], schema)),
config_schema=cast(dict[str, object], schema),
fields=fields,
secret_fields=[field.name for field in fields if field.secret],
)


@router.get(
"/api/v1/storage",
response_model=StorageResponse,
dependencies=[Depends(require_normal_mode)],
)
async def get_storage(app: Application = Depends(get_homesec_app)) -> StorageResponse:
"""Get active storage backend config with redacted secret values."""
config = await asyncio.to_thread(app.config_manager.get_config)
return _storage_response(config.storage)


@router.patch(
"/api/v1/storage",
response_model=StorageChangeResponse,
dependencies=[Depends(require_normal_mode)],
)
async def patch_storage(
payload: StorageUpdate,
apply_changes: bool = False,
app: Application = Depends(get_homesec_app),
) -> StorageChangeResponse:
"""Partially update storage config and optionally trigger runtime reload."""
try:
result = await app.config_manager.update_storage(
storage_backend=payload.backend,
storage_config=payload.config,
)
except StorageMutationError as exc:
raise APIError(
str(exc),
status_code=400,
error_code=APIErrorCode.STORAGE_CONFIG_INVALID,
) from exc

config = await asyncio.to_thread(app.config_manager.get_config)
runtime_reload = await reload_runtime_if_requested(apply_changes=apply_changes, app=app)
return StorageChangeResponse(
restart_required=False if runtime_reload is not None else result.restart_required,
storage=_storage_response(config.storage),
runtime_reload=runtime_reload,
)


@router.get("/api/v1/storage/backends", response_model=list[StorageBackendMetadata])
async def list_storage_backends() -> list[StorageBackendMetadata]:
"""List available storage backends and their config schema metadata."""
discover_all_plugins()
return [_backend_metadata(backend) for backend in get_plugin_names(PluginType.STORAGE)]
Loading
Loading