diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 819e437..6af1a95 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.25.1 + rev: v1.28.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/cloud_storage/hooks.py b/cloud_storage/hooks.py index 674381f..e415f54 100644 --- a/cloud_storage/hooks.py +++ b/cloud_storage/hooks.py @@ -69,6 +69,9 @@ # before_install = "cloud_storage.install.before_install" after_install = "cloud_storage.install.after_install" +# Documented for operators; install hooks read DEBIAN_PACKAGES in install.py. +debian_packages = ["libmagic1", "libreoffice"] + # Uninstallation # ------------ diff --git a/cloud_storage/install.py b/cloud_storage/install.py index 5be7470..3be19fd 100644 --- a/cloud_storage/install.py +++ b/cloud_storage/install.py @@ -1,49 +1,10 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -import os -import subprocess -from getpass import getpass -from sys import platform +from cloud_storage.system_packages import ensure_debian_packages - -def is_root(): - return os.geteuid() == 0 - - -def test_sudo(): - args = "sudo -S echo OK".split() - kwargs = dict(stdout=subprocess.PIPE, encoding="ascii") - cmd = subprocess.run(args, **kwargs) - return "OK" in cmd.stdout - - -def install(module, pwd=""): - args = f"sudo -S apt-get -y install {module}".split() - kwargs = dict(stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="ascii") - if pwd: - kwargs.update(input=pwd) - cmd = subprocess.run(args, **kwargs) - return cmd.stdout, cmd.stderr +DEBIAN_PACKAGES = ["libmagic1", "libreoffice"] def after_install(): - modules = ["libmagic1", "libreoffice"] - if platform != "linux": - print(f"You need to manually install the following modules: {', '.join(modules)}.") - return - - has_sudo_permissions = is_root() or test_sudo() - pwd = "" - if not has_sudo_permissions: - pwd = getpass(f"Provide sudo password to install {', '.join(modules)}: ") - - for module in modules: - try: - out, err = install(module, pwd) - if err: - print(f"There was an error installing {module}: {err}.") - if out: - print(f"{module}: {out}") - except Exception as e: - print(f"There was an error installing {module}: {e}.") + ensure_debian_packages(DEBIAN_PACKAGES) diff --git a/cloud_storage/system_packages.py b/cloud_storage/system_packages.py new file mode 100644 index 0000000..06e344d --- /dev/null +++ b/cloud_storage/system_packages.py @@ -0,0 +1,78 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt + +"""Debian/Ubuntu system package helpers for Frappe install hooks. + +Used from before_install / after_install so ``bench --site … install-app`` +can satisfy OS dependencies without duplicating package lists in Ansible. +Set FRAPPE_INSTALL_NONINTERACTIVE=1 (or run without a TTY) for automation. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +ENV_NONINTERACTIVE = "FRAPPE_INSTALL_NONINTERACTIVE" + + +def is_noninteractive() -> bool: + return os.environ.get(ENV_NONINTERACTIVE) == "1" or not sys.stdin.isatty() + + +def can_sudo() -> bool: + if os.geteuid() == 0: + return True + return ( + subprocess.run( + ["sudo", "-n", "true"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + == 0 + ) + + +def dpkg_installed(package: str) -> bool: + return ( + subprocess.run( + ["dpkg", "-s", package], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + == 0 + ) + + +def ensure_debian_packages(packages: list[str], *, required: bool = True) -> None: + """Install missing Debian packages via apt when sudo is available.""" + if sys.platform != "linux": + message = f"Install system packages manually: {', '.join(packages)}" + if required: + raise RuntimeError(message) + print(message) + return + + missing = [pkg for pkg in packages if not dpkg_installed(pkg)] + if not missing: + return + + if not can_sudo(): + message = ( + f"Need passwordless sudo to install: {', '.join(missing)}. " + f"Run: sudo apt-get install -y {' '.join(missing)}" + ) + if required or is_noninteractive(): + raise RuntimeError(message) + print(message) + return + + env = os.environ.copy() + env["DEBIAN_FRONTEND"] = "noninteractive" + subprocess.run(["sudo", "apt-get", "update"], check=required, env=env) + subprocess.run( + ["sudo", "apt-get", "install", "-y", *missing], + check=required, + env=env, + )