Skip to content

Add Zypper updates and patches script metrics #222

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions zypper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
#!/usr/bin/env python3

"""
Description: Expose metrics from zypper updates and patches.

The script can take 2 arguments: `--more` and `--less`.
The selection of the arguments change how many informations are going to be printed.

The `--more` is by default.

Examples:

zypper.py --less
zypper.py -m

Authors: Gabriele Puliti <[email protected]>
Bernd Shubert <[email protected]>
"""

import argparse
import subprocess
import os
import sys

from collections.abc import Sequence
from prometheus_client import CollectorRegistry, Gauge, Info, generate_latest

REGISTRY = CollectorRegistry()
NAMESPACE = "zypper"


def __print_pending_data(data, fields, info, filters=None):
filters = filters or {}

if len(data) == 0:
field_str = ",".join([f'{name}=""' for _, name in fields])
info.info({field_str: '0'})
else:
for package in data:
check = all(package.get(k) == v for k, v in filters.items())
if check:
field_str = ",".join([f'{name}="{package[field]}"' for field, name in fields])
info.info({field_str: '1'})


def print_pending_updates(data, all_info, filters=None):
if all_info:
fields = [("Repository", "repository"),
("Name", "package-name"),
("Available Version",
"available-version")]
else:
fields = [("Repository", "repository"),
("Name", "package-name")]
prefix = "zypper_update_pending"
description = (
"zypper package update available from repository. "
"(0 = not available, 1 = available)"
)
info = Info(prefix, description)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You specify a metric description that includes "(0 = not available, 1 = available)", yet Info metrics cannot take a user-defined value, and always have a value of 1. I'm not sure what your intentions are here.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dswarbrick Thanks for your comments and effort to review it. The explanation for 0 and 1 in the help section clarifies what value can be expected to get from the metrics. Even if it is clear for some people not of of the users will have the same knowledge from the beginning. From my point of view having this hint mentioned here does hurt anyone and makes the usage a bit easier.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pirat013 The issue is not with having that information in the metric description. The issue is that using an Info metric type does not allow the value to be specified - they always have a value of 1. Encoding the 0 or 1 status as a label is not an acceptable approach, due to its propensity to create new series and result in stale metrics when that label value changes. Using a Gauge metric, and setting the metric value to 0 or 1, is the only correct way to do this.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, got it.


__print_pending_data(data, fields, info, filters)


def print_pending_patches(data, all_info, filters=None):
if all_info:
fields = [("Repository", "repository"),
("Name", "patch-name"),
("Category", "category"),
("Severity", "severity"),
("Interactive", "interactive"),
("Status", "status")]
else:
fields = [("Repository", "repository"),
("Name", "patch-name"),
("Interactive", "interactive"),
("Status", "status")]
prefix = "zypper_patch_pending"
description = "zypper patch available from repository. (0 = not available , 1 = available)"
info = Info(prefix, description)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above regarding Info metric types.


__print_pending_data(data, fields, info, filters)


def print_orphaned_packages(data, filters=None):
fields = [("Name", "package"),
("Version", "installed-version")]
prefix = "zypper_package_orphan"
description = "zypper packages with no update source (orphaned)"
info = Info(prefix, description)

__print_pending_data(data, fields, info, filters)


def print_data_sum(data, prefix, description, filters=None):
gauge = Gauge(prefix,
description,
namespace=NAMESPACE,
registry=REGISTRY)
filters = filters or {}
if len(data) == 0:
gauge.set(0)
else:
for package in data:
check = all(package.get(k) == v for k, v in filters.items())
if check:
gauge.inc()


def print_reboot_required():
needs_restarting_path = '/usr/bin/needs-restarting'
is_path_ok = os.path.isfile(needs_restarting_path) and os.access(needs_restarting_path, os.X_OK)
Copy link
Member

@dswarbrick dswarbrick Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is overly paranoid (and not atomic). A more elegant approach would simply be to catch subprocess.CalledProcessError exceptions thrown by subprocess.run.


