ProjectManager and SiteManager now skip env vars that belong to a more specific plugin type (e.g. WORDPRESS_ADVANCED_*) when discovering sites for a shorter prefix (WORDPRESS_). This prevents ghost projects, ERROR logs, and false health alerts on startup. Also downgrades Docker socket errors in WP-CLI to warnings — if Docker socket is not mounted, WP-CLI features are silently unavailable instead of logging ERROR and triggering health alerts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
559 lines
17 KiB
Python
559 lines
17 KiB
Python
"""
|
|
Site Manager - Type-safe site configuration management
|
|
|
|
Manages site configurations with Pydantic validation.
|
|
Part of Option B clean architecture refactoring.
|
|
|
|
Discovers sites from environment variables:
|
|
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
|
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
|
|
|
|
Example:
|
|
WORDPRESS_SITE1_URL=https://example.com
|
|
WORDPRESS_SITE1_USERNAME=admin
|
|
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
|
WORDPRESS_SITE2_ALIAS=myblog
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
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")
|
|
|
|
# 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.
|
|
|
|
Discovers, validates, and provides access to site configurations.
|
|
|
|
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.discover_sites(['wordpress', 'gitea'])
|
|
>>> 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 discover_sites(self, plugin_types: list[str]) -> int:
|
|
"""
|
|
Discover sites from environment variables.
|
|
|
|
Scans environment for site configurations and registers them.
|
|
|
|
Args:
|
|
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
|
|
|
|
Returns:
|
|
Number of sites discovered
|
|
|
|
Examples:
|
|
>>> count = manager.discover_sites(['wordpress', 'gitea'])
|
|
>>> print(f"Discovered {count} sites")
|
|
"""
|
|
self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}")
|
|
|
|
total_discovered = 0
|
|
for plugin_type in plugin_types:
|
|
count = self._discover_plugin_sites(plugin_type)
|
|
total_discovered += count
|
|
|
|
self.logger.info(
|
|
f"Discovery complete. Found {total_discovered} sites "
|
|
f"across {len(plugin_types)} plugin types."
|
|
)
|
|
|
|
return total_discovered
|
|
|
|
# Reserved words that should NOT be interpreted as site IDs
|
|
RESERVED_SITE_WORDS = {
|
|
"limit",
|
|
"rate",
|
|
"config",
|
|
"debug",
|
|
"log",
|
|
"level",
|
|
"mode",
|
|
"timeout",
|
|
"retry",
|
|
"max",
|
|
"min",
|
|
"default",
|
|
"global",
|
|
"enabled",
|
|
"disabled",
|
|
"host",
|
|
"port",
|
|
"path",
|
|
"key",
|
|
"secret",
|
|
"token",
|
|
"advanced",
|
|
"basic",
|
|
"simple",
|
|
"pro",
|
|
"premium",
|
|
"standard",
|
|
}
|
|
|
|
def _discover_plugin_sites(self, plugin_type: str) -> int:
|
|
"""
|
|
Discover all sites for a specific plugin type.
|
|
|
|
Args:
|
|
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
|
|
Returns:
|
|
Number of sites discovered for this plugin type
|
|
|
|
Examples:
|
|
>>> count = manager._discover_plugin_sites('wordpress')
|
|
"""
|
|
prefix = plugin_type.upper() + "_"
|
|
|
|
# Build list of longer prefixes from other plugin types to avoid collisions.
|
|
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
|
|
from plugins import registry as plugin_registry
|
|
|
|
all_plugin_types = plugin_registry.get_registered_types()
|
|
longer_prefixes = [
|
|
pt.upper() + "_"
|
|
for pt in all_plugin_types
|
|
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
|
|
]
|
|
|
|
# Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc.
|
|
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
|
|
|
# Find all unique site IDs
|
|
site_ids = set()
|
|
for env_key in os.environ.keys():
|
|
# Skip env vars that belong to a more specific plugin type
|
|
if any(env_key.startswith(lp) for lp in longer_prefixes):
|
|
continue
|
|
|
|
match = env_pattern.match(env_key)
|
|
if match:
|
|
site_id = match.group(1).lower()
|
|
# Skip reserved words that are not real site IDs
|
|
if site_id not in self.RESERVED_SITE_WORDS:
|
|
site_ids.add(site_id)
|
|
|
|
# Load configuration for each site
|
|
discovered_count = 0
|
|
for site_id in site_ids:
|
|
try:
|
|
config = self._load_site_config(plugin_type, site_id)
|
|
if config:
|
|
self.register_site(config)
|
|
discovered_count += 1
|
|
except Exception as e:
|
|
self.logger.error(
|
|
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
|
|
)
|
|
|
|
return discovered_count
|
|
|
|
def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None:
|
|
"""
|
|
Load configuration for a site from environment.
|
|
|
|
Args:
|
|
plugin_type: Plugin type
|
|
site_id: Site ID
|
|
|
|
Returns:
|
|
SiteConfig if successful, None if incomplete
|
|
|
|
Examples:
|
|
>>> config = manager._load_site_config('wordpress', 'site1')
|
|
"""
|
|
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
|
|
config_data = {"site_id": site_id, "plugin_type": plugin_type}
|
|
|
|
# Collect all config keys for this site
|
|
for env_key, env_value in os.environ.items():
|
|
if env_key.startswith(prefix):
|
|
# Extract config key (everything after prefix)
|
|
config_key = env_key[len(prefix) :].lower()
|
|
config_data[config_key] = env_value
|
|
|
|
# Must have at least some configuration beyond site_id and plugin_type
|
|
if len(config_data) <= 2:
|
|
return None
|
|
|
|
try:
|
|
# Create and validate SiteConfig
|
|
config = SiteConfig(**config_data)
|
|
|
|
self.logger.debug(
|
|
f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}"
|
|
)
|
|
|
|
return config
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True)
|
|
return None
|
|
|
|
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 check your environment variables."
|
|
)
|
|
|
|
# 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 and check environment variables."
|
|
)
|
|
|
|
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(),
|
|
}
|
|
)
|
|
|
|
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'
|
|
"""
|
|
# Parse full_id to get plugin_type and site_id
|
|
parts = full_id.split("_", 1)
|
|
if len(parts) != 2:
|
|
return full_id
|
|
|
|
plugin_type, site_id = parts
|
|
|
|
# Try to get config
|
|
try:
|
|
config = self.get_site_config(plugin_type, site_id)
|
|
# If alias exists and is different from site_id, use alias
|
|
if config.alias and config.alias != config.site_id:
|
|
return config.alias
|
|
return full_id
|
|
except ValueError:
|
|
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.discover_sites(['wordpress'])
|
|
"""
|
|
global _site_manager
|
|
if _site_manager is None:
|
|
_site_manager = SiteManager()
|
|
return _site_manager
|