aboutsummaryrefslogtreecommitdiff
path: root/prometheus_pacman_exporter
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--prometheus_pacman_exporter/__init__.py224
-rw-r--r--prometheus_pacman_exporter/__main__.py7
-rw-r--r--prometheus_pacman_exporter/cli.py161
-rw-r--r--prometheus_pacman_exporter/metrics.py51
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,
+)