diff --git a/pkgmgr/actions/repository/install/installers/nix_flake.py b/pkgmgr/actions/repository/install/installers/nix_flake.py index 63a9819..038fe3b 100644 --- a/pkgmgr/actions/repository/install/installers/nix_flake.py +++ b/pkgmgr/actions/repository/install/installers/nix_flake.py @@ -13,6 +13,13 @@ Behavior: * Then install the flake outputs (`pkgmgr`, `default`) via `nix profile install`. - Failure installing `pkgmgr` is treated as fatal. - Failure installing `default` is logged as an error/warning but does not abort. + +Special handling for dev shells / CI: + - If IN_NIX_SHELL is set (e.g. inside `nix develop`), the installer is + disabled. In that environment the flake outputs are already provided + by the dev shell and we must not touch the user profile. + - If PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 is set, the installer is + globally disabled (useful for CI or debugging). """ import os @@ -36,14 +43,45 @@ class NixFlakeInstaller(BaseInstaller): FLAKE_FILE = "flake.nix" PROFILE_NAME = "package-manager" + def _in_nix_shell(self) -> bool: + """ + Return True if we appear to be running inside a Nix dev shell. + + Nix sets IN_NIX_SHELL in `nix develop` environments. In that case + the flake outputs are already available, and touching the user + profile (nix profile install/remove) is undesirable. + """ + return bool(os.environ.get("IN_NIX_SHELL")) + def supports(self, ctx: "RepoContext") -> bool: """ Only support repositories that: - - Have a flake.nix + - Are NOT inside a Nix dev shell (IN_NIX_SHELL unset), + - Are NOT explicitly disabled via PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1, + - Have a flake.nix, - And have the `nix` command available. """ + # 1) Skip when running inside a dev shell – flake is already active. + if self._in_nix_shell(): + print( + "[INFO] IN_NIX_SHELL detected; skipping NixFlakeInstaller. " + "Flake outputs are provided by the development shell." + ) + return False + + # 2) Optional global kill-switch for CI or debugging. + if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1": + print( + "[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 – " + "NixFlakeInstaller is disabled." + ) + return False + + # 3) Nix must be available. if shutil.which("nix") is None: return False + + # 4) flake.nix must exist in the repository. flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE) return os.path.exists(flake_path) @@ -76,6 +114,14 @@ class NixFlakeInstaller(BaseInstaller): Any failure installing `pkgmgr` is treated as fatal (SystemExit). A failure installing `default` is logged but does not abort. """ + # Extra guard in case run() is called directly without supports(). + if self._in_nix_shell(): + print( + "[INFO] IN_NIX_SHELL detected in run(); " + "skipping Nix flake profile installation." + ) + return + # Reuse supports() to keep logic in one place if not self.supports(ctx): # type: ignore[arg-type] return @@ -91,7 +137,12 @@ class NixFlakeInstaller(BaseInstaller): try: # For 'default' we don't want the process to exit on error allow_failure = output == "default" - run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview, allow_failure=allow_failure) + run_command( + cmd, + cwd=ctx.repo_dir, + preview=ctx.preview, + allow_failure=allow_failure, + ) print(f"Nix flake output '{output}' successfully installed.") except SystemExit as e: print(f"[Error] Failed to install Nix flake output '{output}': {e}") diff --git a/pkgmgr/actions/repository/install/installers/os_packages/debian_control.py b/pkgmgr/actions/repository/install/installers/os_packages/debian_control.py index 49d4e03..5a9c521 100644 --- a/pkgmgr/actions/repository/install/installers/os_packages/debian_control.py +++ b/pkgmgr/actions/repository/install/installers/os_packages/debian_control.py @@ -17,7 +17,6 @@ apt/dpkg tooling are available. import glob import os import shutil - from typing import List from pkgmgr.actions.repository.install.context import RepoContext @@ -68,6 +67,32 @@ class DebianControlInstaller(BaseInstaller): pattern = os.path.join(parent, "*.deb") return sorted(glob.glob(pattern)) + def _privileged_prefix(self) -> str | None: + """ + Determine how to run privileged commands: + + - If 'sudo' is available, return 'sudo '. + - If we are running as root (e.g. inside CI/container), return ''. + - Otherwise, return None, meaning we cannot safely elevate. + + Callers are responsible for handling the None case (usually by + warning and skipping automatic installation). + """ + sudo_path = shutil.which("sudo") + + is_root = False + try: + is_root = os.geteuid() == 0 + except AttributeError: # pragma: no cover - non-POSIX platforms + # On non-POSIX systems, fall back to assuming "not root". + is_root = False + + if sudo_path is not None: + return "sudo " + if is_root: + return "" + return None + def _install_build_dependencies(self, ctx: RepoContext) -> None: """ Install build dependencies using `apt-get build-dep ./`. @@ -86,12 +111,25 @@ class DebianControlInstaller(BaseInstaller): ) return + prefix = self._privileged_prefix() + if prefix is None: + print( + "[Warning] Neither 'sudo' is available nor running as root. " + "Skipping automatic build-dep installation for Debian. " + "Please install build dependencies from debian/control manually." + ) + return + # Update package lists first for reliable build-dep resolution. - run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview) + run_command( + f"{prefix}apt-get update", + cwd=ctx.repo_dir, + preview=ctx.preview, + ) # Install build dependencies based on debian/control in the current tree. # `apt-get build-dep ./` uses the source in the current directory. - builddep_cmd = "sudo apt-get build-dep -y ./" + builddep_cmd = f"{prefix}apt-get build-dep -y ./" run_command(builddep_cmd, cwd=ctx.repo_dir, preview=ctx.preview) def run(self, ctx: RepoContext) -> None: @@ -101,7 +139,7 @@ class DebianControlInstaller(BaseInstaller): Steps: 1. apt-get build-dep ./ (automatic build dependency installation) 2. dpkg-buildpackage -b -us -uc - 3. sudo dpkg -i ../*.deb + 3. sudo dpkg -i ../*.deb (or plain dpkg -i when running as root) """ control_path = self._control_path(ctx) if not os.path.exists(control_path): @@ -123,7 +161,17 @@ class DebianControlInstaller(BaseInstaller): ) return + prefix = self._privileged_prefix() + if prefix is None: + print( + "[Warning] Neither 'sudo' is available nor running as root. " + "Skipping automatic .deb installation. " + "You can manually install the following files with dpkg -i:\n " + + "\n ".join(debs) + ) + return + # 4) Install .deb files - install_cmd = "sudo dpkg -i " + " ".join(os.path.basename(d) for d in debs) + install_cmd = prefix + "dpkg -i " + " ".join(os.path.basename(d) for d in debs) parent = os.path.dirname(ctx.repo_dir) run_command(install_cmd, cwd=parent, preview=ctx.preview) diff --git a/pkgmgr/actions/repository/install/installers/os_packages/rpm_spec.py b/pkgmgr/actions/repository/install/installers/os_packages/rpm_spec.py index b612476..9b09f29 100644 --- a/pkgmgr/actions/repository/install/installers/os_packages/rpm_spec.py +++ b/pkgmgr/actions/repository/install/installers/os_packages/rpm_spec.py @@ -7,8 +7,10 @@ Installer for RPM-based packages defined in *.spec files. This installer: 1. Installs build dependencies via dnf/yum builddep (where available) - 2. Uses rpmbuild to build RPMs from the provided .spec file - 3. Installs the resulting RPMs via `rpm -i` + 2. Prepares a source tarball in ~/rpmbuild/SOURCES based on the .spec + 3. Uses rpmbuild to build RPMs from the provided .spec file + 4. Installs the resulting RPMs via the system package manager (dnf/yum) + or rpm as a fallback. It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.). """ @@ -16,8 +18,8 @@ It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.). import glob import os import shutil - -from typing import List, Optional +import tarfile +from typing import List, Optional, Tuple from pkgmgr.actions.repository.install.context import RepoContext from pkgmgr.actions.repository.install.installers.base import BaseInstaller @@ -59,6 +61,117 @@ class RpmSpecInstaller(BaseInstaller): return None return matches[0] + # ------------------------------------------------------------------ + # Helpers for preparing rpmbuild topdir and source tarball + # ------------------------------------------------------------------ + def _rpmbuild_topdir(self) -> str: + """ + Return the rpmbuild topdir that rpmbuild will use by default. + + By default this is: ~/rpmbuild + + In the self-install tests, $HOME is set to /tmp/pkgmgr-self-install, + so this becomes /tmp/pkgmgr-self-install/rpmbuild which matches the + paths in the RPM build logs. + """ + home = os.path.expanduser("~") + return os.path.join(home, "rpmbuild") + + def _ensure_rpmbuild_tree(self, topdir: str) -> None: + """ + Ensure the standard rpmbuild directory tree exists: + + / + BUILD/ + BUILDROOT/ + RPMS/ + SOURCES/ + SPECS/ + SRPMS/ + """ + for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): + os.makedirs(os.path.join(topdir, sub), exist_ok=True) + + def _parse_name_version(self, spec_path: str) -> Optional[Tuple[str, str]]: + """ + Parse Name and Version from the given .spec file. + + Returns (name, version) or None if either cannot be determined. + """ + name = None + version = None + + with open(spec_path, "r", encoding="utf-8") as f: + for raw_line in f: + line = raw_line.strip() + # Ignore comments + if not line or line.startswith("#"): + continue + + lower = line.lower() + if lower.startswith("name:"): + # e.g. "Name: package-manager" + parts = line.split(":", 1) + if len(parts) == 2: + name = parts[1].strip() + elif lower.startswith("version:"): + # e.g. "Version: 0.7.7" + parts = line.split(":", 1) + if len(parts) == 2: + version = parts[1].strip() + + if name and version: + break + + if not name or not version: + print( + "[Warning] Could not determine Name/Version from spec file " + f"'{spec_path}'. Skipping RPM source tarball preparation." + ) + return None + + return name, version + + def _prepare_source_tarball(self, ctx: RepoContext, spec_path: str) -> None: + """ + Prepare a source tarball in /rpmbuild/SOURCES that matches + the Name/Version in the .spec file. + """ + parsed = self._parse_name_version(spec_path) + if parsed is None: + return + + name, version = parsed + topdir = self._rpmbuild_topdir() + self._ensure_rpmbuild_tree(topdir) + + build_dir = os.path.join(topdir, "BUILD") + sources_dir = os.path.join(topdir, "SOURCES") + + source_root = os.path.join(build_dir, f"{name}-{version}") + tarball_path = os.path.join(sources_dir, f"{name}-{version}.tar.gz") + + # Clean any previous build directory for this name/version. + if os.path.exists(source_root): + shutil.rmtree(source_root) + + # Copy the repository tree into BUILD/-. + shutil.copytree(ctx.repo_dir, source_root) + + # Create the tarball with the top-level directory -. + if os.path.exists(tarball_path): + os.remove(tarball_path) + + with tarfile.open(tarball_path, "w:gz") as tar: + tar.add(source_root, arcname=f"{name}-{version}") + + print( + f"[INFO] Prepared RPM source tarball at '{tarball_path}' " + f"from '{ctx.repo_dir}'." + ) + + # ------------------------------------------------------------------ + def supports(self, ctx: RepoContext) -> bool: """ This installer is supported if: @@ -77,26 +190,13 @@ class RpmSpecInstaller(BaseInstaller): By default, rpmbuild outputs RPMs into: ~/rpmbuild/RPMS/*/*.rpm """ - home = os.path.expanduser("~") - pattern = os.path.join(home, "rpmbuild", "RPMS", "**", "*.rpm") + topdir = self._rpmbuild_topdir() + pattern = os.path.join(topdir, "RPMS", "**", "*.rpm") return sorted(glob.glob(pattern, recursive=True)) def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None: """ Install build dependencies for the given .spec file. - - Strategy (best-effort): - - 1. If dnf is available: - sudo dnf builddep -y - 2. Else if yum-builddep is available: - sudo yum-builddep -y - 3. Else if yum is available: - sudo yum-builddep -y # Some systems provide it via yum plugin - 4. Otherwise: print a warning and skip automatic builddep install. - - Any failure in builddep installation is treated as fatal (SystemExit), - consistent with other installer steps. """ spec_basename = os.path.basename(spec_path) @@ -105,7 +205,6 @@ class RpmSpecInstaller(BaseInstaller): elif shutil.which("yum-builddep") is not None: cmd = f"sudo yum-builddep -y {spec_basename}" elif shutil.which("yum") is not None: - # Some distributions ship yum-builddep as a plugin. cmd = f"sudo yum-builddep -y {spec_basename}" else: print( @@ -114,33 +213,17 @@ class RpmSpecInstaller(BaseInstaller): ) return - # Run builddep in the repository directory so relative spec paths work. run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) - def run(self, ctx: RepoContext) -> None: + def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None: """ - Build and install RPM-based packages. + Install or upgrade the built RPMs. - Steps: - 1. dnf/yum builddep (automatic build dependency installation) - 2. rpmbuild -ba path/to/spec - 3. sudo rpm -i ~/rpmbuild/RPMS/*/*.rpm + Strategy: + - Prefer dnf install -y (handles upgrades cleanly) + - Else yum install -y + - Else fallback to rpm -Uvh (upgrade/replace existing) """ - spec_path = self._spec_path(ctx) - if not spec_path: - return - - # 1) Install build dependencies - self._install_build_dependencies(ctx, spec_path) - - # 2) Build RPMs - # Use the full spec path, but run in the repo directory. - spec_basename = os.path.basename(spec_path) - build_cmd = f"rpmbuild -ba {spec_basename}" - run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview) - - # 3) Find built RPMs - rpms = self._find_built_rpms() if not rpms: print( "[Warning] No RPM files found after rpmbuild. " @@ -148,13 +231,52 @@ class RpmSpecInstaller(BaseInstaller): ) return - # 4) Install RPMs - if shutil.which("rpm") is None: + dnf = shutil.which("dnf") + yum = shutil.which("yum") + rpm = shutil.which("rpm") + + if dnf is not None: + install_cmd = "sudo dnf install -y " + " ".join(rpms) + elif yum is not None: + install_cmd = "sudo yum install -y " + " ".join(rpms) + elif rpm is not None: + # Fallback: use rpm in upgrade mode so an existing older + # version is replaced instead of causing file conflicts. + install_cmd = "sudo rpm -Uvh " + " ".join(rpms) + else: print( - "[Warning] rpm binary not found on PATH. " + "[Warning] No suitable RPM installer (dnf/yum/rpm) found. " "Cannot install built RPMs." ) return - install_cmd = "sudo rpm -i " + " ".join(rpms) run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview) + + def run(self, ctx: RepoContext) -> None: + """ + Build and install RPM-based packages. + + Steps: + 1. Prepare source tarball in ~/rpmbuild/SOURCES matching Name/Version + 2. dnf/yum builddep (automatic build dependency installation) + 3. rpmbuild -ba path/to/spec + 4. Install built RPMs via dnf/yum (or rpm as fallback) + """ + spec_path = self._spec_path(ctx) + if not spec_path: + return + + # 1) Prepare source tarball so rpmbuild finds Source0 in SOURCES. + self._prepare_source_tarball(ctx, spec_path) + + # 2) Install build dependencies + self._install_build_dependencies(ctx, spec_path) + + # 3) Build RPMs + spec_basename = os.path.basename(spec_path) + build_cmd = f"rpmbuild -ba {spec_basename}" + run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview) + + # 4) Find and install built RPMs + rpms = self._find_built_rpms() + self._install_built_rpms(ctx, rpms) diff --git a/pkgmgr/actions/repository/install/installers/python.py b/pkgmgr/actions/repository/install/installers/python.py index acb47fd..99ef7d0 100644 --- a/pkgmgr/actions/repository/install/installers/python.py +++ b/pkgmgr/actions/repository/install/installers/python.py @@ -11,15 +11,28 @@ Strategy: 3. "pip" from PATH as last resort - If pyproject.toml exists: pip install . -All installation failures are treated as fatal errors (SystemExit). +All installation failures are treated as fatal errors (SystemExit), +except when we explicitly skip the installer: + + - If IN_NIX_SHELL is set, we assume Python is managed by Nix and + skip this installer entirely. + - If PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set, the installer is + globally disabled (useful for CI or debugging). """ +from __future__ import annotations + import os import sys +from typing import TYPE_CHECKING from pkgmgr.actions.repository.install.installers.base import BaseInstaller from pkgmgr.core.command.run import run_command +if TYPE_CHECKING: + from pkgmgr.actions.repository.install.context import RepoContext + from pkgmgr.actions.repository.install import InstallContext + class PythonInstaller(BaseInstaller): """Install Python projects and dependencies via pip.""" @@ -27,19 +40,54 @@ class PythonInstaller(BaseInstaller): # Logical layer name, used by capability matchers. layer = "python" - def supports(self, ctx) -> bool: + def _in_nix_shell(self) -> bool: + """ + Return True if we appear to be running inside a Nix dev shell. + + Nix sets IN_NIX_SHELL in `nix develop` environments. In that case + the Python environment is already provided by Nix, so we must not + attempt an additional pip-based installation. + """ + return bool(os.environ.get("IN_NIX_SHELL")) + + def supports(self, ctx: "RepoContext") -> bool: """ Return True if this installer should handle the given repository. Only pyproject.toml is supported as the single source of truth for Python dependencies and packaging metadata. + + The installer is *disabled* when: + - IN_NIX_SHELL is set (Python managed by Nix dev shell), or + - PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set. """ + # 1) Skip in Nix dev shells – Python is managed by the flake/devShell. + if self._in_nix_shell(): + print( + "[INFO] IN_NIX_SHELL detected; skipping PythonInstaller. " + "Python runtime is provided by the Nix dev shell." + ) + return False + + # 2) Optional global kill-switch. + if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1": + print( + "[INFO] PKGMGR_DISABLE_PYTHON_INSTALLER=1 – " + "PythonInstaller is disabled." + ) + return False + repo_dir = ctx.repo_dir return os.path.exists(os.path.join(repo_dir, "pyproject.toml")) def _pip_cmd(self) -> str: """ Resolve the pip command to use. + + Order: + 1) PKGMGR_PIP (explicit override) + 2) sys.executable -m pip + 3) plain "pip" """ explicit = os.environ.get("PKGMGR_PIP", "").strip() if explicit: @@ -50,12 +98,23 @@ class PythonInstaller(BaseInstaller): return "pip" - def run(self, ctx) -> None: + def run(self, ctx: "InstallContext") -> None: """ Install Python project defined via pyproject.toml. Any pip failure is propagated as SystemExit. """ + # Extra guard in case run() is called directly without supports(). + if self._in_nix_shell(): + print( + "[INFO] IN_NIX_SHELL detected in PythonInstaller.run(); " + "skipping pip-based installation." + ) + return + + if not self.supports(ctx): # type: ignore[arg-type] + return + pip_cmd = self._pip_cmd() pyproject = os.path.join(ctx.repo_dir, "pyproject.toml") diff --git a/scripts/docker/entry.sh b/scripts/docker/entry.sh index b419b93..5517156 100755 --- a/scripts/docker/entry.sh +++ b/scripts/docker/entry.sh @@ -2,28 +2,59 @@ set -euo pipefail # --------------------------------------------------------------------------- -# Ensure Nix has access to a valid CA bundle (TLS trust store) +# Detect and export a valid CA bundle so Nix, Git, curl and Python tooling +# can successfully perform HTTPS requests on all distros (Debian, Ubuntu, +# Fedora, RHEL, CentOS, etc.) # --------------------------------------------------------------------------- -if [[ -z "${NIX_SSL_CERT_FILE:-}" ]]; then - if [[ -f /etc/ssl/certs/ca-certificates.crt ]]; then - # Debian/Ubuntu-style path - export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt - echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}" - elif [[ -f /etc/pki/tls/certs/ca-bundle.crt ]]; then - # Fedora/RHEL/CentOS-style path - export NIX_SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt - echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}" - else - echo "[docker] WARNING: No CA bundle found for Nix (NIX_SSL_CERT_FILE not set)." - echo "[docker] HTTPS access for Nix flakes may fail." - fi +detect_ca_bundle() { + # Common CA bundle locations across major Linux distributions + local candidates=( + /etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu + /etc/ssl/cert.pem # Some distros + /etc/pki/tls/certs/ca-bundle.crt # Fedora/RHEL/CentOS + /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem # CentOS/RHEL extracted bundle + /etc/ssl/ca-bundle.pem # Generic fallback + ) + + for path in "${candidates[@]}"; do + if [[ -f "$path" ]]; then + echo "$path" + return 0 + fi + done + + return 1 +} + +# Use existing NIX_SSL_CERT_FILE if provided, otherwise auto-detect +CA_BUNDLE="${NIX_SSL_CERT_FILE:-}" + +if [[ -z "${CA_BUNDLE}" ]]; then + CA_BUNDLE="$(detect_ca_bundle || true)" +fi + +if [[ -n "${CA_BUNDLE}" ]]; then + # Export for Nix (critical) + export NIX_SSL_CERT_FILE="${CA_BUNDLE}" + + # Export for Git, Python requests, curl, etc. + export SSL_CERT_FILE="${CA_BUNDLE}" + export REQUESTS_CA_BUNDLE="${CA_BUNDLE}" + export GIT_SSL_CAINFO="${CA_BUNDLE}" + + echo "[docker] Using CA bundle: ${CA_BUNDLE}" +else + echo "[docker] WARNING: No CA certificate bundle found." + echo "[docker] HTTPS access for Nix flakes and other tools may fail." fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "[docker] Starting package-manager container" -# Distro info for logging +# --------------------------------------------------------------------------- +# Log distribution info +# --------------------------------------------------------------------------- if [[ -f /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release @@ -34,9 +65,9 @@ fi echo "[docker] Using /src as working directory" cd /src -# ------------------------------------------------------------ -# DEV mode: build/install package-manager from current /src -# ------------------------------------------------------------ +# --------------------------------------------------------------------------- +# DEV mode: rebuild package-manager from the mounted /src tree +# --------------------------------------------------------------------------- if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then echo "[docker] DEV mode enabled (PKGMGR_DEV=1)" echo "[docker] Rebuilding package-manager from /src via scripts/installation/run-package.sh..." @@ -49,9 +80,9 @@ if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then fi fi -# ------------------------------------------------------------ -# Hand-off to pkgmgr / arbitrary command -# ------------------------------------------------------------ +# --------------------------------------------------------------------------- +# Hand off to pkgmgr or arbitrary command +# --------------------------------------------------------------------------- if [[ $# -eq 0 ]]; then echo "[docker] No arguments provided. Showing pkgmgr help..." exec pkgmgr --help