betterbot/provisioners/base.py
Andre K e68c84424f
Some checks failed
Deploy BetterBot / deploy (push) Failing after 3s
Deploy BetterBot / notify (push) Successful in 3s
feat: fork from CodeAnywhere framework
Replace standalone Telegram bot with full CodeAnywhere framework fork.
BetterBot shares all framework code and customizes only:
- instance.py: BetterBot identity, system prompt, feature flags
- tools/site_editing/: list_files, read_file, write_file with auto git push
- .env: model defaults and site directory paths
- compose/: Docker setup with betterlifesg + memoraiz mounts
- deploy script: RackNerd with Infisical secrets
2026-04-19 08:01:27 +08:00

91 lines
2.8 KiB
Python

"""Provisioner base class and registry (T029)."""
from __future__ import annotations
import asyncio
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from user_store import ServiceCredential, User, UserStore
logger = logging.getLogger(__name__)
@dataclass
class ProvisionResult:
"""Result of a provisioning attempt."""
success: bool
service_username: str | None = None
service_user_id: str | None = None
service_url: str | None = None
error: str | None = None
class ServiceProvisioner(ABC):
"""Abstract base for service account provisioners."""
@property
@abstractmethod
def service_name(self) -> str:
"""Unique service key (e.g. 'vikunja', 'karakeep')."""
@property
def capabilities(self) -> list[str]:
"""Human-readable capability labels."""
return []
@property
def requires_consent(self) -> bool:
"""Whether the user must explicitly opt-in before provisioning."""
return True
@abstractmethod
async def provision(self, user: User, store: UserStore) -> ProvisionResult:
"""Create a service account for *user* and store credentials.
Implementations MUST:
- Generate credentials (username, password, API token)
- Store the token via ``store.store_credential(...)``
- Log to ``store.log_provisioning(...)``
- Attempt rollback on partial failure
"""
@abstractmethod
async def health_check(self, credential: ServiceCredential) -> bool:
"""Return True if *credential* is still valid and usable."""
# ── Provisioner registry ─────────────────────────────────────────
class ProvisionerRegistry:
"""Central registry of service provisioners."""
def __init__(self) -> None:
self._provisioners: dict[str, ServiceProvisioner] = {}
self._locks: dict[str, asyncio.Lock] = {}
def register(self, provisioner: ServiceProvisioner) -> None:
self._provisioners[provisioner.service_name] = provisioner
logger.info("Registered provisioner: %s", provisioner.service_name)
def get(self, service_name: str) -> ServiceProvisioner | None:
return self._provisioners.get(service_name)
@property
def available(self) -> dict[str, ServiceProvisioner]:
return dict(self._provisioners)
def get_lock(self, user_id: str, service: str) -> asyncio.Lock:
"""Per-user, per-service provisioning lock to prevent races."""
key = f"{user_id}:{service}"
if key not in self._locks:
self._locks[key] = asyncio.Lock()
return self._locks[key]
provisioner_registry = ProvisionerRegistry()