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
91 lines
2.8 KiB
Python
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()
|