diff options
Diffstat (limited to 'prometheus_pacman_exporter/__init__.py')
| -rw-r--r-- | prometheus_pacman_exporter/__init__.py | 224 |
1 files changed, 224 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() |
