diff options
Diffstat (limited to 'prometheus_pacman_exporter')
| -rw-r--r-- | prometheus_pacman_exporter/__init__.py | 224 | ||||
| -rw-r--r-- | prometheus_pacman_exporter/__main__.py | 7 | ||||
| -rw-r--r-- | prometheus_pacman_exporter/cli.py | 161 | ||||
| -rw-r--r-- | prometheus_pacman_exporter/metrics.py | 51 |
4 files changed, 443 insertions, 0 deletions
diff --git a/prometheus_pacman_exporter/__init__.py b/prometheus_pacman_exporter/__init__.py new file mode 100644 index 0000000..baf69dd --- /dev/null +++ b/prometheus_pacman_exporter/__init__.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +import os +import shlex +import subprocess +from pathlib import Path +from typing import cast + +import click_extra as click +from cloup.constraints import If, require_all +from prometheus_client import write_to_textfile + +from . import metrics +from .cli import FlexibleColorOption, debug, error, msg + +VERSION = "1.0.0" +DESCRIPTION = "Collect pacman package metrics and export them for Prometheus." +DATE_OF_CREATION = "2021-05-15" +DATE_OF_REVISION = "2026-05-08" +AUTHOR = "Dennis Fink <me+coding@dennisfink.me>" +LICENSE = "BSD-3-Clause" + + +def print_version( + ctx: click.Context, param: click.Parameter | None, value: bool +) -> None: + """Print script metadata (name, version, description, author, dates) and exit. + + :param ctx: Click context injected by :func:`click.pass_context`. + """ + if not value or ctx.resilient_parsing: + return + + click.echo(click.style("Scriptname:", fg="red", bold=True) + f" {ctx.info_name}") + click.echo(click.style("Version:", fg="green", bold=True) + f" {VERSION}") + click.echo(click.style("Description:", fg="yellow", bold=True) + f" {DESCRIPTION}") + click.echo(click.style("Author:", fg="blue", bold=True) + f" {AUTHOR}") + click.echo( + click.style("Date of creation:", fg="magenta", bold=True) + + f" {DATE_OF_CREATION}" + ) + click.echo( + click.style("Date of revision:", fg="cyan", bold=True) + f" {DATE_OF_REVISION}" + ) + click.echo(click.style("License:", fg="red", bold=True) + f" {LICENSE}") + + click.echo("""Copyright (c) 2026 Dennis Fink <me+coding@dennisfink.me>. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors +may be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.""") + + ctx.exit() + + +@click.pass_context +def run_command( + ctx: click.Context, command: list[str] +) -> subprocess.CompletedProcess[str]: + """Run a shell command and return the result. + + Logs the command at debug level before execution. Exits the application + with the command's return code if it fails. + + :param ctx: Click context injected by :func:`click.pass_context`. + :param command: Command and its arguments. + :return: Completed process with stdout and stderr captured. + """ + debug("Running command", " ".join(command), err=True) + try: + return subprocess.run( + command, capture_output=True, encoding="utf-8", check=True + ) + except subprocess.CalledProcessError as e: + error(f"Command ({' '.join(command)}) failed with exit code {e.returncode}") + if e.stderr: + error("stderr:", e.stderr.strip()) + ctx.exit(e.returncode) + + +@click.command( + context_settings={"help_option_names": ("-h", "--help", "-?")}, params=[] +) +@click.option( + "--textfile-collector-dir", + type=click.Path( + exists=True, + readable=True, + writable=True, + file_okay=False, + allow_dash=False, + path_type=Path, + ), + default="/var/lib/prometheus/node-exporter", + help="Directory where the Prometheus collector textfile will be written.", +) +@click.option( + "--use-checkupdates", "use_checkupdates_flag", is_flag=True, default=False +) +@click.option( + "--aur", "aur_flag", is_flag=True, default=False, help="Include AUR updates." +) +@click.option("--aur-command", help="Command used to check for AUR updates.") +@click.constraint( + If("aur_flag", then=require_all).hidden(), ["aur_flag", "aur_command"] +) +@click.option("--color", cls=FlexibleColorOption) +@click.no_color_option() +@click.option( + "-q", + "--quiet", + "quiet_flag", + is_flag=True, + default=False, + help="Suppress all non-error output.", +) +@click.option( + "-v", + "--verbose", + "verbose_flag", + is_flag=True, + default=False, + help="Enable verbose output.", +) +@click.option( + "--version", + callback=print_version, + default=False, + expose_value=False, + help="Show version information and exit.", + is_eager=True, + is_flag=True, +) +@click.pass_context +def prometheus_pacman_exporter( + ctx: click.Context, + textfile_collector_dir: Path, + use_checkupdates_flag: bool, + aur_flag: bool, + aur_command: str | None, + quiet_flag: bool, + verbose_flag: bool, +): + """Collect pacman package metrics and export them for Prometheus. + + Queries pacman for installed package statistics and available updates, then + writes them to a ``.prom`` file for consumption by the Prometheus node + exporter textfile collector. + + By default, updateable packages are determined using ``pacman --query + --upgrades``. Use ``--use-checkupdates`` to query updates with + ``checkupdates`` instead. AUR update metrics can optionally be collected by + providing ``--aur`` together with ``--aur-command``. + """ + + ctx.ensure_object(dict) + ctx.obj["debug"] = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes") + ctx.obj["quiet"] = quiet_flag + ctx.obj["verbose"] = verbose_flag + + debug("Effective configuration:", err=True) + debug(" textfile_collector_dir:", str(textfile_collector_dir), err=True) + debug(" use_checkupdates:", str(use_checkupdates_flag), err=True) + debug(" aur:", str(aur_flag), err=True) + debug(" aur_command:", str(aur_command), err=True) + debug(" verbose:", str(verbose_flag), err=True) + debug(" quiet:", str(quiet_flag), err=True) + + pacman = ["pacman", "--query"] + queries = [ + ([*pacman], metrics.INSTALLED_PACKAGES), + ([*pacman, "--explicit"], metrics.EXPLICIT_PACKAGES), + ([*pacman, "--deps"], metrics.DEPENDS_PACKAGES), + ([*pacman, "--unrequired"], metrics.UNREQUIRED_PACKAGES), + ([*pacman, "--foreign"], metrics.FOREIGN_PACKAGES), + ([*pacman, "--native"], metrics.NATIVE_PACKAGES), + ([*pacman, "--deps", "--unrequired"], metrics.ORPHAN_PACKAGES), + ( + ["checkupdates"] if use_checkupdates_flag else [*pacman, "--upgrades"], + metrics.UPDATEABLE_PACKAGES, + ), + ] + + if aur_flag: + queries.append( + (shlex.split(cast(str, aur_command)), metrics.AUR_UPDATEABLE_PACKAGES) + ) + + for command, metric in queries: + output = run_command(command) + metric.set(len(output.stdout.splitlines())) + + metrics_file = textfile_collector_dir / "pacman.prom" + write_to_textfile(str(metrics_file.absolute()), metrics.REGISTRY) + msg("Metrics written to:", str(metrics_file.absolute()), err=True) + + +if __name__ == "__main__": + prometheus_pacman_exporter() diff --git a/prometheus_pacman_exporter/__main__.py b/prometheus_pacman_exporter/__main__.py new file mode 100644 index 0000000..877708c --- /dev/null +++ b/prometheus_pacman_exporter/__main__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +from prometheus_pacman_exporter import prometheus_pacman_exporter + +prometheus_pacman_exporter() diff --git a/prometheus_pacman_exporter/cli.py b/prometheus_pacman_exporter/cli.py new file mode 100644 index 0000000..5d91b05 --- /dev/null +++ b/prometheus_pacman_exporter/cli.py @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Define Click parameter types and styled terminal message helpers.""" + +from collections.abc import Callable +from functools import update_wrapper +from typing import Any, Concatenate + +import click_extra as click + + +class FlexibleColorOption(click.ColorOption): + """Allow the color option value to be passed with or without ``=``.""" + + _gnu_optional_value = False + + +def pass_obj_silent[T, **P, R]( + f: Callable[Concatenate[T | None, P], R], +) -> Callable[P, R]: + """Pass the current context object to a callback, if available. + + The current Click context is retrieved silently. If no context exists or + its object is not a dictionary, ``None`` is passed instead. + + The context object is inserted as the first positional argument to *f*. + """ + + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: + ctx = click.get_current_context(silent=True) + obj = ctx.obj if ctx is not None else None + return f(obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + +def emit( + prefix: str, color: str, *message: str, enabled: bool = True, err: bool = False +) -> None: + """Render a styled prefix and message to stdout. + + This helper is used by :func:`error`, :func:`msg`, :func:`warn`, + :func:`verbose`, and :func:`debug`. The first message argument is rendered in + bold and subsequent arguments are appended unstyled. + + :param prefix: Prefix displayed before the message. + :param color: Click-compatible color name applied to the prefix. + :param message: One or more message parts to display. + :param enabled: Whether the message should be emitted. + :param err: Write to `stderr` instead of `stdout`. + :raises TypeError: If no message arguments are provided. + """ + if not message: + raise TypeError("emit() missing 1 required positional argument: 'message'") + + if not enabled: + return + + click.echo( + " ".join( + [ + click.style(prefix, fg=color, bold=True), + click.style(message[0], bold=True), + *message[1:], + ] + ), + err=err, + ) + + +def error(*message: str) -> None: + """Print a formatted error message to stdout in red. + + The first argument is rendered in bold and subsequent arguments are appended + unstyled. Error messages are always printed regardless of quiet or verbose + flags. + + :param message: One or more message parts to display. + """ + emit("==> ERROR:", "red", *message, err=True) + + +@pass_obj_silent +def msg(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted informational message to stdout in green. + + The message is suppressed when the quiet flag is set. When called outside a + Click context, the message is emitted normally. The first argument is rendered + in bold and subsequent arguments are appended unstyled. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "green", + *message, + enabled=obj is None or not obj.get("quiet", False), + err=err, + ) + + +@pass_obj_silent +def warn(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted warning message to stdout in yellow. + + The message is suppressed when the quiet flag is set. When called outside a + Click context, the message is emitted normally. The first argument is rendered + in bold and subsequent arguments are appended unstyled. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "yellow", + *message, + enabled=obj is None or not obj.get("quiet", False), + err=err, + ) + + +@pass_obj_silent +def verbose(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted verbose message to stdout in blue. + + The message is printed only when called from a Click context with the verbose + flag set and the quiet flag unset. Calls made outside a Click context are a + no-op. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "blue", + *message, + enabled=( + obj is not None + and obj.get("verbose", False) + and not obj.get("quiet", False) + ), + err=err, + ) + + +@pass_obj_silent +def debug(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted debug message to stdout in magenta. + + The message is printed only when called from a Click context with the debug + flag enabled. Calls made outside a Click context are a no-op. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "magenta", + *message, + enabled=obj is not None and obj.get("debug", False), + err=err, + ) diff --git a/prometheus_pacman_exporter/metrics.py b/prometheus_pacman_exporter/metrics.py new file mode 100644 index 0000000..f8a99fd --- /dev/null +++ b/prometheus_pacman_exporter/metrics.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Prometheus metric definitions for the pacman exporter.""" + +from prometheus_client import CollectorRegistry, Gauge + +REGISTRY = CollectorRegistry(auto_describe=True) + +INSTALLED_PACKAGES = Gauge( + "pacman_installed_packages", + "The current number of installed packages", + registry=REGISTRY, +) +UPDATEABLE_PACKAGES = Gauge( + "pacman_updateable_packages", + "The current number of updateable packages", + registry=REGISTRY, +) +EXPLICIT_PACKAGES = Gauge( + "pacman_explicit_packages", + "The current number of explicitly installed packages", + registry=REGISTRY, +) +DEPENDS_PACKAGES = Gauge( + "pacman_depends_packages", + "The current number of packages installed as dependencies", + registry=REGISTRY, +) +UNREQUIRED_PACKAGES = Gauge( + "pacman_unrequired_packages", + "The current number of unrequired packages", + registry=REGISTRY, +) +FOREIGN_PACKAGES = Gauge( + "pacman_foreign_packages", + "The current number of foreign packages", + registry=REGISTRY, +) +NATIVE_PACKAGES = Gauge( + "pacman_native_packages", "The current number of native packages", registry=REGISTRY +) +ORPHAN_PACKAGES = Gauge( + "pacman_orphan_packages", "The current number of orphan packages", registry=REGISTRY +) +AUR_UPDATEABLE_PACKAGES = Gauge( + "pacman_aur_updateable_packages", + "The current number of updates packages from the AUR", + registry=REGISTRY, +) |
