""" RootCache Plugin for Deb-Mock This plugin provides root cache management for faster builds, inspired by Fedora's Mock root_cache plugin but adapted for Debian-based systems. """ import os import tarfile import hashlib import json import time import logging from pathlib import Path from typing import Dict, Any, Optional from .base import BasePlugin logger = logging.getLogger(__name__) class RootCachePlugin(BasePlugin): """ Root cache management for faster builds. This plugin caches the chroot environment in a compressed tarball, which can significantly speed up subsequent builds by avoiding the need to recreate the entire chroot from scratch. """ def __init__(self, config, hook_manager): """Initialize the RootCache plugin.""" super().__init__(config, hook_manager) self.cache_settings = self._get_cache_settings() self.cache_file = self._get_cache_file_path() self._log_info(f"Initialized with cache dir: {self.cache_settings['cache_dir']}") def _register_hooks(self): """Register root cache hooks.""" self.hook_manager.add_hook("preinit", self.preinit) self.hook_manager.add_hook("postinit", self.postinit) self.hook_manager.add_hook("postchroot", self.postchroot) self.hook_manager.add_hook("postshell", self.postshell) self.hook_manager.add_hook("clean", self.clean) self._log_debug("Registered root cache hooks") def _get_cache_settings(self) -> Dict[str, Any]: """ Get cache settings from configuration. Returns: Dictionary with cache settings """ plugin_config = self._get_plugin_config() return { 'cache_dir': plugin_config.get('cache_dir', '/var/cache/deb-mock/root-cache'), 'max_age_days': plugin_config.get('max_age_days', 7), 'compression': plugin_config.get('compression', 'gzip'), 'exclude_dirs': plugin_config.get('exclude_dirs', ['/tmp', '/var/tmp', '/var/cache']), 'exclude_patterns': plugin_config.get('exclude_patterns', ['*.log', '*.tmp']), 'min_cache_size_mb': plugin_config.get('min_cache_size_mb', 100), 'auto_cleanup': plugin_config.get('auto_cleanup', True) } def _get_cache_file_path(self) -> str: """ Get the cache file path based on configuration. Returns: Path to the cache file """ cache_dir = self.cache_settings['cache_dir'] compression = self.cache_settings['compression'] # Create cache directory if it doesn't exist os.makedirs(cache_dir, exist_ok=True) # Determine file extension based on compression extensions = { 'gzip': '.tar.gz', 'bzip2': '.tar.bz2', 'xz': '.tar.xz', 'zstd': '.tar.zst' } ext = extensions.get(compression, '.tar.gz') return os.path.join(cache_dir, f"cache{ext}") def preinit(self, context: Dict[str, Any]) -> None: """ Restore chroot from cache before initialization. Args: context: Context dictionary with chroot information """ if not self.enabled: return chroot_path = context.get('chroot_path') if not chroot_path: self._log_warning("No chroot_path in context, skipping cache restoration") return if not self._cache_exists(): self._log_debug("No cache file found, will create new chroot") return if not self._is_cache_valid(): self._log_debug("Cache is invalid or expired, will create new chroot") return self._log_info("Restoring chroot from cache") try: self._restore_from_cache(chroot_path) self._log_info("Successfully restored chroot from cache") except Exception as e: self._log_error(f"Failed to restore from cache: {e}") def postinit(self, context: Dict[str, Any]) -> None: """ Create cache after successful initialization. Args: context: Context dictionary with chroot information """ if not self.enabled: return chroot_path = context.get('chroot_path') if not chroot_path: self._log_warning("No chroot_path in context, skipping cache creation") return self._log_info("Creating root cache") try: self._create_cache(chroot_path) self._log_info("Successfully created root cache") except Exception as e: self._log_error(f"Failed to create cache: {e}") def postchroot(self, context: Dict[str, Any]) -> None: """ Update cache after chroot operations. Args: context: Context dictionary with chroot information """ if not self.enabled: return chroot_path = context.get('chroot_path') if not chroot_path: return self._log_debug("Updating cache after chroot operations") try: self._update_cache(chroot_path) except Exception as e: self._log_error(f"Failed to update cache: {e}") def postshell(self, context: Dict[str, Any]) -> None: """ Update cache after shell operations. Args: context: Context dictionary with chroot information """ if not self.enabled: return chroot_path = context.get('chroot_path') if not chroot_path: return self._log_debug("Updating cache after shell operations") try: self._update_cache(chroot_path) except Exception as e: self._log_error(f"Failed to update cache: {e}") def clean(self, context: Dict[str, Any]) -> None: """ Clean up cache resources. Args: context: Context dictionary with cleanup information """ if not self.enabled: return if self.cache_settings['auto_cleanup']: self._log_info("Cleaning up old caches") try: cleaned_count = self._cleanup_old_caches() self._log_info(f"Cleaned up {cleaned_count} old cache files") except Exception as e: self._log_error(f"Failed to cleanup old caches: {e}") def _cache_exists(self) -> bool: """ Check if cache file exists. Returns: True if cache file exists, False otherwise """ return os.path.exists(self.cache_file) def _is_cache_valid(self) -> bool: """ Check if cache is valid and not expired. Returns: True if cache is valid, False otherwise """ if not self._cache_exists(): return False # Check file age file_age = time.time() - os.path.getmtime(self.cache_file) max_age_seconds = self.cache_settings['max_age_days'] * 24 * 3600 if file_age > max_age_seconds: self._log_debug(f"Cache is {file_age/3600:.1f} hours old, max age is {max_age_seconds/3600:.1f} hours") return False # Check file size file_size_mb = os.path.getsize(self.cache_file) / (1024 * 1024) min_size_mb = self.cache_settings['min_cache_size_mb'] if file_size_mb < min_size_mb: self._log_debug(f"Cache size {file_size_mb:.1f}MB is below minimum {min_size_mb}MB") return False return True def _restore_from_cache(self, chroot_path: str) -> None: """ Restore chroot from cache. Args: chroot_path: Path to restore chroot to """ if not self._cache_exists(): raise FileNotFoundError("Cache file does not exist") # Create chroot directory if it doesn't exist os.makedirs(chroot_path, exist_ok=True) # Extract cache compression = self.cache_settings['compression'] if compression == 'gzip': mode = 'r:gz' elif compression == 'bzip2': mode = 'r:bz2' elif compression == 'xz': mode = 'r:xz' elif compression == 'zstd': mode = 'r:zstd' else: mode = 'r:gz' # Default to gzip try: with tarfile.open(self.cache_file, mode) as tar: tar.extractall(path=chroot_path) self._log_debug(f"Successfully extracted cache to {chroot_path}") except Exception as e: self._log_error(f"Failed to extract cache: {e}") raise def _create_cache(self, chroot_path: str) -> None: """ Create cache from chroot. Args: chroot_path: Path to the chroot to cache """ if not os.path.exists(chroot_path): raise FileNotFoundError(f"Chroot path does not exist: {chroot_path}") # Determine compression mode compression = self.cache_settings['compression'] if compression == 'gzip': mode = 'w:gz' elif compression == 'bzip2': mode = 'w:bz2' elif compression == 'xz': mode = 'w:xz' elif compression == 'zstd': mode = 'w:zstd' else: mode = 'w:gz' # Default to gzip try: with tarfile.open(self.cache_file, mode) as tar: # Add chroot contents to archive tar.add(chroot_path, arcname='', exclude=self._get_exclude_filter()) self._log_debug(f"Successfully created cache: {self.cache_file}") except Exception as e: self._log_error(f"Failed to create cache: {e}") raise def _update_cache(self, chroot_path: str) -> None: """ Update existing cache. Args: chroot_path: Path to the chroot to update cache from """ # For now, just recreate the cache # In the future, we could implement incremental updates self._create_cache(chroot_path) def _cleanup_old_caches(self) -> int: """ Clean up old cache files. Returns: Number of cache files cleaned up """ cache_dir = self.cache_settings['cache_dir'] max_age_seconds = self.cache_settings['max_age_days'] * 24 * 3600 current_time = time.time() cleaned_count = 0 if not os.path.exists(cache_dir): return 0 for cache_file in os.listdir(cache_dir): if not cache_file.startswith('cache'): continue cache_path = os.path.join(cache_dir, cache_file) file_age = current_time - os.path.getmtime(cache_path) if file_age > max_age_seconds: try: os.remove(cache_path) cleaned_count += 1 self._log_debug(f"Removed old cache: {cache_file}") except Exception as e: self._log_warning(f"Failed to remove old cache {cache_file}: {e}") return cleaned_count def _get_exclude_filter(self): """ Get exclude filter function for tarfile. Returns: Function to filter out excluded files/directories """ exclude_dirs = self.cache_settings['exclude_dirs'] exclude_patterns = self.cache_settings['exclude_patterns'] def exclude_filter(tarinfo): # Check excluded directories for exclude_dir in exclude_dirs: if tarinfo.name.startswith(exclude_dir.lstrip('/')): return None # Check excluded patterns for pattern in exclude_patterns: if pattern in tarinfo.name: return None return tarinfo return exclude_filter def validate_config(self, config: Any) -> bool: """ Validate plugin configuration. Args: config: Configuration to validate Returns: True if configuration is valid, False otherwise """ plugin_config = getattr(config, 'plugins', {}).get('root_cache', {}) # Validate cache_dir cache_dir = plugin_config.get('cache_dir', '/var/cache/deb-mock/root-cache') if not cache_dir: self._log_error("cache_dir cannot be empty") return False # Validate max_age_days max_age_days = plugin_config.get('max_age_days', 7) if not isinstance(max_age_days, int) or max_age_days <= 0: self._log_error(f"Invalid max_age_days: {max_age_days}. Must be positive integer") return False # Validate compression valid_compressions = ['gzip', 'bzip2', 'xz', 'zstd'] compression = plugin_config.get('compression', 'gzip') if compression not in valid_compressions: self._log_error(f"Invalid compression: {compression}. Valid options: {valid_compressions}") return False # Validate exclude_dirs exclude_dirs = plugin_config.get('exclude_dirs', ['/tmp', '/var/tmp', '/var/cache']) if not isinstance(exclude_dirs, list): self._log_error("exclude_dirs must be a list") return False # Validate exclude_patterns exclude_patterns = plugin_config.get('exclude_patterns', ['*.log', '*.tmp']) if not isinstance(exclude_patterns, list): self._log_error("exclude_patterns must be a list") return False # Validate min_cache_size_mb min_cache_size_mb = plugin_config.get('min_cache_size_mb', 100) if not isinstance(min_cache_size_mb, (int, float)) or min_cache_size_mb < 0: self._log_error(f"Invalid min_cache_size_mb: {min_cache_size_mb}. Must be non-negative number") return False # Validate auto_cleanup auto_cleanup = plugin_config.get('auto_cleanup', True) if not isinstance(auto_cleanup, bool): self._log_error(f"Invalid auto_cleanup: {auto_cleanup}. Must be boolean") return False return True def get_plugin_info(self) -> Dict[str, Any]: """ Get plugin information. Returns: Dictionary with plugin information """ info = super().get_plugin_info() info.update({ 'cache_dir': self.cache_settings['cache_dir'], 'cache_file': self.cache_file, 'max_age_days': self.cache_settings['max_age_days'], 'compression': self.cache_settings['compression'], 'exclude_dirs': self.cache_settings['exclude_dirs'], 'exclude_patterns': self.cache_settings['exclude_patterns'], 'min_cache_size_mb': self.cache_settings['min_cache_size_mb'], 'auto_cleanup': self.cache_settings['auto_cleanup'], 'cache_exists': self._cache_exists(), 'cache_valid': self._is_cache_valid() if self._cache_exists() else False, 'hooks': ['preinit', 'postinit', 'postchroot', 'postshell', 'clean'] }) return info