if is_path_ok:
prefix = "node_reboot_required"
description = (
"Node require reboot to activate installed updates or patches. "
"(0 = not needed, 1 = needed)"
)
info = Info(prefix, description)
result = subprocess.run(
[needs_restarting_path, '-r'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False)
if result.returncode == 0:
info.info({"node_reboot_required": "0"})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once again you are abusing the Info metric type. Specifying a 0 or 1 value as a label will create new series whenever that value changes, leading to stale metrics due to Prometheus' look-behind mechanism. What you need to use is a Gauge metric.

else:
info.info({"node_reboot_required": "1"})


def print_zypper_version():
result = subprocess.run(
['/usr/bin/zypper', '-V'],
stdout=subprocess.PIPE,
check=False).stdout.decode('utf-8')
info = Info("zypper_version", "zypper installed package version")

info.info({"zypper_version": result.split()[1]})


def __extract_data(raw, fields):
raw_lines = raw.splitlines()[2:]
extracted_data = []

for line in raw_lines:
parts = [part.strip() for part in line.split('|')]
if len(parts) >= max(fields.values()) + 1:
extracted_data.append({
field: parts[index] for field, index in fields.items()
})

return extracted_data


def stdout_zypper_command(command):
result = subprocess.run(
command,
stdout=subprocess.PIPE,
check=False
)

if result.returncode != 0:
raise RuntimeError(f"zypper returned exit code {result.returncode}: {result.stderr}")

return result.stdout.decode('utf-8')


def extract_lu_data(raw: str):
fields = {
"Repository": 1,
"Name": 2,
"Current Version": 3,
"Available Version": 4,
"Arch": 5
}

return __extract_data(raw, fields)


def extract_lp_data(raw: str):
fields = {
"Repository": 0,
"Name": 1,
"Category": 2,
"Severity": 3,
"Interactive": 4,
"Status": 5
}

return __extract_data(raw, fields)


def extract_orphaned_data(raw: str):
fields = {
"Name": 3,
"Version": 4
}

return __extract_data(raw, fields)


def __parse_arguments(argv):
parser = argparse.ArgumentParser()

parser.add_mutually_exclusive_group(required=False)
Copy link
Member

@dswarbrick dswarbrick Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks incorrect. I think what you meant to do is

group = parser.add_mutually_exclusive_group(required=False)
group.add_argument("-m", ...)
group.add_argument("-l", ...)

However, this combination of "more / less" in a mutually exclusive group, and with a default that sets the --more option true (thus overriding the fact you set the group required=False), is really unintuitive from a user's perspective.

You have essentially just reinvented a boolean option, which by definition can only be one of two values, i.e. mutually exclusive.

Please re-work this to something that is a bit clearer.

parser.add_argument(
"-m",
"--more",
dest="all_info",
action='store_true',
help="Print all the package infos",
)
parser.add_argument(
"-l",
"--less",
dest="all_info",
action='store_false',
help="Print less package infos",
)
parser.set_defaults(all_info=True)

return parser.parse_args(argv)


def main(argv: Sequence[str] | None = None) -> int:
args = __parse_arguments(argv)
data_zypper_lu = extract_lu_data(
stdout_zypper_command(['/usr/bin/zypper', '--quiet', 'lu'])
)
data_zypper_lp = extract_lp_data(
stdout_zypper_command(['/usr/bin/zypper', '--quiet', 'lp'])
)
data_zypper_orphaned = extract_orphaned_data(
stdout_zypper_command(['/usr/bin/zypper', '--quiet', 'pa', '--orphaned'])
)

print_pending_updates(data_zypper_lu,
args.all_info)
print_data_sum(data_zypper_lu,
"zypper_updates_pending_total",
"zypper packages updates available in total")
print_pending_patches(data_zypper_lp,
args.all_info)
print_data_sum(data_zypper_lp,
"zypper_patches_pending_total",
"zypper patches available total")
print_data_sum(data_zypper_lp,
"zypper_patches_pending_security_total",
"zypper patches available with category security total",
filters={'Category': 'security'})
print_data_sum(data_zypper_lp,
"zypper_patches_pending_security_important_total",
"zypper patches available with category security severity important total",
filters={'Category': 'security', 'Severity': 'important'})
print_data_sum(data_zypper_lp,
"zypper_patches_pending_reboot_total",
"zypper patches available which require reboot total",
filters={'Interactive': 'reboot'})
print_reboot_required()
print_zypper_version()
print_orphaned_packages(data_zypper_orphaned)

return 0


if __name__ == "__main__":
try:
main()
except Exception as e:
print("ERROR: {}".format(e), file=sys.stderr)
sys.exit(1)

print(generate_latest(REGISTRY).decode(), end="")
Loading