from __future__ import annotations import json import logging import os import shutil import stat import subprocess import tempfile import urllib.request from dataclasses import dataclass from datetime import datetime from pathlib import Path from .config import AppPaths from .models import TaskDefinition, TaskResult from .system_info import SystemInfo class SecureCheckError(RuntimeError): """Raised when a task cannot be completed.""" @dataclass class CommandResult: command: list[str] returncode: int stdout: str stderr: str @dataclass class PackageOperation: requested: list[str] installed: list[str] already_present: list[str] @property def changed(self) -> bool: return bool(self.installed) @dataclass class ExecutionContext: paths: AppPaths system: SystemInfo logger: logging.Logger dry_run: bool = False assume_yes: bool = True def __post_init__(self) -> None: self.runner = CommandRunner(self) def info(self, message: str) -> None: print(message) self.logger.info(message) def warning(self, message: str) -> None: print(f"WARNING: {message}") self.logger.warning(message) def error(self, message: str) -> None: print(f"ERROR: {message}") self.logger.error(message) def make_result( self, task: TaskDefinition, *, success: bool, changed: bool, started_at: datetime, details: list[str] | None = None, error: str | None = None, ) -> TaskResult: return TaskResult( key=task.key, label=task.label, success=success, changed=changed, started_at=started_at, finished_at=datetime.now(), details=details or [], error=error, ) class CommandRunner: def __init__(self, context: ExecutionContext) -> None: self.context = context self._package_index_updated = False def command_exists(self, command: str) -> bool: return shutil.which(command) is not None def run( self, command: list[str], *, requires_root: bool = False, run_as_user: str | None = None, check: bool = True, capture_output: bool = True, env: dict[str, str] | None = None, input_text: str | None = None, ) -> CommandResult: final_command = list(command) if run_as_user and os.geteuid() == 0 and run_as_user != "root": final_command = ["sudo", "-u", run_as_user] + final_command elif requires_root and os.geteuid() != 0: final_command = ["sudo"] + final_command rendered = " ".join(final_command) self.context.logger.info("Commande: %s", rendered) if self.context.dry_run: self.context.info(f"[dry-run] {rendered}") return CommandResult(command=final_command, returncode=0, stdout="", stderr="") completed = subprocess.run( final_command, text=True, capture_output=capture_output, env=env, input=input_text, check=False, ) stdout = completed.stdout or "" stderr = completed.stderr or "" if stdout.strip(): self.context.logger.info("stdout:\n%s", stdout.rstrip()) if stderr.strip(): self.context.logger.warning("stderr:\n%s", stderr.rstrip()) if check and completed.returncode != 0: raise SecureCheckError(f"Echec de la commande ({completed.returncode}): {rendered}") return CommandResult( command=final_command, returncode=completed.returncode, stdout=stdout, stderr=stderr, ) def update_package_index(self) -> None: if self._package_index_updated: return manager = self.context.system.package_manager if manager == "apt-get": self.run(["apt-get", "update"], requires_root=True) elif manager in {"dnf", "yum"}: self.run([manager, "makecache"], requires_root=True) elif manager == "pacman": self.run(["pacman", "-Sy"], requires_root=True) else: raise SecureCheckError("Gestionnaire de paquets non supporté") self._package_index_updated = True def is_package_installed(self, package: str) -> bool: manager = self.context.system.package_manager if manager == "apt-get": return subprocess.run(["dpkg", "-s", package], capture_output=True).returncode == 0 if manager in {"dnf", "yum"}: return subprocess.run(["rpm", "-q", package], capture_output=True).returncode == 0 if manager == "pacman": return subprocess.run(["pacman", "-Q", package], capture_output=True).returncode == 0 return False def package_available(self, package: str) -> bool: manager = self.context.system.package_manager if manager == "apt-get": self.update_package_index() return subprocess.run(["apt-cache", "show", package], capture_output=True).returncode == 0 if manager in {"dnf", "yum"}: return subprocess.run([manager, "info", package], capture_output=True).returncode == 0 if manager == "pacman": return subprocess.run(["pacman", "-Si", package], capture_output=True).returncode == 0 return False def ensure_packages(self, packages: list[str]) -> bool: return self.ensure_packages_report(packages).changed def ensure_packages_report(self, packages: list[str]) -> PackageOperation: if not packages: return PackageOperation(requested=[], installed=[], already_present=[]) manager = self.context.system.package_manager self.update_package_index() already_present = [package for package in packages if self.is_package_installed(package)] missing = [package for package in packages if package not in already_present] if not missing: return PackageOperation(requested=packages, installed=[], already_present=already_present) if manager == "apt-get": command = ["apt-get", "install", "-y", *missing] elif manager in {"dnf", "yum"}: command = [manager, "install", "-y", *missing] elif manager == "pacman": command = ["pacman", "-S", "--noconfirm", *missing] else: raise SecureCheckError("Installation de paquets non supportée") self.run(command, requires_root=True) return PackageOperation(requested=packages, installed=missing, already_present=already_present) def upgrade_system(self) -> None: manager = self.context.system.package_manager if manager == "apt-get": self.run(["apt-get", "dist-upgrade", "-y"], requires_root=True) self.run(["apt-get", "autoremove", "-y"], requires_root=True) self.run(["apt-get", "autoclean"], requires_root=True) elif manager in {"dnf", "yum"}: self.run([manager, "upgrade", "-y", "--refresh"], requires_root=True) elif manager == "pacman": self.run(["pacman", "-Syu", "--noconfirm"], requires_root=True) else: raise SecureCheckError("Mise à jour système non supportée") def ensure_directory(self, path: Path, mode: int = 0o755, *, requires_root: bool = False) -> None: if self.context.dry_run: self.context.info(f"[dry-run] mkdir -p {path}") return if requires_root and os.geteuid() != 0: self.run(["install", "-d", "-m", f"{mode:o}", str(path)], requires_root=True) return path.mkdir(parents=True, exist_ok=True) path.chmod(mode) if requires_root and os.geteuid() == 0: os.chown(path, 0, 0) def write_text_file( self, path: Path, content: str, *, mode: int = 0o644, requires_root: bool = False, owner_uid: int | None = None, owner_gid: int | None = None, ) -> bool: try: current = path.read_text(encoding="utf-8") if path.exists() else None except OSError: current = None if current == content: return False self.context.logger.info("Ecriture du fichier %s", path) if self.context.dry_run: self.context.info(f"[dry-run] write {path}") return True if requires_root and os.geteuid() != 0: self._write_text_file_as_root(path, content, mode=mode, owner_uid=owner_uid, owner_gid=owner_gid) return True path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") os.chmod(path, mode) if requires_root and os.geteuid() == 0: os.chown(path, 0, 0) elif owner_uid is not None and owner_gid is not None and os.geteuid() == 0: os.chown(path, owner_uid, owner_gid) return True def _write_text_file_as_root( self, path: Path, content: str, *, mode: int, owner_uid: int | None, owner_gid: int | None, ) -> None: self.ensure_directory(path.parent, requires_root=True) tmp_dir = self.context.paths.state_dir tmp_dir.mkdir(parents=True, exist_ok=True) tmp_path: Path | None = None try: with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=tmp_dir) as handle: handle.write(content) tmp_path = Path(handle.name) self.run(["install", "-m", f"{mode:o}", str(tmp_path), str(path)], requires_root=True) if owner_uid is not None and owner_gid is not None: self.run(["chown", f"{owner_uid}:{owner_gid}", str(path)], requires_root=True) finally: if tmp_path and tmp_path.exists(): tmp_path.unlink(missing_ok=True) def ensure_user_shell(self, shell_path: str) -> bool: passwd_file = Path("/etc/passwd").read_text(encoding="utf-8") for line in passwd_file.splitlines(): if line.startswith(f"{self.context.system.target_user}:"): current_shell = line.rsplit(":", 1)[-1] if current_shell == shell_path: return False break self.run(["chsh", "-s", shell_path, self.context.system.target_user], requires_root=os.geteuid() == 0) return True def enable_service(self, service: str) -> None: self.run(["systemctl", "enable", "--now", service], requires_root=True) def restart_service(self, service: str) -> None: self.run(["systemctl", "restart", service], requires_root=True) def service_is_active(self, service: str) -> bool: result = self.run(["systemctl", "is-active", service], requires_root=True, check=False) return result.returncode == 0 def read_memory_mb(self) -> int: for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines(): if line.startswith("MemTotal:"): parts = line.split() return int(parts[1]) // 1024 return 1024 def write_json_file(self, path: Path, payload: dict, *, mode: int = 0o644, requires_root: bool = False) -> bool: content = json.dumps(payload, indent=2) + "\n" return self.write_text_file(path, content, mode=mode, requires_root=requires_root) def ensure_executable(self, path: Path, *, requires_root: bool = False) -> None: if self.context.dry_run: return current = stat.S_IMODE(path.stat().st_mode) path.chmod(current | 0o111) if requires_root and os.geteuid() == 0: os.chown(path, 0, 0) def download_text(self, url: str, *, timeout: int = 20) -> str: self.context.logger.info("Téléchargement: %s", url) if self.context.dry_run: self.context.info(f"[dry-run] download {url}") return "" with urllib.request.urlopen(url, timeout=timeout) as response: return response.read().decode("utf-8") def execute_tasks(context: ExecutionContext, tasks: list[TaskDefinition]) -> list[TaskResult]: results: list[TaskResult] = [] total = len(tasks) for index, task in enumerate(tasks, start=1): started_at = datetime.now() context.info(f"[{index}/{total}] {task.label}") try: result = task.handler(context) results.append(result) status = "OK" if result.success else "ECHEC" context.info(f" -> {status} ({result.duration_seconds:.1f}s)") except Exception as exc: # noqa: BLE001 context.logger.exception("Task failed: %s", task.key) results.append( context.make_result( task, success=False, changed=False, started_at=started_at, details=[], error=str(exc), ) ) context.error(f" -> ECHEC: {exc}") return results