From d43af55ea733c2073c97a0fc7c7758ed40adbb44 Mon Sep 17 00:00:00 2001 From: parasol-ka Date: Thu, 22 Oct 2026 13:28:15 +0200 Subject: [PATCH 1/2] feat: add update metrics + tests --- .github/workflows/github-publish.yaml | 8 ++ .github/workflows/smoke-test.yaml | 24 ++++ Dockerfile | 13 ++- README.md | 38 ++++++- apt-metrics.sh | 89 +++++++++++++++ entrypoint.sh | 7 +- tests/smoke.sh | 158 ++++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/smoke-test.yaml create mode 100755 apt-metrics.sh create mode 100755 tests/smoke.sh diff --git a/.github/workflows/github-publish.yaml b/.github/workflows/github-publish.yaml index 4a41c8e..13b582b 100644 --- a/.github/workflows/github-publish.yaml +++ b/.github/workflows/github-publish.yaml @@ -16,8 +16,16 @@ on: type: string jobs: + smoke: + name: Smoke test + if: startsWith(github.ref, 'refs/tags/v') && !inputs.skip_publish + permissions: + contents: read + uses: ./.github/workflows/smoke-test.yaml + publish: name: Push image to GHCR + needs: smoke runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') && !inputs.skip_publish timeout-minutes: 30 diff --git a/.github/workflows/smoke-test.yaml b/.github/workflows/smoke-test.yaml new file mode 100644 index 0000000..218c62c --- /dev/null +++ b/.github/workflows/smoke-test.yaml @@ -0,0 +1,24 @@ +name: Smoke test + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + # called by github-publish.yaml before pushing a release + workflow_call: + +jobs: + smoke: + name: Image starts and serves metrics + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run smoke test + run: tests/smoke.sh diff --git a/Dockerfile b/Dockerfile index 9c446bf..0e93b2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,17 @@ FROM quay.io/prometheus/node-exporter:v1.12.1 # It listens on LISTEN_ADDRESS, by default the gateway of the paashup traefik network # (172.31.0.0/16): Prometheus reaches it there, and the port is not exposed on the public IP. # - udev data: disk model/serial in node_disk_info -# - textfile: node_reboot_required / apt_upgrades_pending, written on the host by the -# node-exporter-apt timer (see paashup-install.sh) into /var/lib/node_exporter/textfile -ENV LISTEN_ADDRESS=172.31.0.1:9100 +# - textfile: node_reboot_required, apt_upgrades_pending and the unattended-upgrades reboot +# settings, written by apt-metrics.sh (host apt, through chroot /host) every +# APT_METRICS_INTERVAL seconds +ENV LISTEN_ADDRESS=172.31.0.1:9100 \ + APT_METRICS_INTERVAL=300 +# root only for apt-metrics.sh: chroot /host needs it to run the host's apt (the host stays +# mounted read-only). node_exporter itself, the part on the network, runs as nobody. +USER root + +COPY --chmod=755 apt-metrics.sh /apt-metrics.sh COPY --chmod=755 entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] CMD [] diff --git a/README.md b/README.md index 247cb8c..bfc27ef 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Repackages `quay.io/prometheus/node-exporter` as a versioned, buildable image. It reports the VM itself: OS, kernel, uptime, CPU/RAM/disk/network, and through the textfile collector the pending -reboot (`node_reboot_required`) and upgrades (`apt_upgrades_pending`). +reboot (`node_reboot_required`), the upgrades (`apt_upgrades_pending`) and +the unattended-upgrades reboot settings. - `Dockerfile` → static metadata (Prometheus scrape labels) and default flags (`--path.rootfs=/host`, udev data, textfile directory). @@ -32,9 +33,23 @@ is not exposed on the public IP. If that network has another subnet, set `prometheus_address=:9100`. Without the `traefik` network the container exits (`cannot assign requested address`). -For the textfile metrics, the `node-exporter-apt` timer installed by -`paashup-install.sh` writes `/var/lib/node_exporter/textfile/apt.prom` -on the host. Without it these two metrics are simply absent. +The textfile metrics are written by `apt-metrics.sh`, started next to +node_exporter: every `APT_METRICS_INTERVAL` seconds (default 300) it runs +the host's apt through `chroot /host` and writes `/tmp/textfile/apt.prom` +inside the container. Nothing has to be installed on the host. +`chroot` needs root, so only this loop runs as root (it reads the host, +mounted read-only, and listens on nothing); node_exporter, the part on +the network, runs as `nobody`. + +| Metric | Value | +|---|---| +| `node_reboot_required` | 1 if `/run/reboot-required` exists | +| `apt_upgrades_pending` | packages `apt-get -s upgrade` would install | +| `apt_unattended_upgrades_enabled` | 1 if `APT::Periodic::Unattended-Upgrade` is set | +| `apt_auto_reboot_enabled` | `Unattended-Upgrade::Automatic-Reboot` | +| `apt_auto_reboot_time_seconds{time,timezone}` | `Automatic-Reboot-Time`, in seconds after midnight in the host time zone (absent when it is `now`) | +| `apt_auto_reboot_timezone_offset_seconds{timezone}` | UTC offset of the host time zone | +| `node_reboot_scheduled_timestamp_seconds` | time of the reboot planned by unattended-upgrades, 0 if none | ## Usage @@ -58,6 +73,21 @@ docker run -d \ Metrics are exposed on `http://172.31.0.1:9100/metrics`. +## Tests + +```bash +tests/smoke.sh # builds the image, then tests it +IMAGE= tests/smoke.sh # tests an existing image +``` + +It runs the container as documented (on `127.0.0.1:$SMOKE_PORT`, +default 19100) and checks the labels, the core collectors, `/` seen +through `--path.rootfs`, node_exporter running as `nobody`, run-time +arguments added to the baked flags, and the apt metrics, against both +the real host and a Debian rootfs with a known unattended-upgrades +config. CI runs it on every push to `main`, on pull requests and +before each release. + ## Release Push a `vX.Y.Z` tag: the workflow builds and pushes diff --git a/apt-metrics.sh b/apt-metrics.sh new file mode 100755 index 0000000..43a9986 --- /dev/null +++ b/apt-metrics.sh @@ -0,0 +1,89 @@ +#!/bin/sh +# Pending reboot and upgrades of the host, and its unattended-upgrades reboot settings, read +# through /host, for the textfile collector. Started in the background by entrypoint.sh and +# rewritten every APT_METRICS_INTERVAL seconds. apt runs from the host (chroot /host), read-only. +dir="$1" +interval="${APT_METRICS_INTERVAL:-300}" + +# value of an apt option, as `apt-config dump` prints it: Key "value"; +conf_get() { + printf '%s\n' "$conf" | sed -n "s|^$1 \"\(.*\)\";$|\1|p" | tail -n 1 +} + +is_true() { + case "$1" in true|yes|on|1) return 0 ;; *) return 1 ;; esac +} + +write_metrics() { + required=0 + [ -f /host/run/reboot-required ] && required=1 + + pending="" + if sim="$(chroot /host apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null)"; then + pending="$(printf '%s\n' "$sim" | grep -c '^Inst ')" + fi + + conf="$(chroot /host apt-config dump 2>/dev/null)" + periodic="$(conf_get 'APT::Periodic::Unattended-Upgrade')" + auto="$(conf_get 'Unattended-Upgrade::Automatic-Reboot')" + at="$(conf_get 'Unattended-Upgrade::Automatic-Reboot-Time')" + [ -n "$at" ] || at="now" + + enabled=0 + [ -n "$periodic" ] && [ "$periodic" != 0 ] && enabled=1 + auto_reboot=0 + is_true "$auto" && auto_reboot=1 + + tz="$(cat /host/etc/timezone 2>/dev/null || readlink /host/etc/localtime | sed 's|.*/zoneinfo/||')" + [ -n "$tz" ] || tz="UTC" + # +0200 -> 7200 + offset="$(chroot /host date +%z 2>/dev/null)" + case "$offset" in + [+-][0-9][0-9][0-9][0-9]) + sign="${offset%????}"; hh="${offset#?}"; hh="${hh%??}"; mm="${offset#???}" + offset=$(( ${sign}1 * (${hh#0} * 3600 + ${mm#0} * 60) )) ;; + *) offset=0 ;; + esac + + # reboot planned by unattended-upgrades (shutdown -r HH:MM), in microseconds since epoch + scheduled=0 + if grep -q '^MODE=reboot' /host/run/systemd/shutdown/scheduled 2>/dev/null; then + usec="$(sed -n 's/^USEC=//p' /host/run/systemd/shutdown/scheduled)" + [ -n "$usec" ] && scheduled=$(( usec / 1000000 )) + fi + + { + echo '# HELP node_reboot_required 1 if a reboot is pending.' + echo '# TYPE node_reboot_required gauge' + echo "node_reboot_required $required" + if [ -n "$pending" ]; then + echo '# HELP apt_upgrades_pending Packages waiting to be upgraded.' + echo '# TYPE apt_upgrades_pending gauge' + echo "apt_upgrades_pending $pending" + fi + echo '# HELP apt_unattended_upgrades_enabled 1 if unattended-upgrades runs every day.' + echo '# TYPE apt_unattended_upgrades_enabled gauge' + echo "apt_unattended_upgrades_enabled $enabled" + echo '# HELP apt_auto_reboot_enabled 1 if unattended-upgrades reboots by itself when an update needs it.' + echo '# TYPE apt_auto_reboot_enabled gauge' + echo "apt_auto_reboot_enabled $auto_reboot" + case "$at" in + [0-9]:[0-9][0-9]|[0-9][0-9]:[0-9][0-9]) + h="${at%%:*}"; m="${at#*:}" + echo '# HELP apt_auto_reboot_time_seconds Configured reboot time, in seconds after midnight in the host time zone.' + echo '# TYPE apt_auto_reboot_time_seconds gauge' + echo "apt_auto_reboot_time_seconds{time=\"$at\",timezone=\"$tz\"} $(( ${h#0} * 3600 + ${m#0} * 60 ))" ;; + esac + echo '# HELP apt_auto_reboot_timezone_offset_seconds UTC offset of the host time zone, now.' + echo '# TYPE apt_auto_reboot_timezone_offset_seconds gauge' + echo "apt_auto_reboot_timezone_offset_seconds{timezone=\"$tz\"} $offset" + echo '# HELP node_reboot_scheduled_timestamp_seconds Time of the planned reboot (0 if none).' + echo '# TYPE node_reboot_scheduled_timestamp_seconds gauge' + echo "node_reboot_scheduled_timestamp_seconds $scheduled" + } > "$dir/apt.prom.$$" && mv "$dir/apt.prom.$$" "$dir/apt.prom" +} + +while :; do + write_metrics || echo "apt-metrics: could not write $dir/apt.prom" >&2 + sleep "$interval" +done diff --git a/entrypoint.sh b/entrypoint.sh index bd6030a..d3ec60e 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,9 +3,12 @@ # the host PID namespace and / mounted read-only at /host. Arguments given at run time are # added after these (they don't replace them). set -e -exec /bin/node_exporter \ +TEXTFILE_DIR=/tmp/textfile +mkdir -p "$TEXTFILE_DIR" +/apt-metrics.sh "$TEXTFILE_DIR" & +exec setuidgid nobody /bin/node_exporter \ --path.rootfs=/host \ --path.udev.data=/host/run/udev/data \ - --collector.textfile.directory=/host/var/lib/node_exporter/textfile \ + --collector.textfile.directory="$TEXTFILE_DIR" \ --web.listen-address="${LISTEN_ADDRESS:-172.31.0.1:9100}" \ "$@" diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..9eee84b --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Smoke test: the image starts with the flags baked in entrypoint.sh, serves metrics, keeps the +# run-time arguments, and apt-metrics.sh writes the textfile metrics from the host's apt. +# tests/smoke.sh builds the image from the repo +# IMAGE= tests/smoke.sh tests an existing image +# Needs docker and curl. Listens on 127.0.0.1:$SMOKE_PORT (default 19100) with the host network. +set -euo pipefail + +cd "$(dirname "$0")/.." + +BUILD=0 +if [ -z "${IMAGE:-}" ]; then IMAGE=node-exporter:smoke; BUILD=1; fi +HOST_IMAGE=node-exporter-smoke-host +PORT="${SMOKE_PORT:-19100}" +ADDR="127.0.0.1:$PORT" +NAME="node-exporter-smoke-$$" +TMP="$(mktemp -d)" +failures=0 + +cleanup() { + docker rm -f "$NAME" "$NAME-host" >/dev/null 2>&1 || true + rm -rf "$TMP" +} +trap cleanup EXIT + +pass() { echo " ok $*"; } +fail() { echo " FAIL $*"; failures=$((failures + 1)); } + +# expect