""" Tmpfs Plugin for Deb-Mock This plugin uses tmpfs for faster I/O operations in chroot, inspired by Fedora's Mock tmpfs plugin but adapted for Debian-based systems. """ import os import subprocess import logging from typing import Dict, Any, Optional from .base import BasePlugin logger = logging.getLogger(__name__) class TmpfsPlugin(BasePlugin): """ Use tmpfs for faster I/O operations in chroot. This plugin mounts a tmpfs filesystem on the chroot directory, which can significantly improve build performance by using RAM instead of disk for temporary files and build artifacts. """ def __init__(self, config, hook_manager): """Initialize the Tmpfs plugin.""" super().__init__(config, hook_manager) self.tmpfs_settings = self._get_tmpfs_settings() self.mounted = False self._log_info(f"Initialized with size: {self.tmpfs_settings['size']}") def _register_hooks(self): """Register tmpfs hooks.""" self.hook_manager.add_hook("mount_root", self.mount_root) self.hook_manager.add_hook("postumount", self.postumount) self._log_debug("Registered mount_root and postumount hooks") def _get_tmpfs_settings(self) -> Dict[str, Any]: """ Get tmpfs settings from configuration. Returns: Dictionary with tmpfs settings """ plugin_config = self._get_plugin_config() return { 'size': plugin_config.get('size', '2G'), 'mode': plugin_config.get('mode', '0755'), 'mount_point': plugin_config.get('mount_point', '/tmp'), 'keep_mounted': plugin_config.get('keep_mounted', False), 'required_ram_mb': plugin_config.get('required_ram_mb', 2048), # 2GB default 'max_fs_size': plugin_config.get('max_fs_size', None) } def mount_root(self, context: Dict[str, Any]) -> None: """ Mount tmpfs when chroot is mounted. 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 tmpfs mount") return # Check if we have enough RAM if not self._check_ram_requirements(): self._log_warning("Insufficient RAM for tmpfs, skipping mount") return # Check if already mounted if self._is_mounted(chroot_path): self._log_info(f"Tmpfs already mounted at {chroot_path}") self.mounted = True return self._log_info(f"Mounting tmpfs at {chroot_path}") try: self._mount_tmpfs(chroot_path) self.mounted = True self._log_info("Tmpfs mounted successfully") except Exception as e: self._log_error(f"Failed to mount tmpfs: {e}") self.mounted = False def postumount(self, context: Dict[str, Any]) -> None: """ Unmount tmpfs when chroot is unmounted. Args: context: Context dictionary with chroot information """ if not self.enabled or not self.mounted: return chroot_path = context.get('chroot_path') if not chroot_path: self._log_warning("No chroot_path in context, skipping tmpfs unmount") return # Check if we should keep mounted if self.tmpfs_settings['keep_mounted']: self._log_info("Keeping tmpfs mounted as requested") return self._log_info(f"Unmounting tmpfs from {chroot_path}") try: self._unmount_tmpfs(chroot_path) self.mounted = False self._log_info("Tmpfs unmounted successfully") except Exception as e: self._log_error(f"Failed to unmount tmpfs: {e}") def _check_ram_requirements(self) -> bool: """ Check if system has enough RAM for tmpfs. Returns: True if system has sufficient RAM, False otherwise """ try: # Get system RAM in MB with open('/proc/meminfo', 'r') as f: for line in f: if line.startswith('MemTotal:'): mem_total_kb = int(line.split()[1]) mem_total_mb = mem_total_kb // 1024 break else: self._log_warning("Could not determine system RAM") return False required_ram = self.tmpfs_settings['required_ram_mb'] if mem_total_mb < required_ram: self._log_warning( f"System has {mem_total_mb}MB RAM, but {required_ram}MB is required for tmpfs" ) return False self._log_debug(f"System RAM: {mem_total_mb}MB, required: {required_ram}MB") return True except Exception as e: self._log_error(f"Failed to check RAM requirements: {e}") return False def _is_mounted(self, chroot_path: str) -> bool: """ Check if tmpfs is already mounted at the given path. Args: chroot_path: Path to check Returns: True if tmpfs is mounted, False otherwise """ try: # Check if the path is a mount point result = subprocess.run( ['mountpoint', '-q', chroot_path], capture_output=True, text=True ) return result.returncode == 0 except FileNotFoundError: # mountpoint command not available, try alternative method try: with open('/proc/mounts', 'r') as f: for line in f: parts = line.split() if len(parts) >= 2 and parts[1] == chroot_path: return parts[0] == 'tmpfs' return False except Exception: self._log_warning("Could not check mount status") return False def _mount_tmpfs(self, chroot_path: str) -> None: """ Mount tmpfs at the specified path. Args: chroot_path: Path where to mount tmpfs """ # Build mount options options = [] # Add mode option mode = self.tmpfs_settings['mode'] options.append(f'mode={mode}') # Add size option size = self.tmpfs_settings['size'] if size: options.append(f'size={size}') # Add max_fs_size if specified max_fs_size = self.tmpfs_settings['max_fs_size'] if max_fs_size: options.append(f'size={max_fs_size}') # Add noatime for better performance options.append('noatime') # Build mount command mount_cmd = [ 'mount', '-n', '-t', 'tmpfs', '-o', ','.join(options), 'deb_mock_tmpfs', chroot_path ] self._log_debug(f"Mount command: {' '.join(mount_cmd)}") try: result = subprocess.run( mount_cmd, capture_output=True, text=True, check=True ) self._log_debug("Tmpfs mount command executed successfully") except subprocess.CalledProcessError as e: self._log_error(f"Tmpfs mount failed: {e.stderr}") raise except FileNotFoundError: self._log_error("mount command not found - ensure mount is available") raise def _unmount_tmpfs(self, chroot_path: str) -> None: """ Unmount tmpfs from the specified path. Args: chroot_path: Path where tmpfs is mounted """ # Try normal unmount first try: cmd = ['umount', '-n', chroot_path] result = subprocess.run(cmd, capture_output=True, text=True, check=True) self._log_debug("Tmpfs unmounted successfully") return except subprocess.CalledProcessError as e: self._log_warning(f"Normal unmount failed: {e.stderr}") # Try lazy unmount try: cmd = ['umount', '-n', '-l', chroot_path] result = subprocess.run(cmd, capture_output=True, text=True, check=True) self._log_debug("Tmpfs lazy unmounted successfully") return except subprocess.CalledProcessError as e: self._log_warning(f"Lazy unmount failed: {e.stderr}") # Try force unmount as last resort try: cmd = ['umount', '-n', '-f', chroot_path] result = subprocess.run(cmd, capture_output=True, text=True, check=True) self._log_debug("Tmpfs force unmounted successfully") return except subprocess.CalledProcessError as e: self._log_error(f"Force unmount failed: {e.stderr}") raise 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('tmpfs', {}) # Validate size format size = plugin_config.get('size', '2G') if not self._is_valid_size_format(size): self._log_error(f"Invalid size format: {size}. Use format like '2G', '512M', etc.") return False # Validate mode format mode = plugin_config.get('mode', '0755') if not self._is_valid_mode_format(mode): self._log_error(f"Invalid mode format: {mode}. Use octal format like '0755'") return False # Validate required_ram_mb required_ram = plugin_config.get('required_ram_mb', 2048) if not isinstance(required_ram, int) or required_ram <= 0: self._log_error(f"Invalid required_ram_mb: {required_ram}. Must be positive integer") return False # Validate keep_mounted keep_mounted = plugin_config.get('keep_mounted', False) if not isinstance(keep_mounted, bool): self._log_error(f"Invalid keep_mounted: {keep_mounted}. Must be boolean") return False return True def _is_valid_size_format(self, size: str) -> bool: """ Check if size format is valid. Args: size: Size string to validate Returns: True if format is valid, False otherwise """ if not size: return False # Check if it's a number (bytes) if size.isdigit(): return True # Check if it ends with a valid unit valid_units = ['K', 'M', 'G', 'T'] if size[-1] in valid_units and size[:-1].isdigit(): return True return False def _is_valid_mode_format(self, mode: str) -> bool: """ Check if mode format is valid. Args: mode: Mode string to validate Returns: True if format is valid, False otherwise """ if not mode: return False # Check if it's a valid octal number try: int(mode, 8) return True except ValueError: return False def get_plugin_info(self) -> Dict[str, Any]: """ Get plugin information. Returns: Dictionary with plugin information """ info = super().get_plugin_info() info.update({ 'tmpfs_size': self.tmpfs_settings['size'], 'tmpfs_mode': self.tmpfs_settings['mode'], 'mount_point': self.tmpfs_settings['mount_point'], 'keep_mounted': self.tmpfs_settings['keep_mounted'], 'required_ram_mb': self.tmpfs_settings['required_ram_mb'], 'mounted': self.mounted, 'hooks': ['mount_root', 'postumount'] }) return info