210 lines
5.1 KiB
Python
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")
|