2026-02-05 18:05:06 -05:00

210 lines
5.1 KiB
Python

"""Common utilities for Gentoo installation."""
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import NoReturn
# --- Colors ---
class Color:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[0;33m"
BOLD = "\033[1m"
NC = "\033[0m" # No color
def info(msg: str) -> None:
print(f"{Color.YELLOW}{msg}{Color.NC}")
def success(msg: str) -> None:
print(f"{Color.GREEN}{msg}{Color.NC}")
def error(msg: str) -> None:
print(f"{Color.RED}{msg}{Color.NC}", file=sys.stderr)
def warn(msg: str) -> None:
print(f"{Color.YELLOW}WARNING: {msg}{Color.NC}", file=sys.stderr)
def fatal(msg: str) -> NoReturn:
error(f"FATAL: {msg}")
sys.exit(1)
def prompt(msg: str) -> str:
return input(f"{Color.YELLOW}{msg}{Color.NC}")
def confirm(msg: str, require: str = "yes") -> bool:
response = prompt(f"{msg} (type '{require}' to confirm): ")
return response == require
# --- Command Execution ---
@dataclass
class RunResult:
returncode: int
stdout: str
stderr: str
@property
def ok(self) -> bool:
return self.returncode == 0
def run(
*args: str,
check: bool = True,
capture: bool = False,
quiet: bool = False,
) -> RunResult:
"""Run a command with proper error handling.
Args:
*args: Command and arguments
check: Raise exception on non-zero exit
capture: Capture stdout/stderr (otherwise inherit terminal)
quiet: Suppress command echo
"""
if not quiet:
info(f"$ {' '.join(args)}")
result = subprocess.run(
args,
capture_output=capture,
text=True,
)
run_result = RunResult(
returncode=result.returncode,
stdout=result.stdout if capture else "",
stderr=result.stderr if capture else "",
)
if check and not run_result.ok:
error(f"Command failed with exit code {result.returncode}")
if capture and result.stderr:
error(result.stderr)
raise subprocess.CalledProcessError(result.returncode, args)
return run_result
def run_quiet(*args: str, check: bool = True) -> RunResult:
"""Run a command silently, capturing output."""
return run(*args, check=check, capture=True, quiet=True)
# --- System Checks ---
def check_root() -> None:
"""Ensure running as root."""
import os
if os.geteuid() != 0:
fatal("This script must be run as root")
def check_uefi() -> None:
"""Ensure booted in UEFI mode."""
if not Path("/sys/firmware/efi/efivars").is_dir():
fatal("UEFI boot mode not detected. This script requires UEFI.")
success("UEFI boot mode confirmed.")
def is_mounted(path: Path) -> bool:
"""Check if a path is a mount point."""
result = run_quiet("mountpoint", "-q", str(path), check=False)
return result.ok
def is_block_device(path: Path) -> bool:
"""Check if path is a block device."""
return path.is_block_device()
# --- Portage Helpers ---
def dispatch_config() -> bool:
"""Auto-merge pending portage config changes.
Returns True if changes were dispatched, False if none found.
"""
portage_dir = Path("/etc/portage")
# Check for pending changes
has_cfg_files = bool(list(portage_dir.glob("._cfg*")))
has_autounmask = any(
(portage_dir / subdir / "zz-autounmask").exists()
for subdir in ["package.use", "package.accept_keywords", "package.license"]
)
if not has_cfg_files and not has_autounmask:
return False
info("Auto-merging pending portage config changes...")
# Try dispatch-conf first, fall back to etc-update
result = run(
"bash", "-c", "yes u | dispatch-conf",
check=False, capture=True, quiet=True
)
if not result.ok:
run(
"etc-update", "--automode", "-5",
check=False, capture=True, quiet=True
)
return True
def emerge(
*packages: str,
ask: bool = True,
verbose: bool = True,
extra_args: list[str] | None = None,
max_retries: int = 3,
) -> None:
"""Run emerge with automatic handling of USE/keyword/license changes.
Args:
*packages: Package atoms to emerge
ask: Prompt for confirmation (--ask)
verbose: Show detailed output (--verbose)
extra_args: Additional emerge arguments (e.g., ["--update", "--deep"])
max_retries: Max attempts after auto-dispatching config changes
"""
args = ["emerge", "--autounmask-write", "--autounmask-continue"]
if ask:
args.append("--ask")
if verbose:
args.append("--verbose")
if extra_args:
args.extend(extra_args)
args.extend(packages)
for attempt in range(1, max_retries + 1):
if max_retries > 1:
info(f"Emerge attempt {attempt}/{max_retries}...")
result = run(*args, check=False)
if result.ok:
return
# Check if we can auto-dispatch and retry
if dispatch_config():
continue
# No config changes to dispatch, actual failure
fatal(f"Emerge failed: {' '.join(packages)}")
fatal(f"Emerge failed after {max_retries} attempts")