feat: migrate app_config volume to storage-service config bucket (Phase 3)

All JSON config files (AI settings, doc settings, appearance, themes) now live
in the 'config' bucket of storage-service instead of a shared Docker volume.

- backend/core/config_storage.py: new async HTTP helpers for config bucket r/w
- backend/core/app_config.py: fully async rewrite; all load_*/save_*/seed_*
  functions use config_storage instead of filesystem
- backend/routers/settings.py: all asyncio.to_thread() wrappers removed; direct
  await calls throughout; update_theme reads via load_theme_by_id()
- backend/main.py: await seed_builtin_themes() directly (no to_thread)
- ai-service: remove CONFIG_PATH, add STORAGE_SERVICE_URL; config_reader now
  fetches from storage-service via httpx
- doc-service: config_reader rewritten to fetch/write via storage-service
- docker-compose: remove app_config volume; add storage-service depends_on for
  ai-service; remove DATA_DIR and CONFIG_PATH from doc-service

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-20 16:02:57 +02:00
parent 2f3efb9bf9
commit 4c35d7a2a4
8 changed files with 225 additions and 183 deletions
+79 -93
View File
@@ -1,21 +1,25 @@
""" """
Per-service runtime config helpers. Per-service runtime config helpers.
Config files live on the shared `app_config` Docker volume at /config/. All config files are stored in the 'config' bucket of the storage-service.
Each service has its own JSON file. Every function is async — callers must await them.
Atomic write pattern: write to .tmp in same dir, then os.replace() so Key layout in the config bucket:
services never read a partial file. ai_service_config.json
doc_service_config.json
appearance_config.json
themes/{id}.json
""" """
import copy import copy
import json import logging
import os
import re import re
from pathlib import Path from copy import deepcopy
from pydantic import BaseModel from pydantic import BaseModel
_CONFIG_DIR = Path(os.environ.get("APP_CONFIG_DIR", "/config")) from app.core import config_storage
logger = logging.getLogger(__name__)
# ── AI service config schemas ────────────────────────────────────────────────── # ── AI service config schemas ──────────────────────────────────────────────────
@@ -108,59 +112,50 @@ def _mask_ai_config(data: dict) -> dict:
# ── Load / Save ──────────────────────────────────────────────────────────────── # ── Load / Save ────────────────────────────────────────────────────────────────
def _config_path(service: str) -> Path: async def load_service_config(service: str) -> dict:
return _CONFIG_DIR / f"{service}_config.json" data = await config_storage.read_json(f"{service}_config.json")
if data is None:
def load_service_config(service: str) -> dict:
path = _config_path(service)
if not path.exists():
if service == "ai_service": if service == "ai_service":
return AIServiceConfig().model_dump() return AIServiceConfig().model_dump()
if service == "doc_service": if service == "doc_service":
return DocServiceConfig().model_dump() return DocServiceConfig().model_dump()
return {} return {}
with path.open() as f: return data
return json.load(f)
def save_service_config(service: str, data: dict) -> None: async def save_service_config(service: str, data: dict) -> None:
path = _config_path(service) await config_storage.write_json(f"{service}_config.json", data)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2))
os.replace(tmp, path)
# AI service helpers # AI service helpers
def load_ai_service_config() -> AIServiceConfig: async def load_ai_service_config() -> AIServiceConfig:
raw = load_service_config("ai_service") raw = await load_service_config("ai_service")
return AIServiceConfig.model_validate(raw) return AIServiceConfig.model_validate(raw)
def save_ai_service_config(config: AIServiceConfig) -> None: async def save_ai_service_config(config: AIServiceConfig) -> None:
save_service_config("ai_service", config.model_dump()) await save_service_config("ai_service", config.model_dump())
def load_ai_service_config_masked() -> dict: async def load_ai_service_config_masked() -> dict:
raw = load_service_config("ai_service") raw = await load_service_config("ai_service")
return _mask_ai_config(raw) return _mask_ai_config(raw)
# Doc service helpers # Doc service helpers
def load_doc_service_config() -> DocServiceConfig: async def load_doc_service_config() -> DocServiceConfig:
raw = load_service_config("doc_service") raw = await load_service_config("doc_service")
return DocServiceConfig.model_validate(raw) return DocServiceConfig.model_validate(raw)
def save_doc_service_config(config: DocServiceConfig) -> None: async def save_doc_service_config(config: DocServiceConfig) -> None:
save_service_config("doc_service", config.model_dump()) await save_service_config("doc_service", config.model_dump())
def load_doc_service_config_masked() -> dict: async def load_doc_service_config_masked() -> dict:
return load_service_config("doc_service") return await load_service_config("doc_service")
def _merge_api_key(new_key: str, existing_key: str) -> str: def _merge_api_key(new_key: str, existing_key: str) -> str:
@@ -172,18 +167,16 @@ def _merge_api_key(new_key: str, existing_key: str) -> str:
# ── System prompts helpers ───────────────────────────────────────────────────── # ── System prompts helpers ─────────────────────────────────────────────────────
# Registry of all services that have editable system prompts.
# key = service identifier, value = human-readable label
SYSTEM_PROMPT_SERVICES: dict[str, str] = { SYSTEM_PROMPT_SERVICES: dict[str, str] = {
"doc_service": "Document Service", "doc_service": "Document Service",
} }
def load_all_system_prompts() -> dict: async def load_all_system_prompts() -> dict:
"""Return {service_id: {label, system, user_template, default_system, default_user_template}}.""" """Return {service_id: {label, system, user_template, default_system, default_user_template}}."""
result: dict = {} result: dict = {}
for service_id, label in SYSTEM_PROMPT_SERVICES.items(): for service_id, label in SYSTEM_PROMPT_SERVICES.items():
config = load_service_config(service_id) config = await load_service_config(service_id)
prompts = config.get("system_prompts", {}) prompts = config.get("system_prompts", {})
defaults = _get_service_prompt_defaults(service_id) defaults = _get_service_prompt_defaults(service_id)
result[service_id] = { result[service_id] = {
@@ -196,15 +189,14 @@ def load_all_system_prompts() -> dict:
return result return result
def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None: async def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None:
"""Persist updated system prompts into the service's config file."""
if service_id not in SYSTEM_PROMPT_SERVICES: if service_id not in SYSTEM_PROMPT_SERVICES:
raise ValueError(f"Unknown service: {service_id!r}") raise ValueError(f"Unknown service: {service_id!r}")
config = load_service_config(service_id) config = await load_service_config(service_id)
config.setdefault("system_prompts", {}) config.setdefault("system_prompts", {})
config["system_prompts"]["system"] = system config["system_prompts"]["system"] = system
config["system_prompts"]["user_template"] = user_template config["system_prompts"]["user_template"] = user_template
save_service_config(service_id, config) await save_service_config(service_id, config)
def _get_service_prompt_defaults(service_id: str) -> dict: def _get_service_prompt_defaults(service_id: str) -> dict:
@@ -221,26 +213,19 @@ class AppearanceConfig(BaseModel):
default_mode: str = "system" default_mode: str = "system"
def load_appearance_config() -> AppearanceConfig: async def load_appearance_config() -> AppearanceConfig:
path = _CONFIG_DIR / "appearance_config.json" data = await config_storage.read_json("appearance_config.json")
if not path.exists(): if data is None:
return AppearanceConfig() return AppearanceConfig()
with path.open() as f: return AppearanceConfig.model_validate(data)
return AppearanceConfig.model_validate(json.load(f))
def save_appearance_config(config: AppearanceConfig) -> None: async def save_appearance_config(config: AppearanceConfig) -> None:
path = _CONFIG_DIR / "appearance_config.json" await config_storage.write_json("appearance_config.json", config.model_dump())
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(config.model_dump(), indent=2))
os.replace(tmp, path)
# ── Theme file management ────────────────────────────────────────────────────── # ── Theme file management ──────────────────────────────────────────────────────
_THEMES_DIR = _CONFIG_DIR / "themes"
# 9 required colour tokens per mode # 9 required colour tokens per mode
_REQUIRED_TOKENS = frozenset({ _REQUIRED_TOKENS = frozenset({
"primary", "primary_hover", "accent", "accent_hover", "primary", "primary_hover", "accent", "accent_hover",
@@ -361,36 +346,57 @@ _BUILTIN_THEMES: list[dict] = [
] ]
def seed_builtin_themes() -> None: async def seed_builtin_themes() -> None:
"""Create /config/themes/ and write built-in theme files if missing.""" """Write built-in theme files to storage-service if they are not already there."""
_THEMES_DIR.mkdir(parents=True, exist_ok=True) existing_keys = await config_storage.list_keys(prefix="themes/")
existing_ids = {k.removeprefix("themes/").removesuffix(".json") for k in existing_keys}
for theme in _BUILTIN_THEMES: for theme in _BUILTIN_THEMES:
path = _THEMES_DIR / f"{theme['id']}.json" if theme["id"] not in existing_ids:
if not path.exists(): await config_storage.write_json(f"themes/{theme['id']}.json", theme)
path.write_text(json.dumps(theme, indent=2)) logger.info("Built-in themes seeded (%d themes)", len(_BUILTIN_THEMES))
def load_all_themes() -> list[dict]: async def load_all_themes() -> list[dict]:
"""Return all themes from /config/themes/*.json, built-ins first.""" """Return all themes from storage-service, built-ins first then custom by label."""
if not _THEMES_DIR.exists(): keys = await config_storage.list_keys(prefix="themes/")
seed_builtin_themes() themes: list[dict] = []
themes = [] for key in keys:
for f in sorted(_THEMES_DIR.glob("*.json")): data = await config_storage.read_json(key)
try: if data:
themes.append(json.loads(f.read_text())) themes.append(data)
except (json.JSONDecodeError, OSError):
pass
# Sort: built-ins first (preserving their original order), then custom by label
builtin_ids = [t["id"] for t in _BUILTIN_THEMES] builtin_ids = [t["id"] for t in _BUILTIN_THEMES]
def sort_key(t: dict) -> tuple: def sort_key(t: dict) -> tuple:
tid = t.get("id", "") tid = t.get("id", "")
try: try:
return (0, builtin_ids.index(tid)) return (0, builtin_ids.index(tid))
except ValueError: except ValueError:
return (1, t.get("label", tid).lower()) return (1, t.get("label", tid).lower())
return sorted(themes, key=sort_key) return sorted(themes, key=sort_key)
async def load_theme_by_id(theme_id: str) -> dict | None:
"""Return a single theme dict, or None if not found."""
return await config_storage.read_json(f"themes/{theme_id}.json")
async def save_theme(theme: dict) -> None:
"""Write a theme to storage-service."""
await config_storage.write_json(f"themes/{theme['id']}.json", theme)
async def delete_theme(theme_id: str) -> None:
"""Delete a custom theme. Raises ValueError for built-ins, KeyError if not found."""
data = await config_storage.read_json(f"themes/{theme_id}.json")
if data is None:
raise FileNotFoundError(theme_id)
if data.get("builtin"):
raise ValueError("Cannot delete a built-in theme")
await config_storage.delete_key(f"themes/{theme_id}.json")
def validate_theme_tokens(colors: dict) -> list[str]: def validate_theme_tokens(colors: dict) -> list[str]:
"""Return a list of validation error messages, empty if valid.""" """Return a list of validation error messages, empty if valid."""
errors = [] errors = []
@@ -401,23 +407,3 @@ def validate_theme_tokens(colors: dict) -> list[str]:
if key in _REQUIRED_TOKENS and not _RGB_RE.match(str(val)): if key in _REQUIRED_TOKENS and not _RGB_RE.match(str(val)):
errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}") errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}")
return errors return errors
def save_theme(theme: dict) -> None:
"""Write a theme file atomically."""
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
path = _THEMES_DIR / f"{theme['id']}.json"
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(theme, indent=2))
os.replace(tmp, path)
def delete_theme(theme_id: str) -> None:
"""Delete a custom theme file. Raises ValueError for built-ins, FileNotFoundError if missing."""
path = _THEMES_DIR / f"{theme_id}.json"
if not path.exists():
raise FileNotFoundError(theme_id)
data = json.loads(path.read_text())
if data.get("builtin"):
raise ValueError("Cannot delete a built-in theme")
path.unlink()
+63
View File
@@ -0,0 +1,63 @@
"""
Async HTTP client for the 'config' bucket in storage-service.
All JSON config files (AI settings, doc settings, appearance, themes, …) are stored
in the 'config' bucket under the storage-service. This module provides thin
async helpers so app_config.py does not depend on the filesystem at all.
"""
import json
import logging
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
_BUCKET = "config"
_TIMEOUT = 10.0
def _url(key: str) -> str:
return f"{settings.STORAGE_SERVICE_URL}/objects/{_BUCKET}/{key}"
async def read_json(key: str) -> dict | None:
"""Return parsed JSON from the config bucket, or None if the key does not exist."""
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(_url(key))
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
async def write_json(key: str, data: dict) -> None:
"""Serialise *data* to JSON and PUT it into the config bucket."""
payload = json.dumps(data, indent=2).encode()
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.put(
_url(key),
content=payload,
headers={"Content-Type": "application/octet-stream"},
)
resp.raise_for_status()
async def delete_key(key: str) -> None:
"""Delete a key from the config bucket. No-op if it does not exist."""
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.delete(_url(key))
if resp.status_code not in (204, 404):
resp.raise_for_status()
async def list_keys(prefix: str = "") -> list[str]:
"""List all keys in the config bucket, optionally filtered by *prefix*."""
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(f"{settings.STORAGE_SERVICE_URL}/objects/{_BUCKET}")
resp.raise_for_status()
keys: list[str] = resp.json().get("keys", [])
if prefix:
keys = [k for k in keys if k.startswith(prefix)]
return keys
+1 -1
View File
@@ -15,7 +15,7 @@ from app.services.service_health import check_all, health_check_loop, register_s
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await asyncio.to_thread(seed_builtin_themes) await seed_builtin_themes()
register_services( register_services(
doc_service_url=settings.DOC_SERVICE_URL, doc_service_url=settings.DOC_SERVICE_URL,
ai_service_url=settings.AI_SERVICE_URL, ai_service_url=settings.AI_SERVICE_URL,
+27 -34
View File
@@ -2,10 +2,9 @@
Admin-only settings API for per-service runtime configuration. Admin-only settings API for per-service runtime configuration.
All endpoints require the caller to be an admin (Depends(get_current_admin)). All endpoints require the caller to be an admin (Depends(get_current_admin)).
Config files live on the shared app_config volume (/config/). Config files are stored in the 'config' bucket of storage-service.
""" """
import asyncio import re as _re
import json
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -21,10 +20,11 @@ from app.core.app_config import (
load_all_system_prompts, load_all_system_prompts,
load_all_themes, load_all_themes,
load_appearance_config, load_appearance_config,
save_appearance_config,
load_doc_service_config, load_doc_service_config,
load_doc_service_config_masked, load_doc_service_config_masked,
load_theme_by_id,
save_ai_service_config, save_ai_service_config,
save_appearance_config,
save_doc_service_config, save_doc_service_config,
save_service_system_prompts, save_service_system_prompts,
save_theme, save_theme,
@@ -36,6 +36,8 @@ from app.models.user import User
router = APIRouter() router = APIRouter()
_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$")
# ── Pydantic request bodies ──────────────────────────────────────────────────── # ── Pydantic request bodies ────────────────────────────────────────────────────
@@ -98,7 +100,7 @@ class ThemeUpdate(BaseModel):
async def get_ai_settings( async def get_ai_settings(
_: User = Depends(get_service_admin("ai-service")), _: User = Depends(get_service_admin("ai-service")),
) -> dict: ) -> dict:
return load_ai_service_config_masked() return await load_ai_service_config_masked()
@router.patch("/ai") @router.patch("/ai")
@@ -110,7 +112,7 @@ async def update_ai_settings(
if body.provider not in valid_providers: if body.provider not in valid_providers:
raise HTTPException(status_code=422, detail=f"provider must be one of {valid_providers}") raise HTTPException(status_code=422, detail=f"provider must be one of {valid_providers}")
config = load_ai_service_config() config = await load_ai_service_config()
config.provider = body.provider config.provider = body.provider
# Anthropic # Anthropic
@@ -139,8 +141,8 @@ async def update_ai_settings(
body.lmstudio_api_key, config.lmstudio.api_key body.lmstudio_api_key, config.lmstudio.api_key
) )
await asyncio.to_thread(save_ai_service_config, config) await save_ai_service_config(config)
return load_ai_service_config_masked() return await load_ai_service_config_masked()
@router.post("/ai/test") @router.post("/ai/test")
@@ -173,7 +175,7 @@ async def test_ai_connection(
async def get_documents_limits( async def get_documents_limits(
_: User = Depends(get_service_admin("doc-service")), _: User = Depends(get_service_admin("doc-service")),
) -> dict: ) -> dict:
return load_doc_service_config_masked() return await load_doc_service_config_masked()
@router.patch("/documents/limits") @router.patch("/documents/limits")
@@ -184,10 +186,10 @@ async def update_documents_limits(
if body.max_pdf_mb < 1 or body.max_pdf_mb > 200: if body.max_pdf_mb < 1 or body.max_pdf_mb > 200:
raise HTTPException(status_code=422, detail="max_pdf_mb must be between 1 and 200") raise HTTPException(status_code=422, detail="max_pdf_mb must be between 1 and 200")
config = load_doc_service_config() config = await load_doc_service_config()
config.documents.max_pdf_bytes = body.max_pdf_mb * 1024 * 1024 config.documents.max_pdf_bytes = body.max_pdf_mb * 1024 * 1024
await asyncio.to_thread(save_doc_service_config, config) await save_doc_service_config(config)
return load_doc_service_config_masked() return await load_doc_service_config_masked()
# ── System prompts ───────────────────────────────────────────────────────────── # ── System prompts ─────────────────────────────────────────────────────────────
@@ -197,8 +199,7 @@ async def update_documents_limits(
async def get_system_prompts( async def get_system_prompts(
_: User = Depends(get_service_admin("ai-service")), _: User = Depends(get_service_admin("ai-service")),
) -> dict: ) -> dict:
"""Return all editable system prompts, keyed by service id.""" return await load_all_system_prompts()
return await asyncio.to_thread(load_all_system_prompts)
@router.patch("/system-prompts/{service_id}") @router.patch("/system-prompts/{service_id}")
@@ -207,26 +208,20 @@ async def update_system_prompt(
body: SystemPromptUpdate, body: SystemPromptUpdate,
_: User = Depends(get_service_admin("ai-service")), _: User = Depends(get_service_admin("ai-service")),
) -> dict: ) -> dict:
"""Update the system prompts for a single service."""
if service_id not in SYSTEM_PROMPT_SERVICES: if service_id not in SYSTEM_PROMPT_SERVICES:
raise HTTPException(status_code=404, detail=f"No system prompts registered for {service_id!r}") raise HTTPException(status_code=404, detail=f"No system prompts registered for {service_id!r}")
await asyncio.to_thread( await save_service_system_prompts(service_id, body.system, body.user_template)
save_service_system_prompts, service_id, body.system, body.user_template return await load_all_system_prompts()
)
return await asyncio.to_thread(load_all_system_prompts)
# ── Appearance (global default — auth read, admin write) ─────────────────────── # ── Appearance (global default — auth read, admin write) ───────────────────────
import re as _re
_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$")
@router.get("/appearance") @router.get("/appearance")
async def get_appearance( async def get_appearance(
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
) -> dict: ) -> dict:
config = await asyncio.to_thread(load_appearance_config) config = await load_appearance_config()
return config.model_dump() return config.model_dump()
@@ -237,12 +232,12 @@ async def update_appearance(
) -> dict: ) -> dict:
if body.default_mode not in ("light", "dark", "system"): if body.default_mode not in ("light", "dark", "system"):
raise HTTPException(status_code=422, detail="default_mode must be 'light', 'dark', or 'system'") raise HTTPException(status_code=422, detail="default_mode must be 'light', 'dark', or 'system'")
themes = await asyncio.to_thread(load_all_themes) themes = await load_all_themes()
theme_ids = {t["id"] for t in themes} theme_ids = {t["id"] for t in themes}
if body.theme not in theme_ids: if body.theme not in theme_ids:
raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}") raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}")
config = AppearanceConfig(theme=body.theme, default_mode=body.default_mode) config = AppearanceConfig(theme=body.theme, default_mode=body.default_mode)
await asyncio.to_thread(save_appearance_config, config) await save_appearance_config(config)
return config.model_dump() return config.model_dump()
@@ -253,7 +248,7 @@ async def update_appearance(
async def list_themes( async def list_themes(
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
) -> list: ) -> list:
return await asyncio.to_thread(load_all_themes) return await load_all_themes()
@router.post("/themes", status_code=201) @router.post("/themes", status_code=201)
@@ -263,7 +258,7 @@ async def create_theme(
) -> dict: ) -> dict:
if not _THEME_ID_RE.match(body.id): if not _THEME_ID_RE.match(body.id):
raise HTTPException(status_code=422, detail="Theme ID must match [a-z0-9_-]{1,64}") raise HTTPException(status_code=422, detail="Theme ID must match [a-z0-9_-]{1,64}")
existing = {t["id"] for t in await asyncio.to_thread(load_all_themes)} existing = {t["id"] for t in await load_all_themes()}
if body.id in existing: if body.id in existing:
raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}") raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}")
light = body.light.model_dump() light = body.light.model_dump()
@@ -273,7 +268,7 @@ async def create_theme(
if errors: if errors:
raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}") raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}")
theme = {"id": body.id, "label": body.label, "builtin": False, "light": light, "dark": dark} theme = {"id": body.id, "label": body.label, "builtin": False, "light": light, "dark": dark}
await asyncio.to_thread(save_theme, theme) await save_theme(theme)
return theme return theme
@@ -283,11 +278,9 @@ async def update_theme(
body: ThemeUpdate, body: ThemeUpdate,
_: User = Depends(get_current_admin), _: User = Depends(get_current_admin),
) -> dict: ) -> dict:
from app.core.app_config import _THEMES_DIR theme = await load_theme_by_id(theme_id)
path = _THEMES_DIR / f"{theme_id}.json" if theme is None:
if not path.exists():
raise HTTPException(status_code=404, detail="Theme not found") raise HTTPException(status_code=404, detail="Theme not found")
theme = json.loads(path.read_text())
if theme.get("builtin"): if theme.get("builtin"):
raise HTTPException(status_code=400, detail="Cannot edit a built-in theme") raise HTTPException(status_code=400, detail="Cannot edit a built-in theme")
if body.label is not None: if body.label is not None:
@@ -304,7 +297,7 @@ async def update_theme(
if errors: if errors:
raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}") raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}")
theme["dark"] = dark theme["dark"] = dark
await asyncio.to_thread(save_theme, theme) await save_theme(theme)
return theme return theme
@@ -314,7 +307,7 @@ async def remove_theme(
_: User = Depends(get_current_admin), _: User = Depends(get_current_admin),
) -> None: ) -> None:
try: try:
await asyncio.to_thread(delete_theme, theme_id) await delete_theme(theme_id)
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=404, detail="Theme not found") raise HTTPException(status_code=404, detail="Theme not found")
except ValueError as exc: except ValueError as exc:
+5 -12
View File
@@ -54,8 +54,6 @@ services:
DOC_SERVICE_URL: http://doc-service:8001 DOC_SERVICE_URL: http://doc-service:8001
AI_SERVICE_URL: http://ai-service:8010 AI_SERVICE_URL: http://ai-service:8010
STORAGE_SERVICE_URL: http://storage-service:8020 STORAGE_SERVICE_URL: http://storage-service:8020
volumes:
- app_config:/config
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -73,9 +71,10 @@ services:
user: "1001:1001" user: "1001:1001"
restart: unless-stopped restart: unless-stopped
environment: environment:
CONFIG_PATH: /config/ai_service_config.json STORAGE_SERVICE_URL: http://storage-service:8020
volumes: depends_on:
- app_config:/config storage-service:
condition: service_healthy
networks: networks:
- backend-net - backend-net
@@ -89,14 +88,10 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-destroying_sap} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-destroying_sap}
DATA_DIR: /data/documents
CONFIG_PATH: /config/doc_service_config.json
AI_SERVICE_URL: http://ai-service:8010 AI_SERVICE_URL: http://ai-service:8010
STORAGE_SERVICE_URL: http://storage-service:8020 STORAGE_SERVICE_URL: http://storage-service:8020
volumes: volumes:
- doc_data:/data/documents
- watch_data:/data/watch - watch_data:/data/watch
- app_config:/config
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -125,10 +120,8 @@ services:
volumes: volumes:
postgres_data: postgres_data:
storage_data: # All file/blob storage — managed by storage-service storage_data: # All file/blob storage — managed by storage-service (documents + config)
doc_data: # PDF files persisted across restarts (to be removed after Phase 2 migration)
watch_data: # Watch directory — bind-mount your NAS/Nextcloud here via docker-compose.override.yml watch_data: # Watch directory — bind-mount your NAS/Nextcloud here via docker-compose.override.yml
app_config: # Per-service runtime config JSON files (to be removed after Phase 3 migration)
networks: networks:
# backend-net: db ↔ backend ↔ doc-service. No host ports bound. # backend-net: db ↔ backend ↔ doc-service. No host ports bound.
+1 -1
View File
@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
PROJECT_NAME: str = "ai-service" PROJECT_NAME: str = "ai-service"
CONFIG_PATH: str = "/config/ai_service_config.json" STORAGE_SERVICE_URL: str = "http://storage-service:8020"
model_config = {"env_file": ".env", "extra": "ignore"} model_config = {"env_file": ".env", "extra": "ignore"}
@@ -1,5 +1,5 @@
""" """
Reads ai_service_config.json from the shared config volume. Reads ai_service_config.json from the storage-service config bucket.
30-second TTL cache + env var overrides (dev credentials stay out of git). 30-second TTL cache + env var overrides (dev credentials stay out of git).
Env var overrides (all optional): Env var overrides (all optional):
@@ -8,15 +8,17 @@ Env var overrides (all optional):
OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_API_KEY OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_API_KEY
ANTHROPIC_API_KEY, ANTHROPIC_MODEL ANTHROPIC_API_KEY, ANTHROPIC_MODEL
""" """
import asyncio
import json import json
import os import os
import time import time
from copy import deepcopy from copy import deepcopy
from pathlib import Path
import httpx
from app.core.config import settings from app.core.config import settings
_CONFIG_KEY = "ai_service_config.json"
_DEFAULT_CONFIG: dict = { _DEFAULT_CONFIG: dict = {
"provider": "lmstudio", "provider": "lmstudio",
"timeout_seconds": 60, "timeout_seconds": 60,
@@ -31,12 +33,18 @@ _cache_at: float = 0.0
_CACHE_TTL = 30.0 _CACHE_TTL = 30.0
def _read_config_sync() -> dict: def _storage_url() -> str:
path = Path(settings.CONFIG_PATH) return f"{settings.STORAGE_SERVICE_URL}/objects/config/{_CONFIG_KEY}"
if not path.exists():
return _apply_env_overrides(deepcopy(_DEFAULT_CONFIG))
with open(path) as f: async def _fetch_config() -> dict:
return _apply_env_overrides(json.load(f)) """Fetch config from storage-service. Returns defaults if not found."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(_storage_url())
if resp.status_code == 404:
return deepcopy(_DEFAULT_CONFIG)
resp.raise_for_status()
return resp.json()
def _apply_env_overrides(config: dict) -> dict: def _apply_env_overrides(config: dict) -> dict:
@@ -75,7 +83,8 @@ async def load_ai_config() -> dict:
now = time.monotonic() now = time.monotonic()
if _cache is not None and (now - _cache_at) < _CACHE_TTL: if _cache is not None and (now - _cache_at) < _CACHE_TTL:
return _cache return _cache
data = await asyncio.to_thread(_read_config_sync) raw = await _fetch_config()
data = _apply_env_overrides(raw)
_cache = data _cache = data
_cache_at = now _cache_at = now
return data return data
@@ -1,19 +1,20 @@
""" """
Reads doc_service_config.json from the shared config volume. Reads doc_service_config.json from the storage-service config bucket.
30-second TTL cache + env var overrides. 30-second TTL cache + env var overrides.
Env var overrides (all optional): Env var overrides (all optional):
DOC_MAX_PDF_MB — max upload size in megabytes (e.g. "50") DOC_MAX_PDF_MB — max upload size in megabytes (e.g. "50")
""" """
import asyncio
import json
import os import os
import time import time
from copy import deepcopy from copy import deepcopy
from pathlib import Path
import httpx
from app.core.config import settings from app.core.config import settings
_CONFIG_KEY = "doc_service_config.json"
_DEFAULT_STORAGE_CONFIG: dict = { _DEFAULT_STORAGE_CONFIG: dict = {
"watch_enabled": False, "watch_enabled": False,
"watch_path": "/data/watch", "watch_path": "/data/watch",
@@ -63,33 +64,30 @@ _cache_at: float = 0.0
_CACHE_TTL = 30.0 _CACHE_TTL = 30.0
def _read_config_sync() -> dict: def _storage_url() -> str:
path = Path(settings.CONFIG_PATH) return f"{settings.STORAGE_SERVICE_URL}/objects/config/{_CONFIG_KEY}"
if not path.exists():
base = deepcopy(_DEFAULT_CONFIG)
else:
with open(path) as f:
base = json.load(f)
return _apply_env_overrides(base)
def _read_config_sync_raw() -> dict: async def _fetch_config() -> dict:
"""Read without env overrides — used when we need to write back to disk.""" """Fetch config from storage-service. Returns defaults if not found."""
path = Path(settings.CONFIG_PATH) async with httpx.AsyncClient(timeout=10.0) as client:
if not path.exists(): resp = await client.get(_storage_url())
return deepcopy(_DEFAULT_CONFIG) if resp.status_code == 404:
with open(path) as f: return deepcopy(_DEFAULT_CONFIG)
return json.load(f) resp.raise_for_status()
return resp.json()
def _write_config_sync(config: dict) -> None: async def _write_config(data: dict) -> None:
"""Atomically write config JSON to disk.""" import json
path = Path(settings.CONFIG_PATH) payload = json.dumps(data, indent=2).encode()
tmp = path.with_suffix(".tmp") async with httpx.AsyncClient(timeout=10.0) as client:
tmp.parent.mkdir(parents=True, exist_ok=True) resp = await client.put(
with open(tmp, "w") as f: _storage_url(),
json.dump(config, f, indent=2) content=payload,
os.replace(tmp, path) headers={"Content-Type": "application/octet-stream"},
)
resp.raise_for_status()
def _apply_env_overrides(config: dict) -> dict: def _apply_env_overrides(config: dict) -> dict:
@@ -108,7 +106,8 @@ async def load_doc_config() -> dict:
now = time.monotonic() now = time.monotonic()
if _cache is not None and (now - _cache_at) < _CACHE_TTL: if _cache is not None and (now - _cache_at) < _CACHE_TTL:
return _cache return _cache
data = await asyncio.to_thread(_read_config_sync) raw = await _fetch_config()
data = _apply_env_overrides(raw)
_cache = data _cache = data
_cache_at = now _cache_at = now
return data return data
@@ -123,11 +122,10 @@ async def get_storage_config() -> dict:
async def save_storage_config(data: dict) -> None: async def save_storage_config(data: dict) -> None:
"""Merge data into the storage config block and persist to disk.""" """Merge data into the storage config block and persist to storage-service."""
global _cache, _cache_at global _cache, _cache_at
raw = await asyncio.to_thread(_read_config_sync_raw) raw = await _fetch_config()
raw.setdefault("storage", {}).update(data) raw.setdefault("storage", {}).update(data)
await asyncio.to_thread(_write_config_sync, raw) await _write_config(raw)
# Invalidate cache so next read picks up the new values
_cache = None _cache = None
_cache_at = 0.0 _cache_at = 0.0