Files
mcphub/core/site_manager.py
airano-ir 3815f5a6d5 chore(F.4e): remove env-based site loading, unify OAuth Clients, update docs
- Remove env-based site discovery from SiteManager and ProjectManager
- Unify admin and user OAuth Clients into single role-based page
- Update all documentation to reflect DB-based site management
- Clean up env.example, README, ARCHITECTURE docs
- Improve Projects page empty state with link to My Sites
- Remove obsolete env discovery tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:24:33 +02:00

386 lines
12 KiB
Python

"""
Site Manager - Type-safe site configuration management
Manages site configurations with Pydantic validation.
Part of Option B clean architecture refactoring.
Sites are managed via the web dashboard and stored in SQLite (DB-based).
The SiteManager provides registration and lookup infrastructure for
plugin tool generation and endpoint routing.
"""
import logging
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
logger = logging.getLogger(__name__)
class SiteConfig(BaseModel):
"""
Type-safe site configuration.
Represents configuration for a single site with validation.
Attributes:
site_id: Unique site identifier (e.g., 'site1')
plugin_type: Plugin type (e.g., 'wordpress')
alias: Optional friendly name
config: Site-specific configuration (URL, credentials, etc.)
Examples:
>>> config = SiteConfig(
... site_id="site1",
... plugin_type="wordpress",
... url="https://example.com",
... username="admin",
... app_password="xxxx"
... )
"""
site_id: str = Field(..., description="Unique site identifier")
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
alias: str | None = Field(None, description="Friendly alias for the site")
user_id: str | None = Field(None, description="Owner user ID for the site")
# Common config fields (plugins may require additional fields)
url: str | None = Field(None, description="Site URL")
username: str | None = Field(None, description="Username for authentication")
app_password: str | None = Field(None, description="Application password")
container: str | None = Field(None, description="Docker container name (for WP-CLI)")
# Allow additional fields for plugin-specific configuration
model_config = ConfigDict(extra="allow")
@field_validator("alias", mode="before")
@classmethod
def default_alias(cls, v: str | None, info: ValidationInfo) -> str | None:
"""Set alias to site_id if not provided."""
return v if v is not None else info.data.get("site_id")
def get_full_id(self) -> str:
"""
Get full site identifier.
Returns:
Full ID in format: plugin_type_site_id
Examples:
>>> config.get_full_id()
'wordpress_site1'
"""
return f"{self.plugin_type}_{self.site_id}"
def get_display_name(self) -> str:
"""
Get display name for the site.
Returns:
Alias if available, otherwise site_id
Examples:
>>> config.get_display_name()
'myblog' # or 'site1' if no alias
"""
return self.alias or self.site_id
def to_dict(self) -> dict[str, Any]:
"""
Convert to dictionary for plugin consumption.
Returns:
Dictionary with all configuration
Examples:
>>> config_dict = config.to_dict()
>>> plugin = WordPressPlugin(config_dict)
"""
return self.model_dump()
class SiteManager:
"""
Manage site configurations with type safety.
Provides registration and lookup of site configurations.
Sites are registered programmatically (e.g., from database) via register_site().
Attributes:
sites: Dictionary mapping plugin_type to site configurations
aliases: Dictionary mapping aliases to (plugin_type, site_id)
logger: Logger instance
Examples:
>>> manager = SiteManager()
>>> manager.register_site(config)
>>> config = manager.get_site_config('wordpress', 'myblog')
>>> sites = manager.list_sites('wordpress')
"""
def __init__(self):
"""Initialize site manager with empty registries."""
# Nested dict: plugin_type -> site_id/alias -> SiteConfig
self.sites: dict[str, dict[str, SiteConfig]] = {}
# Map aliases to full_id for quick lookup
self.aliases: dict[str, str] = {} # alias -> full_id
self.logger = logging.getLogger("SiteManager")
self.logger.info("SiteManager initialized")
def register_site(self, config: SiteConfig) -> None:
"""
Register a site configuration.
Args:
config: Site configuration to register
Examples:
>>> config = SiteConfig(site_id="site1", plugin_type="wordpress", ...)
>>> manager.register_site(config)
"""
plugin_type = config.plugin_type
# Initialize plugin_type dict if needed
if plugin_type not in self.sites:
self.sites[plugin_type] = {}
# Register by site_id
self.sites[plugin_type][config.site_id] = config
# Register by alias if different from site_id
if config.alias and config.alias != config.site_id:
self.sites[plugin_type][config.alias] = config
# Also register in global aliases map
self.aliases[config.alias] = config.get_full_id()
# Register full_id
full_id = config.get_full_id()
self.aliases[full_id] = full_id
# Register site_id
self.aliases[config.site_id] = full_id
self.logger.info(
f"Registered site: {full_id} " f"(alias: {config.alias or config.site_id})"
)
def get_site_config(self, plugin_type: str, site: str) -> SiteConfig:
"""
Get site configuration by ID or alias.
Args:
plugin_type: Plugin type (e.g., 'wordpress')
site: Site ID, alias, or full_id
Returns:
Site configuration
Raises:
ValueError: If site not found
Examples:
>>> config = manager.get_site_config('wordpress', 'myblog')
>>> config = manager.get_site_config('wordpress', 'site1')
"""
if plugin_type not in self.sites:
# SECURITY: Don't reveal available plugin types in multi-tenant environment
raise ValueError(
f"No sites configured for plugin type: {plugin_type}. "
f"Please add a site via the dashboard."
)
# Try direct lookup
config = self.sites[plugin_type].get(site)
if config:
return config
# SECURITY: Don't reveal available sites in multi-tenant environment
# Only log available sites count for debugging (not in error message)
available_count = len(self.sites[plugin_type])
self.logger.debug(
f"Site '{site}' not found for {plugin_type}. "
f"Total configured sites: {available_count}"
)
raise ValueError(
f"Site '{site}' not configured for {plugin_type}. "
f"Please verify the site alias/ID in the dashboard."
)
def list_sites(self, plugin_type: str) -> list[str]:
"""
List available site IDs and aliases for a plugin type.
Args:
plugin_type: Plugin type
Returns:
List of valid site identifiers (IDs and aliases)
Examples:
>>> sites = manager.list_sites('wordpress')
>>> print(sites) # ['site1', 'site2', 'myblog']
"""
if plugin_type not in self.sites:
return []
# Get unique site identifiers (both IDs and aliases)
identifiers = set()
for config in self.sites[plugin_type].values():
identifiers.add(config.site_id)
if config.alias and config.alias != config.site_id:
identifiers.add(config.alias)
# Remove duplicates and sort
return sorted(set(identifiers))
def get_sites_by_type(self, plugin_type: str) -> list[SiteConfig]:
"""
Get all site configurations for a plugin type.
Args:
plugin_type: Plugin type
Returns:
List of site configurations
Examples:
>>> configs = manager.get_sites_by_type('wordpress')
>>> for config in configs:
... print(config.get_display_name())
"""
if plugin_type not in self.sites:
return []
# Return unique configs (since aliases may point to same config)
seen_site_ids = set()
unique_configs = []
for config in self.sites[plugin_type].values():
if config.site_id not in seen_site_ids:
seen_site_ids.add(config.site_id)
unique_configs.append(config)
return unique_configs
def list_all_sites(self) -> list[dict[str, Any]]:
"""
List all discovered sites across all plugin types.
Returns:
List of site info dictionaries
Examples:
>>> all_sites = manager.list_all_sites()
>>> for site in all_sites:
... print(f"{site['full_id']}: {site['alias']}")
"""
all_sites = []
for plugin_type in self.sites:
for config in self.get_sites_by_type(plugin_type):
all_sites.append(
{
"plugin_type": config.plugin_type,
"site_id": config.site_id,
"alias": config.alias,
"full_id": config.get_full_id(),
"user_id": config.user_id,
}
)
return all_sites
def get_count(self) -> int:
"""
Get total number of registered sites.
Returns:
Total site count
Examples:
>>> count = manager.get_count()
"""
total = 0
for plugin_type in self.sites:
total += len(self.get_sites_by_type(plugin_type))
return total
def get_count_by_type(self) -> dict[str, int]:
"""
Get site counts grouped by plugin type.
Returns:
Dictionary mapping plugin type to site count
Examples:
>>> counts = manager.get_count_by_type()
>>> print(counts) # {'wordpress': 4, 'gitea': 2}
"""
return {plugin_type: len(self.get_sites_by_type(plugin_type)) for plugin_type in self.sites}
def get_effective_path_suffix(self, full_id: str) -> str:
"""
Get the effective path suffix for a site's endpoint.
Uses alias if available, otherwise returns full_id.
Args:
full_id: The full site ID (e.g., 'wordpress_site1')
Returns:
Path suffix to use in endpoint URL (alias or full_id)
Examples:
>>> suffix = manager.get_effective_path_suffix('wordpress_site1')
>>> print(suffix) # 'myblog' or 'wordpress_site1'
"""
# Look up by full_id from registered sites (handles multi-word plugin types)
for info in self.list_all_sites():
if info["full_id"] == full_id:
config = self.sites[info["plugin_type"]].get(info["site_id"])
if config and config.alias and config.alias != config.site_id:
return config.alias
return full_id
return full_id
def get_alias_conflicts(self) -> dict[str, list[str]]:
"""
Get all alias conflicts.
Note: SiteManager doesn't track conflicts like SiteRegistry.
This is a stub for compatibility.
Returns:
Empty dict (no conflict tracking in SiteManager)
"""
return {}
def __repr__(self) -> str:
"""String representation of site manager."""
counts = self.get_count_by_type()
counts_str = ", ".join(f"{k}: {v}" for k, v in counts.items())
return f"SiteManager(total={self.get_count()}, {counts_str})"
# Singleton instance
_site_manager: SiteManager | None = None
def get_site_manager() -> SiteManager:
"""
Get the singleton site manager instance.
Returns:
Global SiteManager instance
Examples:
>>> manager = get_site_manager()
>>> manager.register_site(config)
"""
global _site_manager
if _site_manager is None:
_site_manager = SiteManager()
return _site_manager