"""
Site Registry
Manages site configurations discovered from environment variables.
Supports multi-site setups with aliases and dynamic discovery.
"""
import logging
import os
import re
from typing import Any
logger = logging.getLogger(__name__)
class SiteInfo:
"""Information about a single site."""
def __init__(
self, plugin_type: str, site_id: str, config: dict[str, Any], alias: str | None = None
):
"""
Initialize site information.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
site_id: Site identifier (e.g., 'site1')
config: Site configuration from environment
alias: Optional friendly alias for the site
"""
self.plugin_type = plugin_type
self.site_id = site_id
self.config = config
self.alias = alias or site_id
def get_full_id(self) -> str:
"""Get full site identifier: plugin_type_site_id"""
return f"{self.plugin_type}_{self.site_id}"
def get_display_name(self) -> str:
"""Get display name (alias if available, otherwise site_id)"""
return self.alias
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"plugin_type": self.plugin_type,
"site_id": self.site_id,
"alias": self.alias,
"full_id": self.get_full_id(),
"config_keys": list(self.config.keys()),
}
class SiteRegistry:
"""
Registry for managing site configurations across plugin types.
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_URL=https://myblog.com
WORDPRESS_SITE2_ALIAS=myblog
"""
def __init__(self):
"""Initialize site registry."""
self.sites: dict[str, SiteInfo] = {} # full_id -> SiteInfo
self.aliases: dict[str, str] = {} # alias -> full_id
self.alias_conflicts: dict[str, list[str]] = {} # alias -> [full_ids that wanted it]
self.logger = logging.getLogger("SiteRegistry")
def discover_sites(self, plugin_types: list[str]) -> None:
"""
Discover sites from environment variables.
Args:
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
"""
self.logger.info("Starting site discovery...")
for plugin_type in plugin_types:
self._discover_plugin_sites(plugin_type)
self.logger.info(
f"Discovery complete. Found {len(self.sites)} sites "
f"with {len(self.aliases)} aliases."
)
# Log alias conflicts if any
if self.alias_conflicts:
self.logger.warning("=" * 50)
self.logger.warning("DUPLICATE ALIAS CONFLICTS DETECTED:")
for alias, full_ids in self.alias_conflicts.items():
winner = self.aliases.get(alias)
losers = [fid for fid in full_ids if fid != winner]
self.logger.warning(
f" Alias '{alias}': {winner} (winner), {losers} (using full_id)"
)
self.logger.warning("=" * 50)
# 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) -> None:
"""
Discover all sites for a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
"""
prefix = plugin_type.upper() + "_"
# Find all site IDs for this plugin type
site_ids = set()
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
for env_key in os.environ.keys():
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)
# Create SiteInfo for each discovered site
for site_id in site_ids:
try:
config = self._load_site_config(plugin_type, site_id)
if config:
alias = config.pop("alias", None) # Extract alias if present
site_info = SiteInfo(plugin_type, site_id, config, alias)
full_id = site_info.get_full_id()
self.sites[full_id] = site_info
# Register alias with duplicate detection
if alias:
self._register_alias_safe(alias, full_id)
# Also register with prefix
prefixed_alias = f"{plugin_type}_{alias}"
self._register_alias_safe(prefixed_alias, full_id)
# Always register site_id as alias too (with safe check)
self._register_alias_safe(site_id, full_id)
self.aliases[full_id] = (
full_id # full_id can reference itself (no conflict possible)
)
# Log with alias status
effective_alias = self._get_effective_alias(alias, full_id)
self.logger.info(f"Discovered site: {full_id} " f"(path: {effective_alias})")
except Exception as e:
self.logger.error(
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
)
def _register_alias_safe(self, alias: str, full_id: str) -> bool:
"""
Register an alias with duplicate detection.
Args:
alias: The alias to register
full_id: The full_id to map the alias to
Returns:
True if alias was registered, False if it was a duplicate
"""
if alias in self.aliases:
existing_full_id = self.aliases[alias]
if existing_full_id != full_id:
# Duplicate alias detected
if alias not in self.alias_conflicts:
self.alias_conflicts[alias] = [existing_full_id]
self.alias_conflicts[alias].append(full_id)
self.logger.warning(
f"Duplicate alias '{alias}': {full_id} conflicts with {existing_full_id}. "
f"{full_id} will use full_id for endpoint path."
)
return False
else:
self.aliases[alias] = full_id
return True
def _get_effective_alias(self, alias: str | None, full_id: str) -> str:
"""
Get the effective path suffix for a site.
If the alias was taken by another site, returns full_id.
Otherwise returns the alias (or full_id if no alias).
Args:
alias: The desired alias
full_id: The full site ID
Returns:
The effective path suffix
"""
if alias:
# Check if this site owns the alias
if self.aliases.get(alias) == full_id:
return alias
else:
# Alias was taken, use full_id
return full_id
return full_id
def get_alias_conflicts(self) -> dict[str, list[str]]:
"""
Get all alias conflicts.
Returns:
Dict mapping conflicted aliases to list of full_ids that wanted them
"""
return self.alias_conflicts.copy()
def get_effective_path_suffix(self, full_id: str) -> str:
"""
Get the effective path suffix for a site's endpoint.
Uses alias if available and not conflicted, otherwise full_id.
Args:
full_id: The full site ID
Returns:
Path suffix to use in endpoint URL
"""
site_info = self.sites.get(full_id)
if not site_info:
return full_id
alias = site_info.alias
return self._get_effective_alias(alias, full_id)
def _load_site_config(self, plugin_type: str, site_id: str) -> dict[str, Any] | None:
"""
Load configuration for a site from environment.
Args:
plugin_type: Plugin type
site_id: Site ID
Returns:
Dict with configuration or None if incomplete
"""
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
config = {}
# 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[config_key] = env_value
if not config:
return None
self.logger.debug(f"Loaded config for {plugin_type}/{site_id}: {list(config.keys())}")
return config
def get_site(self, plugin_type: str, site_identifier: str) -> SiteInfo | None:
"""
Get site information by ID or alias.
Args:
plugin_type: Plugin type (e.g., 'wordpress')
site_identifier: Site ID, alias, or full_id
Returns:
SiteInfo if found, None otherwise
"""
# Try direct lookup first
full_id = f"{plugin_type}_{site_identifier}"
if full_id in self.sites:
return self.sites[full_id]
# Try alias lookup
if site_identifier in self.aliases:
resolved_full_id = self.aliases[site_identifier]
return self.sites.get(resolved_full_id)
# Try prefixed alias
prefixed = f"{plugin_type}_{site_identifier}"
if prefixed in self.aliases:
resolved_full_id = self.aliases[prefixed]
return self.sites.get(resolved_full_id)
return None
def get_sites_by_type(self, plugin_type: str) -> list[SiteInfo]:
"""
Get all sites of a specific plugin type.
Args:
plugin_type: Plugin type to filter by
Returns:
List of SiteInfo objects
"""
return [
site_info for site_info in self.sites.values() if site_info.plugin_type == plugin_type
]
def list_all_sites(self) -> list[dict[str, Any]]:
"""
List all discovered sites.
Returns:
List of site info dictionaries
"""
return [site_info.to_dict() for site_info in self.sites.values()]
def get_site_options(self, plugin_type: str) -> list[str]:
"""
Get available site options for a plugin type (for schema enum).
Args:
plugin_type: Plugin type
Returns:
List of valid site identifiers (IDs and aliases)
"""
options = set()
for site_info in self.get_sites_by_type(plugin_type):
options.add(site_info.site_id)
if site_info.alias and site_info.alias != site_info.site_id:
options.add(site_info.alias)
return sorted(options)
# Global site registry instance
_site_registry: SiteRegistry | None = None
def get_site_registry() -> SiteRegistry:
"""Get the global site registry instance."""
global _site_registry
if _site_registry is None:
_site_registry = SiteRegistry()
return _site_registry