"""Configuration loading and validation using Pydantic."""
|
|
from pathlib import Path
|
from typing import Any
|
|
import yaml
|
from pydantic import BaseModel, Field, field_validator
|
|
|
class RepositoryConfig(BaseModel):
|
"""Repository settings."""
|
|
name: str = Field(..., description="Repository name")
|
path: Path = Field(..., description="Path to repository directory")
|
build_dir: Path = Field(..., description="Path to build directory")
|
compression: str = Field(default="zst", description="Compression format")
|
|
|
class BuildingConfig(BaseModel):
|
"""Build settings."""
|
|
parallel: bool = Field(default=True, description="Enable parallel builds")
|
max_workers: int = Field(default=4, ge=1, le=32, description="Maximum parallel workers")
|
clean: bool = Field(default=True, description="Clean build directory after build")
|
update_system: bool = Field(default=False, description="Update system before building")
|
retry_attempts: int = Field(default=3, ge=1, le=10, description="Retry attempts on failure")
|
retry_delay: int = Field(default=5, ge=1, description="Base delay between retries (seconds)")
|
|
|
class SigningConfig(BaseModel):
|
"""Package signing settings."""
|
|
enabled: bool = Field(default=False, description="Enable package signing")
|
key: str = Field(default="", description="GPG key ID for signing")
|
|
|
class EmailConfig(BaseModel):
|
"""Email notification settings."""
|
|
enabled: bool = Field(default=False, description="Enable email notifications")
|
to: str = Field(default="", description="Recipient email address")
|
from_addr: str = Field(default="", alias="from", description="Sender email address")
|
smtp_host: str = Field(default="localhost", description="SMTP server host")
|
smtp_port: int = Field(default=25, description="SMTP server port")
|
use_tls: bool = Field(default=False, description="Use TLS for SMTP")
|
username: str = Field(default="", description="SMTP username")
|
password: str = Field(default="", description="SMTP password")
|
|
|
class WebhookConfig(BaseModel):
|
"""Webhook notification settings (extensible for future use)."""
|
|
enabled: bool = Field(default=False, description="Enable webhook notifications")
|
url: str = Field(default="", description="Webhook URL")
|
type: str = Field(default="generic", description="Webhook type (generic, discord, slack)")
|
|
|
class NotificationsConfig(BaseModel):
|
"""Notification settings."""
|
|
email: EmailConfig = Field(default_factory=EmailConfig)
|
webhooks: list[WebhookConfig] = Field(default_factory=list)
|
|
|
class PackageRetentionConfig(BaseModel):
|
"""Package retention settings."""
|
|
keep_versions: int = Field(default=3, ge=1, le=100, description="Number of old versions to keep")
|
cleanup_on_build: bool = Field(default=True, description="Clean old versions after build")
|
|
|
class PackageOverride(BaseModel):
|
"""Per-package build overrides."""
|
|
skip_checksums: bool = Field(default=False, description="Skip checksum verification")
|
extra_args: list[str] = Field(default_factory=list, description="Extra makepkg arguments")
|
env: dict[str, str] = Field(default_factory=dict, description="Environment variables")
|
|
|
class Config(BaseModel):
|
"""Main configuration model."""
|
|
repository: RepositoryConfig
|
building: BuildingConfig = Field(default_factory=BuildingConfig)
|
signing: SigningConfig = Field(default_factory=SigningConfig)
|
notifications: NotificationsConfig = Field(default_factory=NotificationsConfig)
|
retention: PackageRetentionConfig = Field(default_factory=PackageRetentionConfig)
|
package_overrides: dict[str, PackageOverride] = Field(
|
default_factory=dict, description="Per-package overrides"
|
)
|
log_level: str = Field(default="INFO", description="Logging level")
|
log_file: Path | None = Field(default=None, description="Optional log file path")
|
|
@field_validator("log_level")
|
@classmethod
|
def validate_log_level(cls, v: str) -> str:
|
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
if v.upper() not in valid_levels:
|
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
|
return v.upper()
|
|
|
def load_config(path: Path) -> Config:
|
"""Load and validate configuration from YAML file.
|
|
Args:
|
path: Path to configuration file
|
|
Returns:
|
Validated Config object
|
|
Raises:
|
FileNotFoundError: If config file doesn't exist
|
ValidationError: If config is invalid
|
"""
|
if not path.exists():
|
raise FileNotFoundError(f"Configuration file not found: {path}")
|
|
with open(path) as f:
|
data = yaml.safe_load(f)
|
|
return Config.model_validate(data)
|
|
|
def migrate_vars_sh(vars_path: Path) -> dict[str, Any]:
|
"""Migrate old vars.sh to new config format.
|
|
Args:
|
vars_path: Path to vars.sh file
|
|
Returns:
|
Dictionary suitable for Config.model_validate()
|
"""
|
variables: dict[str, str] = {}
|
|
with open(vars_path) as f:
|
for line in f:
|
line = line.strip()
|
if line and not line.startswith("#") and "=" in line:
|
# Handle export statements
|
if line.startswith("export "):
|
line = line[7:]
|
key, _, value = line.partition("=")
|
# Remove quotes
|
value = value.strip("'\"")
|
variables[key] = value
|
|
# Convert to new format
|
config: dict[str, Any] = {
|
"repository": {
|
"name": variables.get("REPONAME", ""),
|
"path": variables.get("REPODIR", "/repo/x86_64"),
|
"build_dir": variables.get("BUILDDIR", "/repo/build"),
|
"compression": variables.get("COMPRESSION", "zst"),
|
},
|
"building": {
|
"parallel": variables.get("PARALLEL", "N") == "Y",
|
"clean": variables.get("CLEAN", "N") == "Y",
|
"update_system": variables.get("UPDATE", "N") == "Y",
|
},
|
"signing": {
|
"enabled": variables.get("SIGN", "N") == "Y",
|
"key": variables.get("KEY", ""),
|
},
|
"notifications": {
|
"email": {
|
"enabled": bool(variables.get("TO_EMAIL")),
|
"to": variables.get("TO_EMAIL", ""),
|
"from": variables.get("FROM_EMAIL", ""),
|
}
|
},
|
"retention": {
|
"keep_versions": int(variables.get("NUM_OLD", "5")),
|
},
|
}
|
|
return config
|
|
|
def save_config(config: Config, path: Path) -> None:
|
"""Save configuration to YAML file.
|
|
Args:
|
config: Config object to save
|
path: Path to save configuration file
|
"""
|
data = config.model_dump(by_alias=True, exclude_none=True)
|
|
# Convert Path objects to strings for YAML
|
def convert_paths(obj: Any) -> Any:
|
if isinstance(obj, Path):
|
return str(obj)
|
elif isinstance(obj, dict):
|
return {k: convert_paths(v) for k, v in obj.items()}
|
elif isinstance(obj, list):
|
return [convert_paths(v) for v in obj]
|
return obj
|
|
data = convert_paths(data)
|
|
with open(path, "w") as f:
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|