diff --git a/backend/app/core/app_config.py b/backend/app/core/app_config.py index bd7b462..1b6faa5 100644 --- a/backend/app/core/app_config.py +++ b/backend/app/core/app_config.py @@ -1,21 +1,25 @@ """ Per-service runtime config helpers. -Config files live on the shared `app_config` Docker volume at /config/. -Each service has its own JSON file. +All config files are stored in the 'config' bucket of the storage-service. +Every function is async — callers must await them. -Atomic write pattern: write to .tmp in same dir, then os.replace() so -services never read a partial file. +Key layout in the config bucket: + ai_service_config.json + doc_service_config.json + appearance_config.json + themes/{id}.json """ import copy -import json -import os +import logging import re -from pathlib import Path +from copy import deepcopy 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 ────────────────────────────────────────────────── @@ -108,59 +112,50 @@ def _mask_ai_config(data: dict) -> dict: # ── Load / Save ──────────────────────────────────────────────────────────────── -def _config_path(service: str) -> Path: - return _CONFIG_DIR / f"{service}_config.json" - - -def load_service_config(service: str) -> dict: - path = _config_path(service) - if not path.exists(): +async def load_service_config(service: str) -> dict: + data = await config_storage.read_json(f"{service}_config.json") + if data is None: if service == "ai_service": return AIServiceConfig().model_dump() if service == "doc_service": return DocServiceConfig().model_dump() return {} - with path.open() as f: - return json.load(f) + return data -def save_service_config(service: str, data: dict) -> None: - path = _config_path(service) - 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) +async def save_service_config(service: str, data: dict) -> None: + await config_storage.write_json(f"{service}_config.json", data) # AI service helpers -def load_ai_service_config() -> AIServiceConfig: - raw = load_service_config("ai_service") +async def load_ai_service_config() -> AIServiceConfig: + raw = await load_service_config("ai_service") return AIServiceConfig.model_validate(raw) -def save_ai_service_config(config: AIServiceConfig) -> None: - save_service_config("ai_service", config.model_dump()) +async def save_ai_service_config(config: AIServiceConfig) -> None: + await save_service_config("ai_service", config.model_dump()) -def load_ai_service_config_masked() -> dict: - raw = load_service_config("ai_service") +async def load_ai_service_config_masked() -> dict: + raw = await load_service_config("ai_service") return _mask_ai_config(raw) # Doc service helpers -def load_doc_service_config() -> DocServiceConfig: - raw = load_service_config("doc_service") +async def load_doc_service_config() -> DocServiceConfig: + raw = await load_service_config("doc_service") return DocServiceConfig.model_validate(raw) -def save_doc_service_config(config: DocServiceConfig) -> None: - save_service_config("doc_service", config.model_dump()) +async def save_doc_service_config(config: DocServiceConfig) -> None: + await save_service_config("doc_service", config.model_dump()) -def load_doc_service_config_masked() -> dict: - return load_service_config("doc_service") +async def load_doc_service_config_masked() -> dict: + return await load_service_config("doc_service") 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 ───────────────────────────────────────────────────── -# Registry of all services that have editable system prompts. -# key = service identifier, value = human-readable label SYSTEM_PROMPT_SERVICES: dict[str, str] = { "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}}.""" result: dict = {} 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", {}) defaults = _get_service_prompt_defaults(service_id) result[service_id] = { @@ -196,15 +189,14 @@ def load_all_system_prompts() -> dict: return result -def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None: - """Persist updated system prompts into the service's config file.""" +async def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None: if service_id not in SYSTEM_PROMPT_SERVICES: 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["system_prompts"]["system"] = system 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: @@ -221,26 +213,19 @@ class AppearanceConfig(BaseModel): default_mode: str = "system" -def load_appearance_config() -> AppearanceConfig: - path = _CONFIG_DIR / "appearance_config.json" - if not path.exists(): +async def load_appearance_config() -> AppearanceConfig: + data = await config_storage.read_json("appearance_config.json") + if data is None: return AppearanceConfig() - with path.open() as f: - return AppearanceConfig.model_validate(json.load(f)) + return AppearanceConfig.model_validate(data) -def save_appearance_config(config: AppearanceConfig) -> None: - path = _CONFIG_DIR / "appearance_config.json" - 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) +async def save_appearance_config(config: AppearanceConfig) -> None: + await config_storage.write_json("appearance_config.json", config.model_dump()) # ── Theme file management ────────────────────────────────────────────────────── -_THEMES_DIR = _CONFIG_DIR / "themes" - # 9 required colour tokens per mode _REQUIRED_TOKENS = frozenset({ "primary", "primary_hover", "accent", "accent_hover", @@ -361,36 +346,57 @@ _BUILTIN_THEMES: list[dict] = [ ] -def seed_builtin_themes() -> None: - """Create /config/themes/ and write built-in theme files if missing.""" - _THEMES_DIR.mkdir(parents=True, exist_ok=True) +async def seed_builtin_themes() -> None: + """Write built-in theme files to storage-service if they are not already there.""" + 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: - path = _THEMES_DIR / f"{theme['id']}.json" - if not path.exists(): - path.write_text(json.dumps(theme, indent=2)) + if theme["id"] not in existing_ids: + await config_storage.write_json(f"themes/{theme['id']}.json", theme) + logger.info("Built-in themes seeded (%d themes)", len(_BUILTIN_THEMES)) -def load_all_themes() -> list[dict]: - """Return all themes from /config/themes/*.json, built-ins first.""" - if not _THEMES_DIR.exists(): - seed_builtin_themes() - themes = [] - for f in sorted(_THEMES_DIR.glob("*.json")): - try: - themes.append(json.loads(f.read_text())) - except (json.JSONDecodeError, OSError): - pass - # Sort: built-ins first (preserving their original order), then custom by label +async def load_all_themes() -> list[dict]: + """Return all themes from storage-service, built-ins first then custom by label.""" + keys = await config_storage.list_keys(prefix="themes/") + themes: list[dict] = [] + for key in keys: + data = await config_storage.read_json(key) + if data: + themes.append(data) + builtin_ids = [t["id"] for t in _BUILTIN_THEMES] + def sort_key(t: dict) -> tuple: tid = t.get("id", "") try: return (0, builtin_ids.index(tid)) except ValueError: return (1, t.get("label", tid).lower()) + 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]: """Return a list of validation error messages, empty if valid.""" 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)): errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}") 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() diff --git a/backend/app/core/config_storage.py b/backend/app/core/config_storage.py new file mode 100644 index 0000000..8a9b8e2 --- /dev/null +++ b/backend/app/core/config_storage.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index fb1239d..f6913a9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,7 @@ from app.services.service_health import check_all, health_check_loop, register_s @asynccontextmanager async def lifespan(app: FastAPI): - await asyncio.to_thread(seed_builtin_themes) + await seed_builtin_themes() register_services( doc_service_url=settings.DOC_SERVICE_URL, ai_service_url=settings.AI_SERVICE_URL, diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index b8b6bdc..069fe08 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -2,10 +2,9 @@ Admin-only settings API for per-service runtime configuration. 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 json +import re as _re import httpx from fastapi import APIRouter, Depends, HTTPException @@ -21,10 +20,11 @@ from app.core.app_config import ( load_all_system_prompts, load_all_themes, load_appearance_config, - save_appearance_config, load_doc_service_config, load_doc_service_config_masked, + load_theme_by_id, save_ai_service_config, + save_appearance_config, save_doc_service_config, save_service_system_prompts, save_theme, @@ -36,6 +36,8 @@ from app.models.user import User router = APIRouter() +_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$") + # ── Pydantic request bodies ──────────────────────────────────────────────────── @@ -98,7 +100,7 @@ class ThemeUpdate(BaseModel): async def get_ai_settings( _: User = Depends(get_service_admin("ai-service")), ) -> dict: - return load_ai_service_config_masked() + return await load_ai_service_config_masked() @router.patch("/ai") @@ -110,7 +112,7 @@ async def update_ai_settings( if body.provider not in 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 # Anthropic @@ -139,8 +141,8 @@ async def update_ai_settings( body.lmstudio_api_key, config.lmstudio.api_key ) - await asyncio.to_thread(save_ai_service_config, config) - return load_ai_service_config_masked() + await save_ai_service_config(config) + return await load_ai_service_config_masked() @router.post("/ai/test") @@ -173,7 +175,7 @@ async def test_ai_connection( async def get_documents_limits( _: User = Depends(get_service_admin("doc-service")), ) -> dict: - return load_doc_service_config_masked() + return await load_doc_service_config_masked() @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: 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 - await asyncio.to_thread(save_doc_service_config, config) - return load_doc_service_config_masked() + await save_doc_service_config(config) + return await load_doc_service_config_masked() # ── System prompts ───────────────────────────────────────────────────────────── @@ -197,8 +199,7 @@ async def update_documents_limits( async def get_system_prompts( _: User = Depends(get_service_admin("ai-service")), ) -> dict: - """Return all editable system prompts, keyed by service id.""" - return await asyncio.to_thread(load_all_system_prompts) + return await load_all_system_prompts() @router.patch("/system-prompts/{service_id}") @@ -207,26 +208,20 @@ async def update_system_prompt( body: SystemPromptUpdate, _: User = Depends(get_service_admin("ai-service")), ) -> dict: - """Update the system prompts for a single service.""" if service_id not in SYSTEM_PROMPT_SERVICES: raise HTTPException(status_code=404, detail=f"No system prompts registered for {service_id!r}") - await asyncio.to_thread( - save_service_system_prompts, service_id, body.system, body.user_template - ) - return await asyncio.to_thread(load_all_system_prompts) + await save_service_system_prompts(service_id, body.system, body.user_template) + return await load_all_system_prompts() # ── 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") async def get_appearance( _: User = Depends(get_current_user), ) -> dict: - config = await asyncio.to_thread(load_appearance_config) + config = await load_appearance_config() return config.model_dump() @@ -237,12 +232,12 @@ async def update_appearance( ) -> dict: if body.default_mode not in ("light", "dark", "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} if body.theme not in theme_ids: raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}") 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() @@ -253,7 +248,7 @@ async def update_appearance( async def list_themes( _: User = Depends(get_current_user), ) -> list: - return await asyncio.to_thread(load_all_themes) + return await load_all_themes() @router.post("/themes", status_code=201) @@ -263,7 +258,7 @@ async def create_theme( ) -> dict: if not _THEME_ID_RE.match(body.id): 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: raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}") light = body.light.model_dump() @@ -273,7 +268,7 @@ async def create_theme( if errors: raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}") 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 @@ -283,11 +278,9 @@ async def update_theme( body: ThemeUpdate, _: User = Depends(get_current_admin), ) -> dict: - from app.core.app_config import _THEMES_DIR - path = _THEMES_DIR / f"{theme_id}.json" - if not path.exists(): + theme = await load_theme_by_id(theme_id) + if theme is None: raise HTTPException(status_code=404, detail="Theme not found") - theme = json.loads(path.read_text()) if theme.get("builtin"): raise HTTPException(status_code=400, detail="Cannot edit a built-in theme") if body.label is not None: @@ -304,7 +297,7 @@ async def update_theme( if errors: raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}") theme["dark"] = dark - await asyncio.to_thread(save_theme, theme) + await save_theme(theme) return theme @@ -314,7 +307,7 @@ async def remove_theme( _: User = Depends(get_current_admin), ) -> None: try: - await asyncio.to_thread(delete_theme, theme_id) + await delete_theme(theme_id) except FileNotFoundError: raise HTTPException(status_code=404, detail="Theme not found") except ValueError as exc: diff --git a/docker-compose.yml b/docker-compose.yml index 2ec7324..dec204b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,8 +54,6 @@ services: DOC_SERVICE_URL: http://doc-service:8001 AI_SERVICE_URL: http://ai-service:8010 STORAGE_SERVICE_URL: http://storage-service:8020 - volumes: - - app_config:/config depends_on: db: condition: service_healthy @@ -73,9 +71,10 @@ services: user: "1001:1001" restart: unless-stopped environment: - CONFIG_PATH: /config/ai_service_config.json - volumes: - - app_config:/config + STORAGE_SERVICE_URL: http://storage-service:8020 + depends_on: + storage-service: + condition: service_healthy networks: - backend-net @@ -89,14 +88,10 @@ services: restart: unless-stopped environment: 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 STORAGE_SERVICE_URL: http://storage-service:8020 volumes: - - doc_data:/data/documents - watch_data:/data/watch - - app_config:/config depends_on: db: condition: service_healthy @@ -125,10 +120,8 @@ services: volumes: postgres_data: - storage_data: # All file/blob storage — managed by storage-service - doc_data: # PDF files persisted across restarts (to be removed after Phase 2 migration) + storage_data: # All file/blob storage — managed by storage-service (documents + config) 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: # backend-net: db ↔ backend ↔ doc-service. No host ports bound. diff --git a/features/ai-service/app/core/config.py b/features/ai-service/app/core/config.py index bbeb4d0..7f1f52a 100644 --- a/features/ai-service/app/core/config.py +++ b/features/ai-service/app/core/config.py @@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): 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"} diff --git a/features/ai-service/app/services/config_reader.py b/features/ai-service/app/services/config_reader.py index 4caad3a..c5bed16 100644 --- a/features/ai-service/app/services/config_reader.py +++ b/features/ai-service/app/services/config_reader.py @@ -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). Env var overrides (all optional): @@ -8,15 +8,17 @@ Env var overrides (all optional): OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_API_KEY ANTHROPIC_API_KEY, ANTHROPIC_MODEL """ -import asyncio import json import os import time from copy import deepcopy -from pathlib import Path + +import httpx from app.core.config import settings +_CONFIG_KEY = "ai_service_config.json" + _DEFAULT_CONFIG: dict = { "provider": "lmstudio", "timeout_seconds": 60, @@ -31,12 +33,18 @@ _cache_at: float = 0.0 _CACHE_TTL = 30.0 -def _read_config_sync() -> dict: - path = Path(settings.CONFIG_PATH) - if not path.exists(): - return _apply_env_overrides(deepcopy(_DEFAULT_CONFIG)) - with open(path) as f: - return _apply_env_overrides(json.load(f)) +def _storage_url() -> str: + return f"{settings.STORAGE_SERVICE_URL}/objects/config/{_CONFIG_KEY}" + + +async def _fetch_config() -> dict: + """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: @@ -75,7 +83,8 @@ async def load_ai_config() -> dict: now = time.monotonic() if _cache is not None and (now - _cache_at) < _CACHE_TTL: return _cache - data = await asyncio.to_thread(_read_config_sync) + raw = await _fetch_config() + data = _apply_env_overrides(raw) _cache = data _cache_at = now return data diff --git a/features/doc-service/app/services/config_reader.py b/features/doc-service/app/services/config_reader.py index a3db812..da2dd3d 100644 --- a/features/doc-service/app/services/config_reader.py +++ b/features/doc-service/app/services/config_reader.py @@ -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. Env var overrides (all optional): DOC_MAX_PDF_MB — max upload size in megabytes (e.g. "50") """ -import asyncio -import json import os import time from copy import deepcopy -from pathlib import Path + +import httpx from app.core.config import settings +_CONFIG_KEY = "doc_service_config.json" + _DEFAULT_STORAGE_CONFIG: dict = { "watch_enabled": False, "watch_path": "/data/watch", @@ -63,33 +64,30 @@ _cache_at: float = 0.0 _CACHE_TTL = 30.0 -def _read_config_sync() -> dict: - path = Path(settings.CONFIG_PATH) - if not path.exists(): - base = deepcopy(_DEFAULT_CONFIG) - else: - with open(path) as f: - base = json.load(f) - return _apply_env_overrides(base) +def _storage_url() -> str: + return f"{settings.STORAGE_SERVICE_URL}/objects/config/{_CONFIG_KEY}" -def _read_config_sync_raw() -> dict: - """Read without env overrides — used when we need to write back to disk.""" - path = Path(settings.CONFIG_PATH) - if not path.exists(): - return deepcopy(_DEFAULT_CONFIG) - with open(path) as f: - return json.load(f) +async def _fetch_config() -> dict: + """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 _write_config_sync(config: dict) -> None: - """Atomically write config JSON to disk.""" - path = Path(settings.CONFIG_PATH) - tmp = path.with_suffix(".tmp") - tmp.parent.mkdir(parents=True, exist_ok=True) - with open(tmp, "w") as f: - json.dump(config, f, indent=2) - os.replace(tmp, path) +async def _write_config(data: dict) -> None: + import json + payload = json.dumps(data, indent=2).encode() + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.put( + _storage_url(), + content=payload, + headers={"Content-Type": "application/octet-stream"}, + ) + resp.raise_for_status() def _apply_env_overrides(config: dict) -> dict: @@ -108,7 +106,8 @@ async def load_doc_config() -> dict: now = time.monotonic() if _cache is not None and (now - _cache_at) < _CACHE_TTL: return _cache - data = await asyncio.to_thread(_read_config_sync) + raw = await _fetch_config() + data = _apply_env_overrides(raw) _cache = data _cache_at = now return data @@ -123,11 +122,10 @@ async def get_storage_config() -> dict: 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 - raw = await asyncio.to_thread(_read_config_sync_raw) + raw = await _fetch_config() raw.setdefault("storage", {}).update(data) - await asyncio.to_thread(_write_config_sync, raw) - # Invalidate cache so next read picks up the new values + await _write_config(raw) _cache = None _cache_at = 0.0