From f9424f2f866c8e108796a106755e37e9a318aa7a Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Thu, 18 Apr 2024 14:54:49 +0200 Subject: [PATCH 1/8] docs: Improve clarity of README.md --- README.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9ce1f3e..c1ff8fb 100644 --- a/README.md +++ b/README.md @@ -88,26 +88,36 @@ See [`topgrade`](https://github.com/topgrade-rs/topgrade)'s GitHub for configuri ## Location -### Valid config paths (in order of priority): +### Valid config paths (in order of priority from highest to lowest): -```/etc/ublue-update/ublue-update.toml``` +1. ```/etc/ublue-update/ublue-update.toml``` -```/usr/etc/ublue-update/ublue-update.toml``` +2. ```/usr/etc/ublue-update/ublue-update.toml``` ## Config Variables -Section: `checks` +### Section: `checks` -`min_battery_percent`: checks if battery is above specified percent +* `min_battery_percent`: checks if battery is above specified percent -`max_cpu_load_percent`: checks if cpu average load is under specified percent +* `max_cpu_load_percent`: checks if cpu average load is under specified percent -`max_mem_percent`: checks if memory usage is below specified the percent +* `max_mem_percent`: checks if memory usage is below specified percent +### Section: `notify` -Section: `notify` +* `dbus_notify`: enable graphical notifications via dbus -`dbus_notify`: enable graphical notifications via dbus +### Full Example + +```toml +[checks] + min_battery_percent = 20.0 # Battery Level >= 20%? + max_cpu_load_percent = 50.0 # CPU Usage <= 50%? + max_mem_percent = 90.0 # RAM Usage <= 90%? +[notify] + dbus_notify = false # Do not show notifications +``` ## How do I build this? From c4db9bcb4f503fd11d3c66e20317b3cdf34d972c Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Thu, 18 Apr 2024 19:26:37 +0200 Subject: [PATCH 2/8] fix: Also accept lowercase log levels for UBLUE_LOG --- src/ublue_update/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ublue_update/cli.py b/src/ublue_update/cli.py index 19724b4..0bc20bd 100644 --- a/src/ublue_update/cli.py +++ b/src/ublue_update/cli.py @@ -182,7 +182,7 @@ def run_updates(system, system_update_available): # setup logging logging.basicConfig( format="[%(asctime)s] %(name)s:%(levelname)s | %(message)s", - level=os.getenv("UBLUE_LOG", default=logging.INFO), + level=os.getenv("UBLUE_LOG", default="INFO").upper(), ) log = logging.getLogger(__name__) From 06faa9b775000b64203d9db45e61b87c31fb5216 Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Thu, 18 Apr 2024 16:23:50 +0200 Subject: [PATCH 3/8] feat: Add a new 'network_is_metered' check --- README.md | 3 ++ files/usr/etc/ublue-update/ublue-update.toml | 1 + .../update_inhibitors/hardware.py | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/README.md b/README.md index c1ff8fb..3620f56 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ See [`topgrade`](https://github.com/topgrade-rs/topgrade)'s GitHub for configuri * `max_mem_percent`: checks if memory usage is below specified percent +* `network_not_metered`: if true, checks if the current network connection is not marked as metered + ### Section: `notify` * `dbus_notify`: enable graphical notifications via dbus @@ -115,6 +117,7 @@ See [`topgrade`](https://github.com/topgrade-rs/topgrade)'s GitHub for configuri min_battery_percent = 20.0 # Battery Level >= 20%? max_cpu_load_percent = 50.0 # CPU Usage <= 50%? max_mem_percent = 90.0 # RAM Usage <= 90%? + network_not_metered = true # Abort if network connection is metered [notify] dbus_notify = false # Do not show notifications ``` diff --git a/files/usr/etc/ublue-update/ublue-update.toml b/files/usr/etc/ublue-update/ublue-update.toml index f21b5b9..95dd735 100644 --- a/files/usr/etc/ublue-update/ublue-update.toml +++ b/files/usr/etc/ublue-update/ublue-update.toml @@ -2,5 +2,6 @@ min_battery_percent = 50.0 max_cpu_load_percent = 50.0 max_mem_percent = 90.0 + network_not_metered = false [notify] dbus_notify = true diff --git a/src/ublue_update/update_inhibitors/hardware.py b/src/ublue_update/update_inhibitors/hardware.py index 5774953..f2e3089 100644 --- a/src/ublue_update/update_inhibitors/hardware.py +++ b/src/ublue_update/update_inhibitors/hardware.py @@ -1,10 +1,12 @@ import psutil +import subprocess from logging import getLogger from ublue_update.config import load_value """Setup logging""" log = getLogger(__name__) +network_not_metered: bool = load_value("checks", "network_not_metered") min_battery_percent: float = load_value("checks", "min_battery_percent") max_cpu_load_percent: float = load_value("checks", "max_cpu_load_percent") max_mem_percent: float = load_value("checks", "max_mem_percent") @@ -22,6 +24,37 @@ def check_network_status() -> dict: return {"passed": network_up, "message": "Network not enabled"} +def check_network_not_metered() -> dict: + if not network_not_metered: + return {"passed": True, "message": "Network metering status is ignored"} + # Use busctl CLI to query the NetworkManager via D-Bus for + # the current metering status of the connection. + # The output on stdout will be " ". + metered_status = subprocess.run([ + "busctl", + "get-property", + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager", + "org.freedesktop.NetworkManager", + "Metered", + ], + capture_output=True, + check=True, + text=True, + ).stdout + # The possible values of "Metered" are documented here: + # https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMMetered + # + # NM_METERED_UNKNOWN = 0 # The metered status is unknown + # NM_METERED_YES = 1 # Metered, the value was explicitly configured + # NM_METERED_NO = 2 # Not metered, the value was explicitly configured + # NM_METERED_GUESS_YES = 3 # Metered, the value was guessed + # NM_METERED_GUESS_NO = 4 # Not metered, the value was guessed + # + is_network_metered = metered_status.strip() in ['u 1', 'u 3'] + return {"passed": not is_network_metered, "message": "Network is metered"} + + def check_battery_status() -> dict: battery_status = psutil.sensors_battery() # null safety on the battery variable, it returns "None" @@ -59,6 +92,7 @@ def check_hardware_inhibitors() -> bool: hardware_inhibitors = [ check_network_status(), + check_network_not_metered(), check_battery_status(), check_cpu_load(), check_mem_percentage(), From ba2c04d0bd7a3c2ea844922317c5b423989b9894 Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Thu, 18 Apr 2024 17:08:01 +0200 Subject: [PATCH 4/8] fix: Avoid crashing on incomplete fallback config --- src/ublue_update/config.py | 17 ++-- .../update_inhibitors/hardware.py | 85 ++++++++++++------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/src/ublue_update/config.py b/src/ublue_update/config.py index d9b4fec..b4e96ea 100644 --- a/src/ublue_update/config.py +++ b/src/ublue_update/config.py @@ -10,6 +10,7 @@ def load_config(): ] # search for the right config + # BUG: config_path and fallback_config_path always have the same value config_path = "" fallback_config_path = "" for path in config_paths: @@ -24,11 +25,17 @@ def load_config(): return config, fallback_config -def load_value(key, value): - fallback = fallback_config[key][value] - if key in config.keys(): - return config[key].get(value, fallback) - return fallback +def safe_get_nested(dct, *keys): + for key in keys: + try: + dct = dct[key] + except KeyError: + return None + return dct + + +def load_value(*keys): + return safe_get_nested(config, *keys) or safe_get_nested(fallback_config, *keys) config, fallback_config = load_config() diff --git a/src/ublue_update/update_inhibitors/hardware.py b/src/ublue_update/update_inhibitors/hardware.py index f2e3089..6820998 100644 --- a/src/ublue_update/update_inhibitors/hardware.py +++ b/src/ublue_update/update_inhibitors/hardware.py @@ -1,15 +1,16 @@ import psutil import subprocess +from typing import Optional from logging import getLogger from ublue_update.config import load_value """Setup logging""" log = getLogger(__name__) -network_not_metered: bool = load_value("checks", "network_not_metered") -min_battery_percent: float = load_value("checks", "min_battery_percent") -max_cpu_load_percent: float = load_value("checks", "max_cpu_load_percent") -max_mem_percent: float = load_value("checks", "max_mem_percent") +network_not_metered: Optional[bool] = load_value("checks", "network_not_metered") +min_battery_percent: Optional[float] = load_value("checks", "min_battery_percent") +max_cpu_load_percent: Optional[float] = load_value("checks", "max_cpu_load_percent") +max_mem_percent: Optional[float] = load_value("checks", "max_mem_percent") def check_network_status() -> dict: @@ -26,7 +27,10 @@ def check_network_status() -> dict: def check_network_not_metered() -> dict: if not network_not_metered: - return {"passed": True, "message": "Network metering status is ignored"} + return { + "passed": True, + "message": "Network metering status is ignored", + } # Use busctl CLI to query the NetworkManager via D-Bus for # the current metering status of the connection. # The output on stdout will be " ". @@ -52,40 +56,61 @@ def check_network_not_metered() -> dict: # NM_METERED_GUESS_NO = 4 # Not metered, the value was guessed # is_network_metered = metered_status.strip() in ['u 1', 'u 3'] - return {"passed": not is_network_metered, "message": "Network is metered"} + return { + "passed": not is_network_metered, + "message": "Network is metered", + } def check_battery_status() -> dict: - battery_status = psutil.sensors_battery() - # null safety on the battery variable, it returns "None" - # when the system doesn't have a battery - battery_pass: bool = True - if battery_status is not None: - battery_pass = ( - battery_status.percent >= min_battery_percent or battery_status.power_plugged - ) - return { - "passed": battery_pass, - "message": f"Battery less than {min_battery_percent}%", - } + if min_battery_percent: + battery_status = psutil.sensors_battery() + # null safety on the battery variable, it returns "None" + # when the system doesn't have a battery + battery_pass: bool = True + if battery_status is not None: + battery_pass = ( + battery_status.percent >= min_battery_percent or battery_status.power_plugged + ) + return { + "passed": battery_pass, + "message": f"Battery less than {min_battery_percent}%", + } + else: + return { + "passed": True, + "message": "Battery status is ignored", + } def check_cpu_load() -> dict: - # get load average percentage in last 5 minutes: - # https://psutil.readthedocs.io/en/latest/index.html?highlight=getloadavg - cpu_load_percent = psutil.getloadavg()[1] / psutil.cpu_count() * 100 - return { - "passed": cpu_load_percent < max_cpu_load_percent, - "message": f"CPU load is above {max_cpu_load_percent}%", - } + if max_cpu_load_percent: + # get load average percentage in last 5 minutes: + # https://psutil.readthedocs.io/en/latest/index.html?highlight=getloadavg + cpu_load_percent = psutil.getloadavg()[1] / psutil.cpu_count() * 100 + return { + "passed": cpu_load_percent < max_cpu_load_percent, + "message": f"CPU load is above {max_cpu_load_percent}%", + } + else: + return { + "passed": True, + "message": "CPU load is ignored", + } def check_mem_percentage() -> dict: - mem = psutil.virtual_memory() - return { - "passed": mem.percent < max_mem_percent, - "message": f"Memory usage is above {max_mem_percent}%", - } + if max_mem_percent: + mem = psutil.virtual_memory() + return { + "passed": mem.percent < max_mem_percent, + "message": f"Memory usage is above {max_mem_percent}%", + } + else: + return { + "passed": True, + "message": "Memory usage is ignored", + } def check_hardware_inhibitors() -> bool: From cea7eb8610be6add67c1f8963b0fc048aa2d8840 Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Thu, 18 Apr 2024 17:17:13 +0200 Subject: [PATCH 5/8] fix: Remove the broken fallback config logic --- src/ublue_update/config.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/ublue_update/config.py b/src/ublue_update/config.py index b4e96ea..07a17ab 100644 --- a/src/ublue_update/config.py +++ b/src/ublue_update/config.py @@ -10,19 +10,12 @@ def load_config(): ] # search for the right config - # BUG: config_path and fallback_config_path always have the same value - config_path = "" - fallback_config_path = "" + # first config file that is found wins for path in config_paths: if os.path.isfile(path): - if config_path == "": - config_path = path - fallback_config_path = path - break + return tomllib.load(open(path, "rb")) - fallback_config = tomllib.load(open(fallback_config_path, "rb")) - config = tomllib.load(open(config_path, "rb")) - return config, fallback_config + return config def safe_get_nested(dct, *keys): @@ -35,7 +28,7 @@ def safe_get_nested(dct, *keys): def load_value(*keys): - return safe_get_nested(config, *keys) or safe_get_nested(fallback_config, *keys) + return safe_get_nested(config, *keys) -config, fallback_config = load_config() +config = load_config() From 6e69105faa296b566d8a83b08bbda978bf06220f Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Thu, 18 Apr 2024 18:48:13 +0200 Subject: [PATCH 6/8] feat: Add --config argument for specifying a configuration file --- README.md | 1 + src/ublue_update/cli.py | 19 ++++++---- src/ublue_update/config.py | 36 +++++++++++++++---- .../update_inhibitors/hardware.py | 27 ++++++-------- 4 files changed, 53 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 3620f56..920e02e 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ options: -c, --check run update checks and exit -u, --updatecheck check for updates and exit -w, --wait wait for transactions to complete and exit + --config CONFIG use the specified config file --system only run system updates (requires root) ``` diff --git a/src/ublue_update/cli.py b/src/ublue_update/cli.py index 0bc20bd..27667b7 100644 --- a/src/ublue_update/cli.py +++ b/src/ublue_update/cli.py @@ -9,13 +9,13 @@ ) from ublue_update.update_checks.wait import transaction_wait from ublue_update.update_inhibitors.hardware import check_hardware_inhibitors -from ublue_update.config import load_value +from ublue_update.config import cfg from ublue_update.session import get_xdg_runtime_dir, get_active_sessions from ublue_update.filelock import acquire_lock, release_lock def notify(title: str, body: str, actions: list = [], urgency: str = "normal"): - if not dbus_notify: + if not cfg.dbus_notify: return process_uid = os.getuid() args = [ @@ -58,7 +58,7 @@ def notify(title: str, body: str, actions: list = [], urgency: str = "normal"): def ask_for_updates(system): - if not dbus_notify: + if not cfg.dbus_notify: return out = notify( "System Updater", @@ -159,7 +159,7 @@ def run_updates(system, system_update_available): ) log.debug(out.stdout.decode("utf-8")) log.info("System update complete") - if pending_deployment_check() and system_update_available and dbus_notify: + if pending_deployment_check() and system_update_available and cfg.dbus_notify: out = notify( "System Updater", "System update complete, pending changes will take effect after reboot. Reboot now?", @@ -177,8 +177,6 @@ def run_updates(system, system_update_available): os._exit(0) -dbus_notify: bool = load_value("notify", "dbus_notify") - # setup logging logging.basicConfig( format="[%(asctime)s] %(name)s:%(levelname)s | %(message)s", @@ -212,17 +210,24 @@ def main(): action="store_true", help="wait for transactions to complete and exit", ) + parser.add_argument( + "--config", + help="use the specified config file" + ) parser.add_argument( "--system", action="store_true", help="only run system updates (requires root)", ) cli_args = parser.parse_args() - hardware_checks_failed = False + + # Load the configuration file + cfg.load_config(cli_args.config) if cli_args.wait: transaction_wait() os._exit(0) + system_update_available: bool = system_update_check() if not cli_args.force and not cli_args.updatecheck: hardware_checks_failed, failures = check_hardware_inhibitors() diff --git a/src/ublue_update/config.py b/src/ublue_update/config.py index 07a17ab..8d7826d 100644 --- a/src/ublue_update/config.py +++ b/src/ublue_update/config.py @@ -1,8 +1,13 @@ import tomllib import os +from typing import List, Optional +from logging import getLogger +"""Setup logging""" +log = getLogger(__name__) -def load_config(): + +def find_default_config_file(): # load config values config_paths = [ "/etc/ublue-update/ublue-update.toml", @@ -13,12 +18,12 @@ def load_config(): # first config file that is found wins for path in config_paths: if os.path.isfile(path): - return tomllib.load(open(path, "rb")) + return path - return config + return None -def safe_get_nested(dct, *keys): +def load_value(dct, *keys): for key in keys: try: dct = dct[key] @@ -27,8 +32,25 @@ def safe_get_nested(dct, *keys): return dct -def load_value(*keys): - return safe_get_nested(config, *keys) +class Config: + dbus_notify: bool + network_not_metered: Optional[bool] + min_battery_percent: Optional[float] + max_cpu_load_percent: Optional[float] + max_mem_percent: Optional[float] + + def load_config(self, path=None): + config_path = path or find_default_config_file() + config = tomllib.load(open(config_path, "rb")) + log.debug(f"Configuration loaded from {os.path.abspath(config_path)}") + self.load_values(config) + + def load_values(self, config): + self.dbus_notify = load_value(config, "notify", "dbus_notify") or False + self.network_not_metered = load_value(config, "checks", "network_not_metered") + self.min_battery_percent = load_value(config, "checks", "min_battery_percent") + self.max_cpu_load_percent = load_value(config, "checks", "max_cpu_load_percent") + self.max_mem_percent = load_value(config, "checks", "max_mem_percent") -config = load_config() +cfg = Config() diff --git a/src/ublue_update/update_inhibitors/hardware.py b/src/ublue_update/update_inhibitors/hardware.py index 6820998..c3f77e1 100644 --- a/src/ublue_update/update_inhibitors/hardware.py +++ b/src/ublue_update/update_inhibitors/hardware.py @@ -2,16 +2,11 @@ import subprocess from typing import Optional from logging import getLogger -from ublue_update.config import load_value +from ublue_update.config import cfg """Setup logging""" log = getLogger(__name__) -network_not_metered: Optional[bool] = load_value("checks", "network_not_metered") -min_battery_percent: Optional[float] = load_value("checks", "min_battery_percent") -max_cpu_load_percent: Optional[float] = load_value("checks", "max_cpu_load_percent") -max_mem_percent: Optional[float] = load_value("checks", "max_mem_percent") - def check_network_status() -> dict: network_status = psutil.net_if_stats() @@ -26,7 +21,7 @@ def check_network_status() -> dict: def check_network_not_metered() -> dict: - if not network_not_metered: + if not cfg.network_not_metered: return { "passed": True, "message": "Network metering status is ignored", @@ -63,18 +58,18 @@ def check_network_not_metered() -> dict: def check_battery_status() -> dict: - if min_battery_percent: + if cfg.min_battery_percent: battery_status = psutil.sensors_battery() # null safety on the battery variable, it returns "None" # when the system doesn't have a battery battery_pass: bool = True if battery_status is not None: battery_pass = ( - battery_status.percent >= min_battery_percent or battery_status.power_plugged + battery_status.percent >= cfg.min_battery_percent or battery_status.power_plugged ) return { "passed": battery_pass, - "message": f"Battery less than {min_battery_percent}%", + "message": f"Battery less than {cfg.min_battery_percent}%", } else: return { @@ -84,13 +79,13 @@ def check_battery_status() -> dict: def check_cpu_load() -> dict: - if max_cpu_load_percent: + if cfg.max_cpu_load_percent: # get load average percentage in last 5 minutes: # https://psutil.readthedocs.io/en/latest/index.html?highlight=getloadavg cpu_load_percent = psutil.getloadavg()[1] / psutil.cpu_count() * 100 return { - "passed": cpu_load_percent < max_cpu_load_percent, - "message": f"CPU load is above {max_cpu_load_percent}%", + "passed": cpu_load_percent < cfg.max_cpu_load_percent, + "message": f"CPU load is above {cfg.max_cpu_load_percent}%", } else: return { @@ -100,11 +95,11 @@ def check_cpu_load() -> dict: def check_mem_percentage() -> dict: - if max_mem_percent: + if cfg.max_mem_percent: mem = psutil.virtual_memory() return { - "passed": mem.percent < max_mem_percent, - "message": f"Memory usage is above {max_mem_percent}%", + "passed": mem.percent < cfg.max_mem_percent, + "message": f"Memory usage is above {cfg.max_mem_percent}%", } else: return { From 2079eb51afdd10ef5e60c6f464b6d2368faff43c Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Thu, 18 Apr 2024 20:04:32 +0200 Subject: [PATCH 7/8] feat: Add script-based custom checks in addition to the predefined checks --- README.md | 48 ++++++++++++++ src/ublue_update/cli.py | 16 +++-- src/ublue_update/config.py | 2 + src/ublue_update/update_inhibitors/custom.py | 68 ++++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 src/ublue_update/update_inhibitors/custom.py diff --git a/README.md b/README.md index 920e02e..eb3f0e5 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,25 @@ See [`topgrade`](https://github.com/topgrade-rs/topgrade)'s GitHub for configuri * `network_not_metered`: if true, checks if the current network connection is not marked as metered +### Section: `checks.scripts` + +In addition to the predefined checks above, it is also possible to implement +custom conditions through user-provided scripts and their exit codes. +Each entry in the `checks.scripts` array must specify the following settings: + +* `shell`: specifies the shell used to execute the custom script (e.g. `bash`) + +* `run`: specifies the script text to be run using the specified shell + +* `message`: an optional message that is shown when the check fails + +* `name`: an optional human-readable name for this check + +The custom script should use its exit code to indicate whether the updater should proceed +(`exit code = 0`) or whether updates should be inhibited right now (any non-0 exit code). +If `message` is not specified but the script has written text to `stdout`, +that text will be used as the message. + ### Section: `notify` * `dbus_notify`: enable graphical notifications via dbus @@ -119,6 +138,35 @@ See [`topgrade`](https://github.com/topgrade-rs/topgrade)'s GitHub for configuri max_cpu_load_percent = 50.0 # CPU Usage <= 50%? max_mem_percent = 90.0 # RAM Usage <= 90%? network_not_metered = true # Abort if network connection is metered + + [[checks.scripts]] + name = "Example script that always fails" + shell = "bash" + run = "exit 1" + message = "Failure message - this message will always appear" + + [[checks.scripts]] + name = "Example script that always succeeds" + shell = "bash" + run = "exit 0" + message = "Failure message - this message will never appear" + + [[checks.scripts]] + name = "Example multiline script with custom message" + shell = "bash" + run = """ +echo "This is a custom message" +exit 1 +""" + + [[checks.scripts]] + name = "Python script" + shell = "python3" + run = """ +print("Python also works when installed") +exit(1) +""" + [notify] dbus_notify = false # Do not show notifications ``` diff --git a/src/ublue_update/cli.py b/src/ublue_update/cli.py index 27667b7..12af882 100644 --- a/src/ublue_update/cli.py +++ b/src/ublue_update/cli.py @@ -9,6 +9,7 @@ ) from ublue_update.update_checks.wait import transaction_wait from ublue_update.update_inhibitors.hardware import check_hardware_inhibitors +from ublue_update.update_inhibitors.custom import check_custom_inhibitors from ublue_update.config import cfg from ublue_update.session import get_xdg_runtime_dir, get_active_sessions from ublue_update.filelock import acquire_lock, release_lock @@ -73,12 +74,12 @@ def ask_for_updates(system): run_updates(system, True) -def hardware_inhibitor_checks_failed( +def inhibitor_checks_failed( failures: list, hardware_check: bool, system_update_available: bool, system: bool ): # ask if an update can be performed through dbus notifications if system_update_available and not hardware_check: - log.info("Harware checks failed, but update is available") + log.info("Precondition checks failed, but update is available") ask_for_updates(system) # notify systemd that the checks have failed, # systemd will try to rerun the unit @@ -230,9 +231,14 @@ def main(): system_update_available: bool = system_update_check() if not cli_args.force and not cli_args.updatecheck: - hardware_checks_failed, failures = check_hardware_inhibitors() - if hardware_checks_failed: - hardware_inhibitor_checks_failed( + hw_checks_failed, hw_failures = check_hardware_inhibitors() + cs_checks_failed, cs_failures = check_custom_inhibitors() + + checks_failed = hw_checks_failed or cs_checks_failed + failures = hw_failures + cs_failures + + if checks_failed: + inhibitor_checks_failed( failures, cli_args.check, system_update_available, diff --git a/src/ublue_update/config.py b/src/ublue_update/config.py index 8d7826d..7eb7354 100644 --- a/src/ublue_update/config.py +++ b/src/ublue_update/config.py @@ -38,6 +38,7 @@ class Config: min_battery_percent: Optional[float] max_cpu_load_percent: Optional[float] max_mem_percent: Optional[float] + custom_check_scripts: List[dict] def load_config(self, path=None): config_path = path or find_default_config_file() @@ -51,6 +52,7 @@ def load_values(self, config): self.min_battery_percent = load_value(config, "checks", "min_battery_percent") self.max_cpu_load_percent = load_value(config, "checks", "max_cpu_load_percent") self.max_mem_percent = load_value(config, "checks", "max_mem_percent") + self.custom_check_scripts = load_value(config, "checks", "scripts") or [] cfg = Config() diff --git a/src/ublue_update/update_inhibitors/custom.py b/src/ublue_update/update_inhibitors/custom.py new file mode 100644 index 0000000..4f1280c --- /dev/null +++ b/src/ublue_update/update_inhibitors/custom.py @@ -0,0 +1,68 @@ +import psutil +import subprocess +from typing import List, Optional +from logging import getLogger +from ublue_update.config import cfg + +"""Setup logging""" +log = getLogger(__name__) + + +def run_custom_check_script(script) -> dict: + if script.get('shell') != 'bash': + raise Exception('checks.scripts.*.shell must be set to \'bash\'') + + log.debug(f"Running script {script}") + + # Run the specified custom script via bash + script_result = subprocess.run( + ['bash', '-c', script['run']], + capture_output=True, + check=False, + ) + + # An exit code of 0 means "OK", a non-zero exit code + # means "Do not download or perform updates right now" + script_pass: bool = script_result.returncode == 0 + + # Use either the message specified in the config, + # the output of the script (if not empty), or a fallback + script_output: Optional[str] = script_result.stdout.strip() + if len(script_output) == 0: + script_output = None + + # Write error messages to our log in case of failure + # to catch any interpreter errors etc. + script_stderr = script_result.stderr.strip() + if not script_pass and len(script_stderr) > 0: + log.warning(f"A custom check script failed and wrote the following to STDERR:\n====\n{script_stderr}\n====") + + fallback_message = "A custom check script returned a non-0 exit code" + script_message = script.get('message') or script_output or fallback_message + + return { + "passed": script_pass, + "message": script_message, + } + + +def run_custom_check_scripts() -> List[dict]: + results = [] + for script in (cfg.custom_check_scripts or []): + results.append(run_custom_check_script(script)) + return results + + +def check_custom_inhibitors() -> bool: + + custom_inhibitors = run_custom_check_scripts() + + failures = [] + custom_checks_failed = False + for inhibitor_result in custom_inhibitors: + if not inhibitor_result["passed"]: + custom_checks_failed = True + failures.append(inhibitor_result["message"]) + if not custom_checks_failed: + log.info("System passed custom checks") + return custom_checks_failed, failures From 91e481d489d341ea01bdb8a30f31d276886373ec Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Thu, 18 Apr 2024 20:30:28 +0200 Subject: [PATCH 8/8] feat: Add the option to specify external script files in addition to inline scripts --- README.md | 8 ++++++++ src/ublue_update/update_inhibitors/custom.py | 21 ++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index eb3f0e5..13b6ee5 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ Each entry in the `checks.scripts` array must specify the following settings: * `name`: an optional human-readable name for this check +The parameters `run` and `file` are mutually exclusive, but at least one must be specified. +The `shell` parameter is mandatory when using `run`. + The custom script should use its exit code to indicate whether the updater should proceed (`exit code = 0`) or whether updates should be inhibited right now (any non-0 exit code). If `message` is not specified but the script has written text to `stdout`, @@ -167,6 +170,11 @@ print("Python also works when installed") exit(1) """ + [[checks.scripts]] + name = "Example external script" + # shell = "bash" # specifying a shell is optional for external scripts/programs + file = "/bin/true" + [notify] dbus_notify = false # Do not show notifications ``` diff --git a/src/ublue_update/update_inhibitors/custom.py b/src/ublue_update/update_inhibitors/custom.py index 4f1280c..cb83398 100644 --- a/src/ublue_update/update_inhibitors/custom.py +++ b/src/ublue_update/update_inhibitors/custom.py @@ -9,17 +9,22 @@ def run_custom_check_script(script) -> dict: - if script.get('shell') != 'bash': - raise Exception('checks.scripts.*.shell must be set to \'bash\'') + if 'run' in script and 'shell' not in script: + raise Exception('checks.scripts.*: \'shell\' must be specified when \'run\' is used') + + if 'run' in script and 'file' in script: + raise Exception('checks.scripts.*: Only one of \'run\' and \'file\' must be set for a given script') log.debug(f"Running script {script}") - # Run the specified custom script via bash - script_result = subprocess.run( - ['bash', '-c', script['run']], - capture_output=True, - check=False, - ) + # Run the specified custom script + if 'run' in script: + run_args = [script['shell'], '-c', script['run']] + elif 'shell' in script: + run_args = [script['shell'], script['file']] + else: + run_args = [script['file']] + script_result = subprocess.run(run_args, capture_output=True, text=True, check=False) # An exit code of 0 means "OK", a non-zero exit code # means "Do not download or perform updates right now"