feat: add HTTP dispatch to AccountManager for generic store services

Services with accounts.manager='http' now use POST/DELETE to the
service container's /service-api/accounts endpoint instead of
requiring a named Python manager. _resolve_service allows 'http'
without a registered Python object; _provision_http and
_deprovision_http handle the HTTP calls with 404-as-success on
delete. 9 new tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 00:46:54 -04:00
parent 1f2f9d9f6e
commit 5cbbfb41d9
2 changed files with 159 additions and 12 deletions
+69 -12
View File
@@ -29,6 +29,11 @@ import threading
from pathlib import Path
from typing import Dict, List, Optional
try:
import requests as _requests
except ImportError:
_requests = None
logger = logging.getLogger('picell')
_DISPATCH_PROVISION = {
@@ -42,6 +47,8 @@ _DISPATCH_DEPROVISION = {
'file_manager': '_deprovision_files',
}
_HTTP_TIMEOUT = 10
class AccountManager:
@@ -105,10 +112,55 @@ class AccountManager:
def _deprovision_files(manager, _svc: Dict, peer_username: str) -> bool:
return manager.delete_user(peer_username)
# ── HTTP dispatch (manager == "http") ────────────────────────────────
@staticmethod
def _http_base_url(svc: Dict) -> str:
"""Return the base URL for the service's /service-api endpoint."""
backend = svc.get('backend', '')
if not backend:
raise ValueError(f"Service {svc.get('id')!r} has no 'backend' configured")
return f'http://{backend}'
def _provision_http(self, svc: Dict, peer_username: str, password: str) -> bool:
if _requests is None:
raise RuntimeError('requests library is required for HTTP account dispatch')
url = self._http_base_url(svc) + '/service-api/accounts'
try:
resp = _requests.post(
url,
json={'username': peer_username, 'password': password},
timeout=_HTTP_TIMEOUT,
)
if resp.status_code in (200, 201):
return True
logger.warning('HTTP provision %s on %s returned %s: %s',
peer_username, svc.get('id'), resp.status_code, resp.text[:200])
return False
except Exception as exc:
raise RuntimeError(f'HTTP provision request failed: {exc}') from exc
def _deprovision_http(self, svc: Dict, peer_username: str) -> bool:
if _requests is None:
raise RuntimeError('requests library is required for HTTP account dispatch')
url = self._http_base_url(svc) + f'/service-api/accounts/{peer_username}'
try:
resp = _requests.delete(url, timeout=_HTTP_TIMEOUT)
if resp.status_code in (200, 204, 404):
return True
logger.warning('HTTP deprovision %s on %s returned %s: %s',
peer_username, svc.get('id'), resp.status_code, resp.text[:200])
return False
except Exception as exc:
raise RuntimeError(f'HTTP deprovision request failed: {exc}') from exc
# ── Service validation helper ─────────────────────────────────────────
def _resolve_service(self, service_id: str):
"""Return (svc, manager_name, manager) or raise ValueError."""
"""Return (svc, manager_name, manager) or raise ValueError.
manager is None when manager_name == 'http' — callers must check.
"""
svc = self._registry.get(service_id)
if svc is None:
raise ValueError(f'Unknown service: {service_id!r}')
@@ -116,6 +168,8 @@ class AccountManager:
manager_name = accounts_cfg.get('manager')
if not manager_name:
raise ValueError(f'Service {service_id!r} does not support accounts')
if manager_name == 'http':
return svc, 'http', None
manager = self._managers.get(manager_name)
if manager is None:
raise ValueError(f'Manager {manager_name!r} is not registered with AccountManager')
@@ -135,12 +189,14 @@ class AccountManager:
if password is None:
password = _secrets_mod.token_urlsafe(16)
dispatch = _DISPATCH_PROVISION.get(manager_name)
if dispatch is None:
raise ValueError(f'No provision dispatch for manager: {manager_name!r}')
fn = getattr(self, dispatch)
if manager_name == 'http':
ok = self._provision_http(svc, peer_username, password)
else:
dispatch = _DISPATCH_PROVISION.get(manager_name)
if dispatch is None:
raise ValueError(f'No provision dispatch for manager: {manager_name!r}')
ok = getattr(self, dispatch)(manager, svc, peer_username, password)
ok = fn(manager, svc, peer_username, password)
if not ok:
raise RuntimeError(
f'Provision of {peer_username!r} on {service_id!r} returned False — '
@@ -160,12 +216,13 @@ class AccountManager:
"""Delete the peer's account on the service and clear stored credentials."""
svc, manager_name, manager = self._resolve_service(service_id)
dispatch = _DISPATCH_DEPROVISION.get(manager_name)
if dispatch is None:
raise ValueError(f'No deprovision dispatch for manager: {manager_name!r}')
fn = getattr(self, dispatch)
ok = fn(manager, svc, peer_username)
if manager_name == 'http':
ok = self._deprovision_http(svc, peer_username)
else:
dispatch = _DISPATCH_DEPROVISION.get(manager_name)
if dispatch is None:
raise ValueError(f'No deprovision dispatch for manager: {manager_name!r}')
ok = getattr(self, dispatch)(manager, svc, peer_username)
with self._lock:
all_creds = self._load_creds()