From bb61002e038888b24e14100afa583072679ad2fd Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:15:49 +0200 Subject: [PATCH 1/4] Add repository gitignore --- .gitignore | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..9d5ce782e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Ignore all dotfiles # +####################### +.* +!/.gitignore +!/.git-blame-ignore-revs +!/.editorconfig +!/.php_cs +!/.github/ +!/.mention-bot +!/.env.example + +# Others # +########## +__pycache__ From df65a62d93ada0c03f169eb1e63845f285123f2e Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:16:02 +0200 Subject: [PATCH 2/4] SNMP: add shared extension systemd units --- snmp/common/README.md | 47 ++++++++++++++++++++ snmp/common/librenms-snmp-extension@.service | 17 +++++++ snmp/common/librenms-snmp-extension@.timer | 14 ++++++ 3 files changed, 78 insertions(+) create mode 100644 snmp/common/README.md create mode 100644 snmp/common/librenms-snmp-extension@.service create mode 100644 snmp/common/librenms-snmp-extension@.timer diff --git a/snmp/common/README.md b/snmp/common/README.md new file mode 100644 index 000000000..5833c435e --- /dev/null +++ b/snmp/common/README.md @@ -0,0 +1,47 @@ +# LibreNMS SNMP Extension Common Units + +This directory contains shared systemd units for LibreNMS SNMP extensions that refresh cached output on a schedule. + +## Files + +- `librenms-snmp-extension@.service`: common oneshot service template. +- `librenms-snmp-extension@.timer`: common timer template that runs an extension every five minutes. + +## Defaults + +The common service runs extension instances by name: + +```ini +ExecStart=/usr/local/lib/snmpd/%i --config /etc/snmp/extension/%i.yaml --output /run/snmp/extension/%i.json +``` + +For an extension named `example`, this resolves to: + +- Script: `/usr/local/lib/snmpd/example` +- Config: `/etc/snmp/extension/example.yaml` +- Output: `/run/snmp/extension/example.json` + +Extensions that need different arguments should install a systemd drop-in override for their specific instance. + +The units include `Documentation=` metadata for the shared common units and the extension-specific directory. The service also sets `SyslogIdentifier=librenms-snmp-extension-%i` so logs can be filtered per extension instance. + +## Enable An Extension + +Install the shared units to `/etc/systemd/system/`, then enable the timer for the extension instance: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now librenms-snmp-extension@example.timer +``` + +Check timer status: + +```bash +systemctl status librenms-snmp-extension@example.timer +``` + +Run a refresh manually: + +```bash +sudo systemctl start librenms-snmp-extension@example.service +``` diff --git a/snmp/common/librenms-snmp-extension@.service b/snmp/common/librenms-snmp-extension@.service new file mode 100644 index 000000000..46d2cd0a7 --- /dev/null +++ b/snmp/common/librenms-snmp-extension@.service @@ -0,0 +1,17 @@ +# SNMP common extension cache. +[Unit] +Description=LibreNMS SNMP extension cache: %i +Documentation=https://github.com/librenms/librenms-agent/tree/master/snmp/common +Documentation=https://github.com/librenms/librenms-agent/tree/master/snmp/%i + +[Service] +Type=oneshot +SyslogIdentifier=librenms-snmp-extension-%i +RuntimeDirectory=snmp/extension +RuntimeDirectoryMode=0755 +ExecStart=/usr/local/lib/snmpd/%i --config /etc/snmp/extension/%i.yaml --output /run/snmp/extension/%i.json +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=full +ReadWritePaths=/run/snmp/extension diff --git a/snmp/common/librenms-snmp-extension@.timer b/snmp/common/librenms-snmp-extension@.timer new file mode 100644 index 000000000..568cb29cf --- /dev/null +++ b/snmp/common/librenms-snmp-extension@.timer @@ -0,0 +1,14 @@ +# SNMP common extension cache. +[Unit] +Description=Run LibreNMS SNMP extension cache refresh: %i +Documentation=https://github.com/librenms/librenms-agent/tree/master/snmp/common +Documentation=https://github.com/librenms/librenms-agent/tree/master/snmp/%i + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +AccuracySec=30s +Persistent=true + +[Install] +WantedBy=timers.target From 4238596f4aa1db8ebc7a84e357d69be091a7287a Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:16:34 +0200 Subject: [PATCH 3/4] SNMP: mdadm: add pass_persist agent --- snmp/mdadm | 101 --- snmp/mdadm/MDADM-MIB.mib | 1570 +++++++++++++++++++++++++++++++++ snmp/mdadm/mdadm | 1312 +++++++++++++++++++++++++++ snmp/mdadm/mdadm.yaml.example | 17 + snmp/mdadm/sudoers.d-mdadm | 10 + 5 files changed, 2909 insertions(+), 101 deletions(-) delete mode 100755 snmp/mdadm create mode 100644 snmp/mdadm/MDADM-MIB.mib create mode 100755 snmp/mdadm/mdadm create mode 100644 snmp/mdadm/mdadm.yaml.example create mode 100644 snmp/mdadm/sudoers.d-mdadm diff --git a/snmp/mdadm b/snmp/mdadm deleted file mode 100755 index 023af68a5..000000000 --- a/snmp/mdadm +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -# MDADM SNMP extension for LibreNMS -# Version -extendVer='2' -# Initial portion of json -mdadmSNMPOutput='{ "data": [' - -# Outputs a list of devices -list_devices() { - for device in "${1}/slaves/"*; do - if [ "${2,,}" == 'count' ]; then - ((devCount++)) - elif [ "${2,,}" != 'missing' ] || [ ! -e "${device}" ]; then - printf '%b\t "%s"' "${multiDisk}" "$(basename "${device}")" - multiDisk=',\n' - fi - done - [ "${devCount}" ] && echo "${devCount}" -} - -# Outputs either 0, 100, or the value of the file referenced -maybe_get() { - if [ -f "${1}" ] && [[ $(cat "${1}") =~ " / " ]]; then - echo $((100 * $(cat "${1}"))) - elif [ -f "${1}" ] && [ "$(cat "${1}")" != 'none' ]; then - cat "${1}" - else - echo 0 - fi -} - -main() { - if ! which 'jq' > /dev/null 2>&1; then - errorCode=1 - # The underscore here is a hack since we have to strip spaces without jq - errorString='jq_missing!' - elif stat "/dev/md"[[:digit:]]* > /dev/null 2>&1; then - for mdadmArray in "/dev/md"[[:digit:]]*; do - # Ignore partitions - [[ "${mdadmArray}" =~ '/dev/md'[[:digit:]]+'p' ]] && continue - - mdadmName="$(basename "$(realpath "${mdadmArray}")")" - - # Ignore inactive arrays - [[ $(grep "^${mdadmName}" /proc/mdstat) =~ 'inactive' ]] && continue - - mdadmSysDev="/sys/block/${mdadmName}" - - degraded=$(maybe_get "${mdadmSysDev}/md/degraded") - syncSpeed=$(($(maybe_get "${mdadmSysDev}/md/sync_speed") * 1024)) - - syncCompleted=$(maybe_get "${mdadmSysDev}/md/sync_completed") - if [ $syncCompleted -eq 0 ] && [ $degraded -eq 0 ] && [ $syncSpeed -eq 0 ]; then - syncCompleted="100" - fi - - read -r -d '' mdadmOutput < /dev/null || sed 's/\s//g' <<< "${mdadmSNMPOutput//$'\n'/}${metadataOutput//$'\n'/}" -} - -main "${@}" diff --git a/snmp/mdadm/MDADM-MIB.mib b/snmp/mdadm/MDADM-MIB.mib new file mode 100644 index 000000000..b04ae0093 --- /dev/null +++ b/snmp/mdadm/MDADM-MIB.mib @@ -0,0 +1,1570 @@ +MDADM-MIB DEFINITIONS ::= BEGIN + +IMPORTS + MODULE-IDENTITY, OBJECT-TYPE, NOTIFICATION-TYPE, + Unsigned32, enterprises + FROM SNMPv2-SMI + TEXTUAL-CONVENTION, DisplayString, TruthValue + FROM SNMPv2-TC + CounterBasedGauge64 + FROM HCNUM-TC + MODULE-COMPLIANCE, OBJECT-GROUP, NOTIFICATION-GROUP + FROM SNMPv2-CONF; + +mdadmMIB MODULE-IDENTITY + LAST-UPDATED "202606080000Z" + ORGANIZATION "Unassigned" + CONTACT-INFO + "mdadm SNMP agent MIB. + + Enterprise OID: 1.3.6.1.4.1.60652.101" + DESCRIPTION + "The MDADM-MIB exposes Linux software RAID (mdadm) array health, + configuration, synchronisation status, and member device health. + + Data source is the Linux md sysfs interface. Each + DESCRIPTION clause notes the canonical Linux sysfs path. + Member-level sysfs paths use the form + /sys/block//md/dev-/. + + Textual conventions, objects, notifications, and conformance + definitions are all in this file. + + OID structure: + mdadmMIB 1.3.6.1.4.1.60652.101 + mdadmObjects .1 + mdadmMetadata .1.1 system-level scalars + array meta table: .1.{2,3,4} + array health table: .1.{5,6,7} + array sync table: .1.{8,9,10} + device meta table: .1.{11,12,13} + device health table: .1.{14,15,16} + mdadmNotifications .2 + mdadmConformance .3" + REVISION "202606080000Z" + DESCRIPTION "Change all timestamp fields from DateAndTime to DisplayString + (ISO 8601) for reliable pass_persist transport; remove JSON + field path annotations; rename MdadmArrayState TC to + MdadmArrayStatus to avoid case-only name collision." + REVISION "202606070000Z" + DESCRIPTION "Add mdadmArrayShortName and mdadmArrayUris to mdadmArrayMetaEntry." + REVISION "202606060000Z" + DESCRIPTION "Initial version." + ::= { enterprises 60652 101 } + +-- -------------------------------------------------------------------------- +-- Textual Conventions +-- -------------------------------------------------------------------------- + +MdadmRaidLevel ::= TEXTUAL-CONVENTION + STATUS current + DESCRIPTION + "RAID level as reported by mdadm and /sys/block//md/level. + unknown(0) is used when the level string cannot be mapped." + SYNTAX INTEGER { + unknown(0), + linear(1), + raid0(2), + raid1(3), + raid4(4), + raid5(5), + raid6(6), + raid10(10) + } + +MdadmLayout ::= TEXTUAL-CONVENTION + STATUS current + DESCRIPTION + "Numeric layout code from /sys/block//md/layout; its + interpretation depends on the RAID level. The enumeration + names the RAID-5/6 parity-placement algorithms. For RAID-10 + the value is a bit-encoded near/far/offset copy count and will + generally fall outside the named values. notApplicable(255) + is reported for RAID levels that have no layout concept + (linear, raid0, raid1)." + SYNTAX INTEGER { + leftAsymmetric(0), + rightAsymmetric(1), + leftSymmetric(2), + rightSymmetric(3), + parityFirst(4), + parityLast(5), + notApplicable(255) + } + +MdadmArrayStatus ::= TEXTUAL-CONVENTION + STATUS current + DESCRIPTION + "Array state derived from /sys/block//md/array_state. + unknown(0) is used when the state string cannot be mapped. + + Defined kernel state strings (from md.rst): + clear -- no devices, no size + inactive -- devices but array not active + suspended -- active but I/O suspended + readonly -- read-only + read-auto -- read-only, switches to read-write on first write + clean -- consistent, no dirty data + active -- normal operation, possible dirty data + write-pending -- active, pending transition to clean + active-idle -- like active but no recent writes" + SYNTAX INTEGER { + unknown(0), + clear(1), + inactive(2), + suspended(3), + readonly(4), + readAuto(5), + clean(6), + active(7), + writePending(8), + activeIdle(9), + degraded(10), + failed(11) + } + +MdadmSyncAction ::= TEXTUAL-CONVENTION + STATUS current + DESCRIPTION + "Current or most recently completed synchronisation action for an + array, derived from /sys/block//md/sync_action. + + Defined kernel values: resync, recover, idle, check, repair." + SYNTAX INTEGER { + idle(0), + resync(1), + recover(2), + check(3), + repair(4), + reshape(5) + } + +MdadmDeviceRole ::= TEXTUAL-CONVENTION + STATUS current + DESCRIPTION + "Role of a member device within its array, derived from + /sys/block//md/dev-/state." + SYNTAX INTEGER { + unknown(0), + active(1), + spare(2), + faulty(3), + missing(4), + writeMostly(5), + replacement(6) + } + +MdadmConsistencyPolicy ::= TEXTUAL-CONVENTION + STATUS current + DESCRIPTION + "Array consistency policy from + /sys/block//md/consistency_policy. + Defined kernel values: none, resync, bitmap, journal, ppl." + SYNTAX INTEGER { + unknown(0), + resync(1), + journal(2), + ppl(3), + none(4), + bitmap(5) + } + +MdadmBitmapType ::= TEXTUAL-CONVENTION + STATUS current + DESCRIPTION + "Write-intent bitmap storage type from + /sys/block//md/bitmap_type. + Defined kernel values: none, bitmap (internal), llbitmap + (lockless internal)." + SYNTAX INTEGER { + none(0), + internal(1), + external(2), + lockless(3) + } + +MdadmJournalMode ::= TEXTUAL-CONVENTION + STATUS current + DESCRIPTION + "RAID-5/6 journal (cache) mode from + /sys/block//md/journal_mode. + Applicable only when a journal device is present. + Defined kernel values: write-through, write-back." + SYNTAX INTEGER { + writeThrough(0), + writeBack(1) + } + +-- -------------------------------------------------------------------------- +-- Top-level structure +-- -------------------------------------------------------------------------- + +mdadmObjects OBJECT IDENTIFIER ::= { mdadmMIB 1 } +mdadmNotifications OBJECT IDENTIFIER ::= { mdadmMIB 2 } +mdadmConformance OBJECT IDENTIFIER ::= { mdadmMIB 3 } +mdadmCompliances OBJECT IDENTIFIER ::= { mdadmConformance 1 } +mdadmGroups OBJECT IDENTIFIER ::= { mdadmConformance 2 } + +-- -------------------------------------------------------------------------- +-- Section 1: Metadata scalars (.1.1) +-- -------------------------------------------------------------------------- + +mdadmMetadata OBJECT IDENTIFIER ::= { mdadmObjects 1 } + +mdadmLastUpdated OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "ISO 8601 timestamp of the most recent agent data collection, + e.g. '2026-06-10T19:45:09+00:00'." + ::= { mdadmMetadata 1 } + +mdadmVersion OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Agent output format version." + ::= { mdadmMetadata 2 } + +mdadmError OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Agent error code from the most recent collection run. Zero + indicates success." + ::= { mdadmMetadata 3 } + +mdadmErrorString OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Human-readable error description from the most recent collection + run. Empty when mdadmError is zero." + ::= { mdadmMetadata 4 } + +mdadmArrayCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Total number of mdadm arrays discovered on this host. + Linux: count of /sys/block/md*/md/ directories" + ::= { mdadmMetadata 5 } + +mdadmDegradedArrayCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of arrays currently in a degraded state. + Linux: count of arrays where /sys/block//md/degraded > 0" + ::= { mdadmMetadata 6 } + +mdadmSyncingArrayCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of arrays currently performing a synchronisation + operation. + Linux: count of arrays where /sys/block//md/sync_action + != 'idle'" + ::= { mdadmMetadata 7 } + +mdadmTotalMemberCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Total number of member device slots across all arrays, including + spares. + Linux: sum of /sys/block//md/raid_disks across arrays" + ::= { mdadmMetadata 8 } + +-- -------------------------------------------------------------------------- +-- Section 2: Array Metadata Table (.2 RowCount, .3 LastChange, .4 Table) +-- -------------------------------------------------------------------------- + +mdadmArrayMetaTableRowCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION "Current number of rows in mdadmArrayMetaTable." + ::= { mdadmObjects 2 } + +mdadmArrayMetaTableLastChange OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "ISO 8601 timestamp of the most recent row addition or removal + in mdadmArrayMetaTable." + ::= { mdadmObjects 3 } + +mdadmArrayMetaTable OBJECT-TYPE + SYNTAX SEQUENCE OF MdadmArrayMetaEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION + "Array identity and static configuration. One row per array. + This table changes infrequently; implementations SHOULD poll + it on a long interval (e.g. 8 hours)." + ::= { mdadmObjects 4 } + +mdadmArrayMetaEntry OBJECT-TYPE + SYNTAX MdadmArrayMetaEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION "Identity and configuration for one mdadm array." + INDEX { mdadmArrayIndex } + ::= { mdadmArrayMetaTable 1 } + +MdadmArrayMetaEntry ::= SEQUENCE { + mdadmArrayIndex Unsigned32, + mdadmArrayUuid DisplayString, + mdadmArrayName DisplayString, + mdadmArrayRaidLevel MdadmRaidLevel, + mdadmArrayMetadataVersion DisplayString, + mdadmArrayConsistencyPolicy MdadmConsistencyPolicy, + mdadmArrayLayout MdadmLayout, + mdadmArrayRaidDisks Unsigned32, + mdadmArraySizeBytes CounterBasedGauge64, + mdadmArraySizeBlocks CounterBasedGauge64, + mdadmArrayComponentSizeBlocks CounterBasedGauge64, + mdadmArrayChunkSize Unsigned32, + mdadmArrayBitmapType MdadmBitmapType, + mdadmArrayBitmapLocation DisplayString, + mdadmArrayBitmapChunksize Unsigned32, + mdadmArrayBitmapMetadata DisplayString, + mdadmArrayLogicalBlockSize Unsigned32, + mdadmArrayResyncStart CounterBasedGauge64, + mdadmArrayReshapePosition CounterBasedGauge64, + mdadmArrayBitmapTimeBase Unsigned32, + mdadmArrayShortName DisplayString, + mdadmArrayUris DisplayString +} + +mdadmArrayIndex OBJECT-TYPE + SYNTAX Unsigned32 (1..4294967295) + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION + "Locally-assigned array index. The agent derives this value + as a 32-bit FNV-1a hash of the array UUID, so the same array + tends to receive the same index across restarts when its UUID + is unchanged. Hash collisions are resolved by linear probing. + Managers SHOULD use mdadmArrayUuid as the stable identifier." + ::= { mdadmArrayMetaEntry 1 } + +mdadmArrayUuid OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Globally unique array identifier in the mdadm UUID format. + Linux: /sys/block//md/uuid" + ::= { mdadmArrayMetaEntry 2 } + +mdadmArrayName OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Kernel device name of the array, e.g. 'md0'. + Linux: kernel device name of /sys/block/" + ::= { mdadmArrayMetaEntry 3 } + +mdadmArrayRaidLevel OBJECT-TYPE + SYNTAX MdadmRaidLevel + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "RAID level of the array. + Linux: /sys/block//md/level" + ::= { mdadmArrayMetaEntry 4 } + +mdadmArrayMetadataVersion OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "mdadm metadata format version string, e.g. '1.2'. May also + be 'none' or 'external:' for externally-managed metadata. + Linux: /sys/block//md/metadata_version" + ::= { mdadmArrayMetaEntry 5 } + +mdadmArrayConsistencyPolicy OBJECT-TYPE + SYNTAX MdadmConsistencyPolicy + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Consistency policy used by the array. + Linux: /sys/block//md/consistency_policy" + ::= { mdadmArrayMetaEntry 6 } + +mdadmArrayLayout OBJECT-TYPE + SYNTAX MdadmLayout + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Numeric layout code; interpretation depends on RAID level. + For RAID-5: 0=left-asymmetric, 1=right-asymmetric, + 2=left-symmetric, 3=right-symmetric, 4=parity-first, + 5=parity-last. For RAID-10: encodes near/far/offset copies. + Linux: /sys/block//md/layout" + ::= { mdadmArrayMetaEntry 7 } + +mdadmArrayRaidDisks OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Configured number of data and parity disks, excluding spares. + Linux: /sys/block//md/raid_disks" + ::= { mdadmArrayMetaEntry 8 } + +mdadmArraySizeBytes OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "bytes" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Total user-addressable array capacity in bytes. + Linux: /sys/block//md/array_size (KiB) x 1024" + ::= { mdadmArrayMetaEntry 9 } + +mdadmArraySizeBlocks OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "512-byte blocks" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Total array capacity in 512-byte blocks. + Linux: /sys/block//size" + ::= { mdadmArrayMetaEntry 10 } + +mdadmArrayComponentSizeBlocks OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "1-KiB blocks" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Per-member component size in 1-KiB blocks. This is the space + each member device contributes to the array. + Linux: /sys/block//md/component_size" + ::= { mdadmArrayMetaEntry 11 } + +mdadmArrayChunkSize OBJECT-TYPE + SYNTAX Unsigned32 + UNITS "bytes" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "RAID stripe chunk size in bytes. Must be a power of 2 and at + least 4 KiB. Zero for RAID levels that do not use striping + (e.g. RAID-1). + Linux: /sys/block//md/chunk_size" + ::= { mdadmArrayMetaEntry 12 } + +mdadmArrayBitmapType OBJECT-TYPE + SYNTAX MdadmBitmapType + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Write-intent bitmap storage type. lockless(3) corresponds to + the kernel 'llbitmap' value. + Linux: /sys/block//md/bitmap_type" + ::= { mdadmArrayMetaEntry 13 } + +mdadmArrayBitmapLocation OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Bitmap storage location string. '+N' or '-N' means N sectors + from the metadata start; 'file' means an external file; 'none' + means no bitmap is present. + Linux: /sys/block//md/bitmap/location" + ::= { mdadmArrayMetaEntry 14 } + +mdadmArrayBitmapChunksize OBJECT-TYPE + SYNTAX Unsigned32 + UNITS "bytes" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Bitmap chunk size in bytes; each bit covers this many bytes of + array space. Zero when no bitmap is present. + Linux: /sys/block//md/bitmap/chunksize" + ::= { mdadmArrayMetaEntry 15 } + +mdadmArrayBitmapMetadata OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Bitmap metadata storage: 'internal' or 'external'. Empty when + no bitmap is present. + Linux: /sys/block//md/bitmap/metadata" + ::= { mdadmArrayMetaEntry 16 } + +mdadmArrayLogicalBlockSize OBJECT-TYPE + SYNTAX Unsigned32 + UNITS "bytes" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Logical block size of the array. Only configurable for + metadata version 1.x arrays. + Linux: /sys/block//md/logical_block_size" + ::= { mdadmArrayMetaEntry 17 } + +mdadmArrayResyncStart OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Sector offset from which a resync will begin if the array is + assembled after an unclean shutdown. The implementation MUST + set this to 18446744073709551615 (0xFFFFFFFFFFFFFFFF) when the + sysfs value is 'none', meaning the array was cleanly shut down + and no resync is needed. + Linux: /sys/block//md/resync_start" + ::= { mdadmArrayMetaEntry 18 } + +mdadmArrayReshapePosition OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Sector offset showing how far an in-progress reshape (level + migration or geometry change) has reached. The implementation + MUST set this to 18446744073709551615 (0xFFFFFFFFFFFFFFFF) + when the sysfs value is 'none', meaning no reshape is in + progress. + Linux: /sys/block//md/reshape_position" + ::= { mdadmArrayMetaEntry 19 } + +mdadmArrayBitmapTimeBase OBJECT-TYPE + SYNTAX Unsigned32 + UNITS "seconds" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Interval used by the bitmap daemon; bitmap bits are cleared + approximately every 2-3 times this period after the + corresponding regions are confirmed in-sync. + Linux: /sys/block//md/bitmap/time_base" + ::= { mdadmArrayMetaEntry 20 } + +mdadmArrayShortName OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Kernel block-device short name of the array, e.g. 'md0' or + 'md127'. This is the basename of the sysfs entry under + /sys/block/ and is stable for the lifetime of the array + assembly. + Linux: basename of /sys/block/" + ::= { mdadmArrayMetaEntry 21 } + +mdadmArrayUris OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Space-separated list of block-device URIs through which this + array is accessible. The first entry is always /dev/. + Subsequent entries are the /dev/disk/by-id/md-* symlinks + (both md-name-* and md-uuid-*) that resolve to the same + device, sorted lexicographically. + Example: '/dev/md0 /dev/disk/by-id/md-name-host:boot + /dev/disk/by-id/md-uuid-003f82c4:...' + Linux: /dev/, /dev/disk/by-id/md-*" + ::= { mdadmArrayMetaEntry 22 } + +-- -------------------------------------------------------------------------- +-- Section 3: Array Health Table (.5 RowCount, .6 LastChange, .7 Table) +-- -------------------------------------------------------------------------- + +mdadmArrayHealthTableRowCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION "Current number of rows in mdadmArrayHealthTable." + ::= { mdadmObjects 5 } + +mdadmArrayHealthTableLastChange OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "ISO 8601 timestamp of the most recent row addition, removal, + or material content change in mdadmArrayHealthTable." + ::= { mdadmObjects 6 } + +mdadmArrayHealthTable OBJECT-TYPE + SYNTAX SEQUENCE OF MdadmArrayHealthEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION + "Array health and operational status. One row per array. + Intended for frequent polling (e.g. 5-minute interval)." + ::= { mdadmObjects 7 } + +mdadmArrayHealthEntry OBJECT-TYPE + SYNTAX MdadmArrayHealthEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION "Health and operational status for one mdadm array." + INDEX { mdadmArrayIndex } + ::= { mdadmArrayHealthTable 1 } + +MdadmArrayHealthEntry ::= SEQUENCE { + mdadmArrayState MdadmArrayStatus, + mdadmArrayDegradedCount Unsigned32, + mdadmArrayActiveDevices Unsigned32, + mdadmArrayWorkingDevices Unsigned32, + mdadmArraySpareDevices Unsigned32, + mdadmArrayFailedDevices Unsigned32, + mdadmArrayMismatchCount Unsigned32, + mdadmArraySafeModeMilliseconds Unsigned32, + mdadmArrayIsMounted TruthValue, + mdadmArrayMountPoints DisplayString, + mdadmArrayIsSwap TruthValue, + mdadmArrayBitmapBacklog Unsigned32, + mdadmArrayBitmapMaxBacklog Unsigned32, + mdadmArrayBitmapCanClear TruthValue, + mdadmArraySuspendLo CounterBasedGauge64, + mdadmArraySuspendHi CounterBasedGauge64, + mdadmArrayStripeCacheSize Unsigned32, + mdadmArrayStripeCacheActive Unsigned32, + mdadmArrayJournalMode MdadmJournalMode +} + +mdadmArrayState OBJECT-TYPE + SYNTAX MdadmArrayStatus + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Current array state. + Linux: /sys/block//md/array_state" + ::= { mdadmArrayHealthEntry 1 } + +mdadmArrayDegradedCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of missing or failed disks causing degraded operation. + Linux: /sys/block//md/degraded" + ::= { mdadmArrayHealthEntry 2 } + +mdadmArrayActiveDevices OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of member devices currently active in the array. + Linux: active_dev count from /proc/mdstat" + ::= { mdadmArrayHealthEntry 3 } + +mdadmArrayWorkingDevices OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of working devices (active plus spares). + Linux: working_dev count from /proc/mdstat" + ::= { mdadmArrayHealthEntry 4 } + +mdadmArraySpareDevices OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of spare devices available for automatic replacement. + Linux: spare_dev count from /proc/mdstat" + ::= { mdadmArrayHealthEntry 5 } + +mdadmArrayFailedDevices OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of failed devices in the array. + Linux: failed_dev count from /proc/mdstat" + ::= { mdadmArrayHealthEntry 6 } + +mdadmArrayMismatchCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of sectors where data mismatches were detected during + the most recent check or repair operation. + Linux: /sys/block//md/mismatch_cnt" + ::= { mdadmArrayHealthEntry 7 } + +mdadmArraySafeModeMilliseconds OBJECT-TYPE + SYNTAX Unsigned32 + UNITS "milliseconds" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Safe-mode write-quiesce delay. After this period of write + inactivity the array transitions from active to clean state. + The kernel default is 200 ms. + Linux: /sys/block//md/safe_mode_delay (seconds, float)" + ::= { mdadmArrayHealthEntry 8 } + +mdadmArrayIsMounted OBJECT-TYPE + SYNTAX TruthValue + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Whether the array device is currently mounted as a filesystem. + Linux: /proc/self/mountinfo" + ::= { mdadmArrayHealthEntry 9 } + +mdadmArrayMountPoints OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Space-separated list of filesystem mount points for this array. + Empty when mdadmArrayIsMounted is false(2). + Linux: /proc/self/mountinfo" + ::= { mdadmArrayHealthEntry 10 } + +mdadmArrayIsSwap OBJECT-TYPE + SYNTAX TruthValue + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Whether the array is enabled as a swap device. + Linux: /proc/swaps" + ::= { mdadmArrayHealthEntry 11 } + +mdadmArrayBitmapBacklog OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Current write-intent bitmap dirty-backlog count (number of + pending background writes for write-mostly RAID-1 devices). + Zero when no bitmap is present. + Linux: /sys/block//md/bitmap/backlog" + ::= { mdadmArrayHealthEntry 12 } + +mdadmArrayBitmapMaxBacklog OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Peak write-intent bitmap dirty backlog observed since the + array was assembled." + ::= { mdadmArrayHealthEntry 13 } + +mdadmArrayBitmapCanClear OBJECT-TYPE + SYNTAX TruthValue + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Whether the write-intent bitmap bits can currently be cleared + as blocks are confirmed in-sync. The kernel sets this to + false(2) when a degraded write occurs; it reverts to true(1) + once the array is fully resynced. + Linux: /sys/block//md/bitmap/can_clear" + ::= { mdadmArrayHealthEntry 14 } + +mdadmArraySuspendLo OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Start sector of the I/O-suspended range. With mdadmArraySuspendHi + this defines a range of the array where all write requests are + blocked until the range is released. Used by RAID-4/5/6 during + reshape operations. Zero when no range is suspended. + Linux: /sys/block//md/suspend_lo" + ::= { mdadmArrayHealthEntry 15 } + +mdadmArraySuspendHi OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "End sector of the I/O-suspended range. See mdadmArraySuspendLo. + Zero when no range is suspended. + Linux: /sys/block//md/suspend_hi" + ::= { mdadmArrayHealthEntry 16 } + +mdadmArrayStripeCacheSize OBJECT-TYPE + SYNTAX Unsigned32 + UNITS "entries" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of stripe-cache slots allocated for RAID-5/6 write + coalescing. The kernel default is 256; the valid range is + 17-32768. Zero for non-RAID-5/6 arrays. + Linux: /sys/block//md/stripe_cache_size" + ::= { mdadmArrayHealthEntry 17 } + +mdadmArrayStripeCacheActive OBJECT-TYPE + SYNTAX Unsigned32 + UNITS "entries" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of stripe-cache slots currently in use (active). Zero + for non-RAID-5/6 arrays. + Linux: /sys/block//md/strip_cache_active" + ::= { mdadmArrayHealthEntry 18 } + +mdadmArrayJournalMode OBJECT-TYPE + SYNTAX MdadmJournalMode + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "RAID-5/6 journal (cache) write mode when a journal device is + present. writeThrough(0) commits data to member disks before + acknowledging the host write; writeBack(1) acknowledges from + the journal device, giving lower latency at the cost of data + loss if the journal device fails. This object is only + meaningful when a journal device is configured. + Linux: /sys/block//md/journal_mode" + ::= { mdadmArrayHealthEntry 19 } + +-- -------------------------------------------------------------------------- +-- Section 4: Array Sync Table (.8 RowCount, .9 LastChange, .10 Table) +-- -------------------------------------------------------------------------- + +mdadmArraySyncTableRowCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION "Current number of rows in mdadmArraySyncTable." + ::= { mdadmObjects 8 } + +mdadmArraySyncTableLastChange OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "ISO 8601 timestamp of the most recent row addition, removal, + or material content change in mdadmArraySyncTable." + ::= { mdadmObjects 9 } + +mdadmArraySyncTable OBJECT-TYPE + SYNTAX SEQUENCE OF MdadmArraySyncEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION + "Array synchronisation, rebuild, and check progress. One row + per array. Rows are always present; mdadmArraySyncAction is + idle(0) when no operation is in progress." + ::= { mdadmObjects 10 } + +mdadmArraySyncEntry OBJECT-TYPE + SYNTAX MdadmArraySyncEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION "Synchronisation state for one mdadm array." + INDEX { mdadmArrayIndex } + ::= { mdadmArraySyncTable 1 } + +MdadmArraySyncEntry ::= SEQUENCE { + mdadmArraySyncAction MdadmSyncAction, + mdadmArraySyncLastAction MdadmSyncAction, + mdadmArraySyncCompletedCentipct Unsigned32, + mdadmArraySyncDoneBytes CounterBasedGauge64, + mdadmArraySyncTotalBytes CounterBasedGauge64, + mdadmArraySyncSpeedBps CounterBasedGauge64, + mdadmArraySyncSpeedMinBps CounterBasedGauge64, + mdadmArraySyncSpeedMaxBps CounterBasedGauge64, + mdadmArraySyncMin CounterBasedGauge64, + mdadmArraySyncMax CounterBasedGauge64 +} + +mdadmArraySyncAction OBJECT-TYPE + SYNTAX MdadmSyncAction + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Current synchronisation action. idle(0) when no operation is + in progress. + Linux: /sys/block//md/sync_action" + ::= { mdadmArraySyncEntry 1 } + +mdadmArraySyncLastAction OBJECT-TYPE + SYNTAX MdadmSyncAction + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Most recently completed synchronisation action." + ::= { mdadmArraySyncEntry 2 } + +mdadmArraySyncCompletedCentipct OBJECT-TYPE + SYNTAX Unsigned32 (0..10000) + UNITS "0.01 percent" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Percentage of the current synchronisation operation completed, + in units of 0.01 percent (divide by 100 for percent). Zero + when mdadmArraySyncAction is idle(0). Derived from + sync_completed as (done / total) x 10000. + Linux: /sys/block//md/sync_completed ('done/total' in + sectors)" + ::= { mdadmArraySyncEntry 3 } + +mdadmArraySyncDoneBytes OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "bytes" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Bytes processed so far in the current synchronisation. Zero + when mdadmArraySyncAction is idle(0). + Linux: /sys/block//md/sync_completed (done part, sectors + x 512)" + ::= { mdadmArraySyncEntry 4 } + +mdadmArraySyncTotalBytes OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "bytes" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Total bytes to process in the current synchronisation. Zero + when mdadmArraySyncAction is idle(0). + Linux: /sys/block//md/sync_completed (total part, + sectors x 512)" + ::= { mdadmArraySyncEntry 5 } + +mdadmArraySyncSpeedBps OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "bytes/second" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Current 30-second average synchronisation throughput. Zero + when mdadmArraySyncAction is idle(0). + Linux: /sys/block//md/sync_speed (K/sec x 1024)" + ::= { mdadmArraySyncEntry 6 } + +mdadmArraySyncSpeedMinBps OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "bytes/second" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Configured minimum synchronisation speed. Zero means the + system global minimum applies. + Linux: /sys/block//md/sync_speed_min (KiB/s x 1024); + 'system' defers to /sys/block/md/md/sync_speed_min" + ::= { mdadmArraySyncEntry 7 } + +mdadmArraySyncSpeedMaxBps OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "bytes/second" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Configured maximum synchronisation speed. Zero means the + system global maximum applies. + Linux: /sys/block//md/sync_speed_max (KiB/s x 1024); + 'system' defers to /sys/block/md/md/sync_speed_max" + ::= { mdadmArraySyncEntry 8 } + +mdadmArraySyncMin OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Start sector of the region to check or repair. Only the + portion of the array at or above this sector is examined + during a check or repair operation. Must be a multiple of + chunk_size. Zero means start from the beginning. + Linux: /sys/block//md/sync_min" + ::= { mdadmArraySyncEntry 9 } + +mdadmArraySyncMax OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "End sector of the region to check or repair. Only the + portion of the array below this sector is examined. The + implementation MUST set this to 18446744073709551615 + (0xFFFFFFFFFFFFFFFF) when the sysfs value is 'max', meaning + the entire array from mdadmArraySyncMin onward is in scope. + Linux: /sys/block//md/sync_max" + ::= { mdadmArraySyncEntry 10 } + +-- -------------------------------------------------------------------------- +-- Section 5: Device Metadata Table (.11 RowCount, .12 LastChange, .13 Table) +-- -------------------------------------------------------------------------- + +mdadmDeviceMetaTableRowCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION "Current number of rows in mdadmDeviceMetaTable." + ::= { mdadmObjects 11 } + +mdadmDeviceMetaTableLastChange OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "ISO 8601 timestamp of the most recent row addition or removal + in mdadmDeviceMetaTable." + ::= { mdadmObjects 12 } + +mdadmDeviceMetaTable OBJECT-TYPE + SYNTAX SEQUENCE OF MdadmDeviceMetaEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION + "Member device identity and static configuration. One row per + device per array. This table changes infrequently; + implementations SHOULD poll it on a long interval (e.g. + 8 hours)." + ::= { mdadmObjects 13 } + +mdadmDeviceMetaEntry OBJECT-TYPE + SYNTAX MdadmDeviceMetaEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION "Identity and configuration for one member device." + INDEX { mdadmArrayIndex, mdadmMemberIndex } + ::= { mdadmDeviceMetaTable 1 } + +MdadmDeviceMetaEntry ::= SEQUENCE { + mdadmMemberIndex Unsigned32, + mdadmMemberDeviceName DisplayString, + mdadmMemberDeviceUuid DisplayString, + mdadmMemberSlot Unsigned32, + mdadmMemberRole MdadmDeviceRole, + mdadmMemberIdModel DisplayString, + mdadmMemberIdSerial DisplayString, + mdadmMemberSizeBytes CounterBasedGauge64, + mdadmMemberOffsetBlocks CounterBasedGauge64, + mdadmMemberPplSector CounterBasedGauge64, + mdadmMemberPplSize CounterBasedGauge64 +} + +mdadmMemberIndex OBJECT-TYPE + SYNTAX Unsigned32 (1..4294967295) + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION + "Locally-assigned member device index within the array. The + agent derives this value as a 32-bit FNV-1a hash of the + concatenation serial_number|model_name; when both are + unavailable the device kernel name is used instead. Hash + collisions are resolved by linear probing. Managers SHOULD + use mdadmMemberDeviceUuid or (mdadmArrayUuid, mdadmMemberSlot) + as stable identifiers." + ::= { mdadmDeviceMetaEntry 1 } + +mdadmMemberDeviceName OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Kernel device name of the member, e.g. 'sda' or 'nvme0n1p3'. + Linux: device name part of /sys/block//md/dev-/ + symlink target" + ::= { mdadmDeviceMetaEntry 2 } + +mdadmMemberDeviceUuid OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Device UUID as recorded in the mdadm superblock on this device. + Linux: mdadm --examine /dev/ (not available via sysfs)" + ::= { mdadmDeviceMetaEntry 3 } + +mdadmMemberSlot OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Slot position of this device in the array (0-based). The + sentinel value 4294967295 (0xFFFFFFFF) is used when the slot + is not assigned, e.g. for spare devices not yet incorporated. + Linux: /sys/block//md/dev-/slot ('none' -> sentinel)" + ::= { mdadmDeviceMetaEntry 4 } + +mdadmMemberRole OBJECT-TYPE + SYNTAX MdadmDeviceRole + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Role of this device within the array. + Linux: derived from /sys/block//md/dev-/state" + ::= { mdadmDeviceMetaEntry 5 } + +mdadmMemberIdModel OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Drive model string, e.g. 'SAMSUNG_MZ7LH960HAJR-00005'. Empty + if unavailable. + Linux: udevadm info /dev/ (ID_MODEL attribute)" + ::= { mdadmDeviceMetaEntry 6 } + +mdadmMemberIdSerial OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Drive serial number. Empty if unavailable. + Linux: udevadm info /dev/ (ID_SERIAL_SHORT attribute)" + ::= { mdadmDeviceMetaEntry 7 } + +mdadmMemberSizeBytes OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "bytes" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Component size of this member in bytes. + Linux: /sys/block//md/dev-/size (1-KiB blocks) + x 1024" + ::= { mdadmDeviceMetaEntry 8 } + +mdadmMemberOffsetBlocks OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Sector offset on the physical device where the array component + begins. Data storage starts at this offset. + Linux: /sys/block//md/dev-/offset (sectors)" + ::= { mdadmDeviceMetaEntry 9 } + +mdadmMemberPplSector OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Sector offset of the Partial Parity Log (PPL) on this device. + PPL is used by RAID-5 with consistency_policy=ppl to reduce + resync time after unclean shutdown. Zero when PPL is not + present on this device. + Linux: /sys/block//md/dev-/ppl_sector" + ::= { mdadmDeviceMetaEntry 10 } + +mdadmMemberPplSize OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Allocated size of the Partial Parity Log on this device in + sectors. Zero when PPL is not present on this device. + Linux: /sys/block//md/dev-/ppl_size" + ::= { mdadmDeviceMetaEntry 11 } + +-- -------------------------------------------------------------------------- +-- Section 6: Device Health Table (.14 RowCount, .15 LastChange, .16 Table) +-- -------------------------------------------------------------------------- + +mdadmDeviceHealthTableRowCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION "Current number of rows in mdadmDeviceHealthTable." + ::= { mdadmObjects 14 } + +mdadmDeviceHealthTableLastChange OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "ISO 8601 timestamp of the most recent row addition, removal, + or material content change in mdadmDeviceHealthTable." + ::= { mdadmObjects 15 } + +mdadmDeviceHealthTable OBJECT-TYPE + SYNTAX SEQUENCE OF MdadmDeviceHealthEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION + "Member device health and error counters. One row per device + per array. Intended for frequent polling (e.g. 5-minute + interval)." + ::= { mdadmObjects 16 } + +mdadmDeviceHealthEntry OBJECT-TYPE + SYNTAX MdadmDeviceHealthEntry + MAX-ACCESS not-accessible + STATUS current + DESCRIPTION "Health and error counters for one member device." + INDEX { mdadmArrayIndex, mdadmMemberIndex } + ::= { mdadmDeviceHealthTable 1 } + +MdadmDeviceHealthEntry ::= SEQUENCE { + mdadmMemberState DisplayString, + mdadmMemberIsMissing TruthValue, + mdadmMemberErrors Unsigned32, + mdadmMemberEvents Unsigned32, + mdadmMemberRecoveryStartBlocks CounterBasedGauge64, + mdadmMemberBadBlockCount Unsigned32, + mdadmMemberUnackBadBlockCount Unsigned32 +} + +mdadmMemberState OBJECT-TYPE + SYNTAX DisplayString + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Raw comma-separated state flag string from the md sysfs state + file. Possible flags defined by the kernel: faulty, in_sync, + writemostly, blocked, spare, write_error, want_replacement, + replacement. Use mdadmMemberRole for a normalised enumeration. + Linux: /sys/block//md/dev-/state" + ::= { mdadmDeviceHealthEntry 1 } + +mdadmMemberIsMissing OBJECT-TYPE + SYNTAX TruthValue + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Whether this member device is missing from the array; i.e. the + slot is occupied in the array configuration but no block device + is present. + Linux: absence of /sys/block//md/dev-/block symlink" + ::= { mdadmDeviceHealthEntry 2 } + +mdadmMemberErrors OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Cumulative count of uncorrected read errors as tracked by the + md driver. The count is approximate; the kernel increments it + on each read error regardless of whether the sector was + eventually recovered. + Linux: /sys/block//md/dev-/errors" + ::= { mdadmDeviceHealthEntry 3 } + +mdadmMemberEvents OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Event counter from the mdadm superblock on this device. The + kernel increments the event counter on every array state + transition; devices with stale (low) event counts are treated + as out-of-date during assembly. + Linux: mdadm --examine /dev/ (not available via sysfs)" + ::= { mdadmDeviceHealthEntry 4 } + +mdadmMemberRecoveryStartBlocks OBJECT-TYPE + SYNTAX CounterBasedGauge64 + UNITS "sectors" + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Sector offset from which a rebuild of this device should + resume after an interruption. Sectors below this offset are + already known good. The implementation MUST set this to + 18446744073709551615 (0xFFFFFFFFFFFFFFFF) when the sysfs + value is 'none', meaning the device is fully in-sync. + Linux: /sys/block//md/dev-/recovery_start" + ::= { mdadmDeviceHealthEntry 5 } + +mdadmMemberBadBlockCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of bad-block ranges recorded in the bad-block log on + this device. A non-zero value means the device has sectors + that the md driver has given up trying to correct; the array + will avoid writing to those sectors where possible. + Linux: /sys/block//md/dev-/bad_blocks (range count)" + ::= { mdadmDeviceHealthEntry 6 } + +mdadmMemberUnackBadBlockCount OBJECT-TYPE + SYNTAX Unsigned32 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of bad-block ranges that have been detected but not yet + persistently written to disk. These bad blocks will be lost + if the system is powered off before they are saved. Non-zero + values require immediate attention. + Linux: /sys/block//md/dev-/unacknowledged_bad_blocks + (range count)" + ::= { mdadmDeviceHealthEntry 7 } + +-- -------------------------------------------------------------------------- +-- Notifications +-- -------------------------------------------------------------------------- + +mdadmArrayDegraded NOTIFICATION-TYPE + OBJECTS { + mdadmArrayName, + mdadmArrayState, + mdadmArrayDegradedCount + } + STATUS current + DESCRIPTION + "Generated when an array transitions to a degraded state, i.e. + when mdadmArrayDegradedCount increases from zero." + ::= { mdadmNotifications 1 } + +mdadmArrayFailed NOTIFICATION-TYPE + OBJECTS { + mdadmArrayName, + mdadmArrayState + } + STATUS current + DESCRIPTION + "Generated when an array transitions to the failed state." + ::= { mdadmNotifications 2 } + +mdadmArrayRecoveryStarted NOTIFICATION-TYPE + OBJECTS { + mdadmArrayName, + mdadmArraySyncAction + } + STATUS current + DESCRIPTION + "Generated when a synchronisation operation begins, i.e. when + mdadmArraySyncAction transitions away from idle(0)." + ::= { mdadmNotifications 3 } + +mdadmArrayRecoveryComplete NOTIFICATION-TYPE + OBJECTS { + mdadmArrayName, + mdadmArraySyncLastAction + } + STATUS current + DESCRIPTION + "Generated when a synchronisation operation completes, i.e. + when mdadmArraySyncAction returns to idle(0). + mdadmArraySyncLastAction identifies the completed operation." + ::= { mdadmNotifications 4 } + +mdadmMemberFailed NOTIFICATION-TYPE + OBJECTS { + mdadmArrayName, + mdadmMemberDeviceName, + mdadmMemberRole + } + STATUS current + DESCRIPTION + "Generated when a member device transitions to faulty role." + ::= { mdadmNotifications 5 } + +mdadmMemberMissing NOTIFICATION-TYPE + OBJECTS { + mdadmArrayName, + mdadmMemberDeviceName, + mdadmMemberIsMissing + } + STATUS current + DESCRIPTION + "Generated when a member device is no longer accessible." + ::= { mdadmNotifications 6 } + +mdadmMemberBadBlocks NOTIFICATION-TYPE + OBJECTS { + mdadmArrayName, + mdadmMemberDeviceName, + mdadmMemberBadBlockCount, + mdadmMemberUnackBadBlockCount + } + STATUS current + DESCRIPTION + "Generated when mdadmMemberBadBlockCount or + mdadmMemberUnackBadBlockCount increases." + ::= { mdadmNotifications 7 } + +-- -------------------------------------------------------------------------- +-- Conformance +-- -------------------------------------------------------------------------- + +mdadmCompliance MODULE-COMPLIANCE + STATUS current + DESCRIPTION "Compliance statement for MDADM-MIB." + MODULE + MANDATORY-GROUPS { + mdadmMetadataGroup, + mdadmArrayMetaGroup, + mdadmArrayHealthGroup, + mdadmArraySyncGroup, + mdadmDeviceMetaGroup, + mdadmDeviceHealthGroup + } + GROUP mdadmNotifGroup + DESCRIPTION + "Notification support is optional. Implementations that + generate SNMP notifications MUST implement this group." + ::= { mdadmCompliances 1 } + +mdadmMetadataGroup OBJECT-GROUP + OBJECTS { + mdadmLastUpdated, + mdadmVersion, + mdadmError, + mdadmErrorString, + mdadmArrayCount, + mdadmDegradedArrayCount, + mdadmSyncingArrayCount, + mdadmTotalMemberCount + } + STATUS current + DESCRIPTION "System-level metadata scalars." + ::= { mdadmGroups 1 } + +mdadmArrayMetaGroup OBJECT-GROUP + OBJECTS { + mdadmArrayMetaTableRowCount, + mdadmArrayMetaTableLastChange, + mdadmArrayUuid, + mdadmArrayName, + mdadmArrayRaidLevel, + mdadmArrayMetadataVersion, + mdadmArrayConsistencyPolicy, + mdadmArrayLayout, + mdadmArrayRaidDisks, + mdadmArraySizeBytes, + mdadmArraySizeBlocks, + mdadmArrayComponentSizeBlocks, + mdadmArrayChunkSize, + mdadmArrayBitmapType, + mdadmArrayBitmapLocation, + mdadmArrayBitmapChunksize, + mdadmArrayBitmapMetadata, + mdadmArrayLogicalBlockSize, + mdadmArrayResyncStart, + mdadmArrayReshapePosition, + mdadmArrayBitmapTimeBase, + mdadmArrayShortName, + mdadmArrayUris + } + STATUS current + DESCRIPTION "Array identity and configuration objects." + ::= { mdadmGroups 2 } + +mdadmArrayHealthGroup OBJECT-GROUP + OBJECTS { + mdadmArrayHealthTableRowCount, + mdadmArrayHealthTableLastChange, + mdadmArrayState, + mdadmArrayDegradedCount, + mdadmArrayActiveDevices, + mdadmArrayWorkingDevices, + mdadmArraySpareDevices, + mdadmArrayFailedDevices, + mdadmArrayMismatchCount, + mdadmArraySafeModeMilliseconds, + mdadmArrayIsMounted, + mdadmArrayMountPoints, + mdadmArrayIsSwap, + mdadmArrayBitmapBacklog, + mdadmArrayBitmapMaxBacklog, + mdadmArrayBitmapCanClear, + mdadmArraySuspendLo, + mdadmArraySuspendHi, + mdadmArrayStripeCacheSize, + mdadmArrayStripeCacheActive, + mdadmArrayJournalMode + } + STATUS current + DESCRIPTION "Array health and operational status objects." + ::= { mdadmGroups 3 } + +mdadmArraySyncGroup OBJECT-GROUP + OBJECTS { + mdadmArraySyncTableRowCount, + mdadmArraySyncTableLastChange, + mdadmArraySyncAction, + mdadmArraySyncLastAction, + mdadmArraySyncCompletedCentipct, + mdadmArraySyncDoneBytes, + mdadmArraySyncTotalBytes, + mdadmArraySyncSpeedBps, + mdadmArraySyncSpeedMinBps, + mdadmArraySyncSpeedMaxBps, + mdadmArraySyncMin, + mdadmArraySyncMax + } + STATUS current + DESCRIPTION "Array synchronisation and rebuild progress objects." + ::= { mdadmGroups 4 } + +mdadmDeviceMetaGroup OBJECT-GROUP + OBJECTS { + mdadmDeviceMetaTableRowCount, + mdadmDeviceMetaTableLastChange, + mdadmMemberDeviceName, + mdadmMemberDeviceUuid, + mdadmMemberSlot, + mdadmMemberRole, + mdadmMemberIdModel, + mdadmMemberIdSerial, + mdadmMemberSizeBytes, + mdadmMemberOffsetBlocks, + mdadmMemberPplSector, + mdadmMemberPplSize + } + STATUS current + DESCRIPTION "Member device identity and configuration objects." + ::= { mdadmGroups 5 } + +mdadmDeviceHealthGroup OBJECT-GROUP + OBJECTS { + mdadmDeviceHealthTableRowCount, + mdadmDeviceHealthTableLastChange, + mdadmMemberState, + mdadmMemberIsMissing, + mdadmMemberErrors, + mdadmMemberEvents, + mdadmMemberRecoveryStartBlocks, + mdadmMemberBadBlockCount, + mdadmMemberUnackBadBlockCount + } + STATUS current + DESCRIPTION "Member device health and error counter objects." + ::= { mdadmGroups 6 } + +mdadmNotifGroup NOTIFICATION-GROUP + NOTIFICATIONS { + mdadmArrayDegraded, + mdadmArrayFailed, + mdadmArrayRecoveryStarted, + mdadmArrayRecoveryComplete, + mdadmMemberFailed, + mdadmMemberMissing, + mdadmMemberBadBlocks + } + STATUS current + DESCRIPTION "Array and member device event notifications." + ::= { mdadmGroups 7 } + +END diff --git a/snmp/mdadm/mdadm b/snmp/mdadm/mdadm new file mode 100755 index 000000000..1efcd391c --- /dev/null +++ b/snmp/mdadm/mdadm @@ -0,0 +1,1312 @@ +#!/usr/bin/env python3 +# (c) 2026, LibreNMS +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# Self-contained net-snmp pass_persist agent for MDADM-MIB +# +# Collects Linux software RAID status directly from sysfs and mdadm(8), +# and serves all MDADM-MIB objects via the SNMP pass_persist protocol. +# No cache file, cron job, or systemd timer is needed. +# +# snmpd.conf example: +# pass_persist .1.3.6.1.4.1.60652.101 /usr/local/lib/snmpd/mdadm +# +# Data is refreshed in-process at most once per --ttl seconds (default 60). +# +# OID structure (MDADM-MIB, enterprise 1.3.6.1.4.1.60652.101): +# .1.1.* metadata scalars +# .1.2/.1.3 array-meta RowCount / LastChange +# .1.4.1.. array meta table (22 cols) +# .1.5/.1.6 array-health RowCount / LastChange +# .1.7.1.. array health table (19 cols) +# .1.8/.1.9 array-sync RowCount / LastChange +# .1.10.1.. array sync table (10 cols) +# .1.11/.1.12 device-meta RowCount / LastChange +# .1.13.1... device meta table (11 cols, dual-indexed) +# .1.14/.1.15 device-health RowCount / LastChange +# .1.16.1... device health table (10 cols, dual-indexed) + +import argparse +import bisect +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone +from typing import Optional + +# -------------------------------------------------------------------------- +# Versioning and logging +# -------------------------------------------------------------------------- + +VERSION = 3 + +VERBOSE = 15 +NOTICE = 25 +logging.addLevelName(VERBOSE, "VERBOSE") +logging.addLevelName(NOTICE, "NOTICE") + + +def _verbose(self, message, *args, **kws): + if self.isEnabledFor(VERBOSE): + self._log(VERBOSE, message, args, **kws) + + +def _notice(self, message, *args, **kws): + if self.isEnabledFor(NOTICE): + self._log(NOTICE, message, args, **kws) + + +logging.Logger.verbose = _verbose +logging.Logger.notice = _notice + +LOGGER = logging.getLogger("mdadm") + +# -------------------------------------------------------------------------- +# SNMP constants +# -------------------------------------------------------------------------- + +BASE_OID = (1, 3, 6, 1, 4, 1, 60652, 101) +CACHE_TTL = 60 + +UINT64_MAX = 18446744073709551615 # sentinel for "none" sysfs values +UINT32_MAX = 4294967295 # sentinel for unassigned slot + +# -------------------------------------------------------------------------- +# Collection error codes (served in-band via mdadmError / mdadmErrorString) +# +# This is a pass_persist agent: snmpd keeps the process resident, so a process +# exit code never reaches LibreNMS - it would just respawn us. Instead these +# codes are published as the mdadmError scalar (.1.1.3) and drive what the +# agent serves: +# +# * "cleanup" codes -> serve empty tables, so LibreNMS prunes stale sensors +# * "skip" codes -> preserve the last good data, only flag the error +# (transient: sensors are kept) +# +# EXIT_OUTPUT_WRITE_FAILURE (4) from the one-shot script convention has no +# analogue here - there is no output file in pass_persist mode - so it is +# intentionally omitted. +# -------------------------------------------------------------------------- + +EXIT_SUCCESS = 0 # all arrays collected cleanly +EXIT_DEPENDENCY_MISSING = 1 # mdadm binary not in $PATH (cleanup) +EXIT_NO_ARRAYS = 2 # auto-discovery found no arrays (cleanup) +EXIT_PERMISSION_DENIED = 3 # /sys/block unreadable (skip) +EXIT_CONFIG_ERROR = 5 # configured device missing name (skip) +EXIT_PARTIAL_FAILURE = 6 # some arrays had read errors (data served) +EXIT_NO_CONFIGURED_DEVICES = 7 # configured devices, none in sysfs(cleanup) + +# Codes that mean "no usable data" - serve empty tables so LibreNMS removes +# any sensors and DB records left from a previous run. +_CLEANUP_CODES = frozenset(( + EXIT_DEPENDENCY_MISSING, + EXIT_NO_ARRAYS, + EXIT_NO_CONFIGURED_DEVICES, +)) + + +class CollectionError(Exception): + """A collection failure that maps to an mdadmError code.""" + + def __init__(self, code: int, message: str): + super().__init__(message) + self.code = code + self.message = message + +# -------------------------------------------------------------------------- +# Textual Convention enum maps (MDADM-MIB) +# -------------------------------------------------------------------------- + +_RAID_LEVEL = { + "linear": 1, "raid0": 2, "raid1": 3, "raid4": 4, + "raid5": 5, "raid6": 6, "raid10": 10, +} + +_ARRAY_STATE = { + "clear": 1, "inactive": 2, "suspended": 3, "readonly": 4, + "read-auto": 5, "clean": 6, "active": 7, + "write-pending": 8, "active-idle": 9, "degraded": 10, "failed": 11, +} + +_SYNC_ACTION = { + "idle": 0, "resync": 1, "recover": 2, "check": 3, + "repair": 4, "reshape": 5, +} + +_DEVICE_ROLE = { + "unknown": 0, "active": 1, "spare": 2, "faulty": 3, + "missing": 4, "write-mostly": 5, "writemostly": 5, "replacement": 6, + "failed": 3, +} + +_CONSISTENCY_POLICY = { + "resync": 1, "journal": 2, "ppl": 3, "none": 4, "bitmap": 5, +} + +# ========================================================================== +# Part 1 - Data collection (sysfs + mdadm commands) +# ========================================================================== + +def _run(argv): + LOGGER.debug("run: %s", " ".join(argv)) + try: + proc = subprocess.run(argv, capture_output=True, text=True) + except FileNotFoundError: + return 127, "", "command not found", None + LOGGER.debug("rc=%s", proc.returncode) + if proc.stdout: + LOGGER.debug("stdout: %s", proc.stdout[:500]) + if proc.stderr: + LOGGER.debug("stderr: %s", proc.stderr[:200]) + return proc.returncode, (proc.stdout or ""), (proc.stderr or ""), proc + + +# Actionable message surfaced (via mdadmErrorString) when the agent can run +# neither `sudo mdadm --detail` nor `sudo mdadm -E`. Arrays are still served +# from sysfs, so this is a partial - not fatal - condition. +_SUDO_DENIED_MSG = ("sudo mdadm access denied - install the sudoers rule " + "(see sudoers.d-mdadm); array/device enrichment skipped") + +# stderr fragments that mean "sudo refused", as opposed to a genuine mdadm error. +_SUDO_DENIED_MARKERS = ( + "a password is required", + "is not allowed to execute", + "no tty present", + "a terminal is required", + "may not run sudo", + "command not allowed", +) + + +def _is_sudo_denied(rc: int, stderr: str) -> bool: + """True when a `sudo` invocation failed because access was refused (or the + sudo binary is absent), rather than because the wrapped command errored.""" + if rc == 0: + return False + s = (stderr or "").lower() + if "command not found" in s: # sudo binary itself is missing + return True + return "sudo:" in s or any(m in s for m in _SUDO_DENIED_MARKERS) + + +def _read_file(path: str) -> Optional[str]: + try: + with open(path) as f: + return f.read().strip() + except FileNotFoundError: + return None # expected for optional sysfs nodes - not worth logging + except (PermissionError, OSError) as exc: + LOGGER.warning("read error %s: %s", path, exc) + return None + + +def _maybe_get(path: str) -> str: + v = _read_file(path) + return "none" if v is None or v == "none" else v + + +def _maybe_get_int(path: str, default: int = 0) -> int: + v = _read_file(path) + if v is None or v == "none": + return default + try: + return int(v) + except ValueError: + return default + + +def _maybe_get_float(path: str, default: float = 0.0) -> float: + v = _read_file(path) + if v is None or v == "none": + return default + try: + return float(v) + except ValueError: + return default + + +def _maybe_get_int64_or_none(path: str) -> Optional[int]: + v = _read_file(path) + if v is None or v == "none": + return None + try: + iv = int(v) + return None if iv == UINT64_MAX else iv + except ValueError: + return None + + +def _uuid_normalize(s: str) -> str: + return s.strip().replace(" ", "-").replace(":", "-") + + +def _parse_bool(value: str) -> Optional[bool]: + v = value.strip().lower() + if v in ("true", "yes", "1", "on"): + return True + if v in ("false", "no", "0", "off"): + return False + return None + + +def _get_udev_info(device_name: str) -> dict: + rc, stdout, _, _ = _run([ + "udevadm", "info", "--query=property", + "--property=ID_SERIAL_SHORT,ID_MODEL", + f"/dev/{device_name}", + ]) + if rc != 0 or not stdout: + LOGGER.debug("udevadm: no info for %s (rc=%s)", device_name, rc) + return {} + result = {} + for line in stdout.splitlines(): + if "=" in line: + key, _, val = line.partition("=") + if key == "ID_SERIAL_SHORT": + result["id_serial_short"] = val.strip() + elif key == "ID_MODEL": + result["id_model"] = val.strip() + LOGGER.debug("udevadm %s: model=%r serial=%r", + device_name, result.get("id_model"), result.get("id_serial_short")) + return result + + +def _discover_arrays(config_devices=None): + if config_devices: + found = [] + for dev in config_devices: + if isinstance(dev, dict): + name = dev.get("name") + if not name: + raise CollectionError( + EXIT_CONFIG_ERROR, + "configured device entry is missing the 'name' field") + else: + name = str(dev) + if os.path.isdir(f"/sys/block/{name}/md"): + found.append(name) + else: + LOGGER.warning("configured device %s not found in sysfs", name) + if not found: + raise CollectionError( + EXIT_NO_CONFIGURED_DEVICES, + "configured devices listed but none exist in sysfs") + LOGGER.debug("discover (config): %s", found) + return sorted(found) + + arrays = [] + try: + entries = os.listdir("/sys/block") + except (PermissionError, OSError) as exc: + raise CollectionError( + EXIT_PERMISSION_DENIED, f"/sys/block is unreadable: {exc}") + for entry in entries: + if os.path.isdir(f"/sys/block/{entry}/md"): + arrays.append(entry) + if not arrays: + raise CollectionError(EXIT_NO_ARRAYS, "no mdadm arrays found in sysfs") + LOGGER.debug("discover (auto): %s", sorted(arrays)) + return sorted(arrays) + + +def _read_sysfs_array(md_name: str) -> dict: + LOGGER.debug("reading sysfs for %s", md_name) + base = f"/sys/block/{md_name}/md" + bm = f"{base}/bitmap" + size_s = _maybe_get_int(f"/sys/block/{md_name}/size") + result = { + "name": md_name, + "uuid": _maybe_get(f"{base}/uuid"), + "level": _maybe_get(f"{base}/level"), + "array_state": _maybe_get(f"{base}/array_state"), + "sync_action": _maybe_get(f"{base}/sync_action"), + "last_sync_action": _maybe_get(f"{base}/last_sync_action"), + "sync_completed_raw": _maybe_get(f"{base}/sync_completed"), + "sync_speed_raw": _maybe_get(f"{base}/sync_speed"), + "sync_speed_min_raw": _maybe_get(f"{base}/sync_speed_min"), + "sync_speed_max_raw": _maybe_get(f"{base}/sync_speed_max"), + "sync_min": _maybe_get(f"{base}/sync_min"), + "sync_max": _maybe_get(f"{base}/sync_max"), + "raid_disks": _maybe_get_int(f"{base}/raid_disks"), + "degraded": _maybe_get_int(f"{base}/degraded"), + "mismatch_cnt": _maybe_get_int(f"{base}/mismatch_cnt"), + "metadata_version": _maybe_get(f"{base}/metadata_version"), + "consistency_policy": _maybe_get(f"{base}/consistency_policy"), + "component_size": _maybe_get_int(f"{base}/component_size"), + "chunk_size": _maybe_get_int(f"{base}/chunk_size"), + "layout": _maybe_get_int(f"{base}/layout"), + "safe_mode_delay": _maybe_get_float(f"{base}/safe_mode_delay"), + "bitmap_type": _maybe_get(f"{base}/bitmap_type"), + "size_blocks": size_s, + "size_bytes": size_s * 512 if size_s else 0, + "bitmap": { + "location": _maybe_get(f"{bm}/location"), + "chunksize": _maybe_get_int(f"{bm}/chunksize"), + "time_base": _maybe_get_int(f"{bm}/time_base"), + "backlog": _maybe_get_int(f"{bm}/backlog"), + "max_backlog_used":_maybe_get_int(f"{bm}/max_backlog_used"), + "metadata": _maybe_get(f"{bm}/metadata"), + "space": _maybe_get_int(f"{bm}/space"), + "can_clear": _maybe_get(f"{bm}/can_clear"), + }, + } + LOGGER.debug("%s sysfs: level=%s state=%s degraded=%s raid_disks=%s size_bytes=%s", + md_name, result["level"], result["array_state"], + result["degraded"], result["raid_disks"], result["size_bytes"]) + return result + + +def _read_sysfs_queue(md_name: str) -> dict: + base = f"/sys/block/{md_name}/queue" + return { + "logical_block_size": _maybe_get_int(f"{base}/logical_block_size"), + "physical_block_size":_maybe_get_int(f"{base}/physical_block_size"), + "hw_sector_size": _maybe_get_int(f"{base}/hw_sector_size"), + "max_sectors_kb": _maybe_get_int(f"{base}/max_sectors_kb"), + "rotational": bool(_maybe_get_int(f"{base}/rotational")), + "write_cache": _maybe_get(f"{base}/write_cache"), + } + + +def _read_sysfs_devices(md_name: str) -> dict: + base = f"/sys/block/{md_name}/md" + devices = {} + try: + entries = os.listdir(base) + except (PermissionError, OSError) as exc: + LOGGER.debug("listdir %s failed: %s", base, exc) + return devices + + for entry in entries: + if not entry.startswith("dev-"): + continue + dev_name = entry[4:] + dev_base = os.path.join(base, entry) + state_raw = _read_file(f"{dev_base}/state") or "" + state_flags = [s.strip() for s in state_raw.split(",") if s.strip()] + slot_raw = _read_file(f"{dev_base}/slot") or "" + slot = int(slot_raw) if slot_raw and slot_raw != "none" else None + size_kib = _maybe_get_int(f"{dev_base}/size") + devices[entry] = { + "device_name": dev_name, + "slot": slot, + "state": state_raw.replace(",", " ").strip() or "unknown", + "state_flags": state_flags, + "errors": _maybe_get_int(f"{dev_base}/errors"), + "size_blocks": size_kib, + "size_bytes": size_kib * 1024, + "offset_blocks": _maybe_get_int(f"{dev_base}/offset"), + "recovery_start_blocks": _maybe_get_int64_or_none(f"{dev_base}/recovery_start"), + "is_missing": False, + "udev": _get_udev_info(dev_name), + } + LOGGER.debug("%s device %s: slot=%s state=%s errors=%s size_bytes=%s", + md_name, dev_name, slot, state_raw or "unknown", + devices[entry]["errors"], devices[entry]["size_bytes"]) + return devices + + +def _normalize_sync_completed(raw: str, action: str, degraded: int) -> float: + if "/" in raw: + try: + done, total = raw.split("/", 1) + total_f = float(total) + return round(100.0 * float(done) / total_f, 2) if total_f > 0 else 0.0 + except (ValueError, ZeroDivisionError): + return 0.0 + if raw == "none": + return 100.0 if action == "idle" and degraded == 0 else 0.0 + try: + val = float(raw) + return 100.0 if val == 0 and action == "idle" and degraded == 0 else round(val, 2) + except ValueError: + return 0.0 + + +def _sync_done_total_bytes(raw: str): + if "/" not in raw: + return 0, 0 + try: + done_s, total_s = raw.split("/", 1) + return int(done_s.strip()) * 512, int(total_s.strip()) * 512 + except (ValueError, IndexError): + return 0, 0 + + +def _normalize_sync_speed(raw: str) -> int: + if not raw or raw == "none": + return 0 + m = re.match(r"^\s*(\d+)", raw) + if m: + return int(m.group(1)) * 1024 + try: + return int(float(raw)) + except ValueError: + return 0 + + +def _parse_mdadm_detail(md_name: str): + """Returns (data, error_message). On success error_message is None; on + failure data is None and error_message describes why (sudo denial is + reported with an actionable, host-wide message).""" + LOGGER.debug("running mdadm --detail /dev/%s", md_name) + rc, stdout, stderr, _ = _run(["sudo", "-n", "mdadm", "--detail", f"/dev/{md_name}"]) + if rc != 0: + if _is_sudo_denied(rc, stderr): + LOGGER.warning("sudo mdadm --detail denied for %s: %s", + md_name, stderr.strip()[:200]) + return None, _SUDO_DENIED_MSG + LOGGER.warning("mdadm --detail failed for %s: %s", md_name, stderr[:200]) + return None, "mdadm --detail failed" + + data: dict = { + "uuid": None, "array_name": None, "raid_level": None, + "active_devices": None, "working_devices": None, + "failed_devices": None, "spare_devices": None, + "state": None, "read_errors": [], + } + + for line in stdout.splitlines(): + line = line.strip() + if not line or ":" not in line: + continue + key, _, value = line.partition(":") + key, value = key.strip(), value.strip() + if key == "Name": + nv = value.split("(")[0].strip() + data["array_name"] = nv.split(":", 1)[-1].strip() if ":" in nv else nv + elif key == "Raid Level": + data["raid_level"] = value + elif key == "State": + data["state"] = value + elif key == "UUID": + data["uuid"] = _uuid_normalize(value) + elif key == "Active Devices": + try: data["active_devices"] = int(value) + except ValueError: pass + elif key == "Working Devices": + try: data["working_devices"] = int(value) + except ValueError: pass + elif key == "Failed Devices": + try: data["failed_devices"] = int(value) + except ValueError: pass + elif key == "Spare Devices": + try: data["spare_devices"] = int(value) + except ValueError: pass + + LOGGER.debug("mdadm --detail %s: state=%r active=%s working=%s failed=%s spare=%s", + md_name, data["state"], data["active_devices"], data["working_devices"], + data["failed_devices"], data["spare_devices"]) + return data, None + + +def _parse_mdadm_examine(member_path: str): + """Returns (data, sudo_denied). data is None when no superblock is readable; + sudo_denied is True specifically when sudo refused the call.""" + LOGGER.debug("running mdadm -E %s", member_path) + rc, stdout, stderr, _ = _run(["sudo", "-n", "mdadm", "-E", member_path]) + if rc != 0: + denied = _is_sudo_denied(rc, stderr) + LOGGER.debug("mdadm -E %s: rc=%s denied=%s (no superblock or permission denied)", + member_path, rc, denied) + return None, denied + data: dict = {"device_uuid": None, "events": None} + for line in stdout.splitlines(): + if ":" not in line: + continue + key, _, value = line.strip().partition(":") + key, value = key.strip(), value.strip() + if key == "Device UUID": + data["device_uuid"] = _uuid_normalize(value) + elif key == "Events": + try: data["events"] = int(value) + except ValueError: pass + LOGGER.debug("mdadm -E %s: device_uuid=%r events=%s", + member_path, data["device_uuid"], data["events"]) + return data, False + + +def _collect_uris(md_name: str) -> list: + uris = [f"/dev/{md_name}"] + target = os.path.realpath(f"/dev/{md_name}") + by_id = "/dev/disk/by-id" + try: + for entry in sorted(os.listdir(by_id)): + link = os.path.join(by_id, entry) + try: + if os.path.realpath(link) == target: + uris.append(link) + except OSError: + pass + except OSError: + pass + LOGGER.debug("%s uris: %s", md_name, uris) + return uris + + +def _read_array_usage(md_name: str) -> dict: + expected = {f"/dev/{md_name}", os.path.realpath(f"/dev/{md_name}")} + mounts, swaps = [], [] + try: + with open("/proc/mounts") as f: + for line in f: + parts = line.split() + if len(parts) >= 2 and os.path.realpath(parts[0]) in expected: + mounts.append(parts[1]) + except OSError: + pass + try: + with open("/proc/swaps") as f: + for i, line in enumerate(f): + if i == 0: + continue + parts = line.split() + if parts and os.path.realpath(parts[0]) in expected: + swaps.append(parts[0]) + except OSError: + pass + mounts = sorted(set(mounts)) + swaps = sorted(set(swaps)) + LOGGER.debug("%s usage: mounts=%s swaps=%s", md_name, mounts, swaps) + return { + "mount": {"is_mounted": bool(mounts), "mount_points": mounts}, + "swap": {"is_enabled": bool(swaps), "devices": swaps}, + } + + +def _derive_device_role(dev_entry: dict) -> str: + flags = set(dev_entry.get("state_flags", [])) + if dev_entry.get("is_missing"): + role = "missing" + elif "faulty" in flags or "blocked" in flags: + role = "faulty" + elif "spare" in flags: + role = "spare" + elif "in_sync" in flags: + role = "active" + else: + state = (dev_entry.get("state") or "").lower() + role = "active" if "active" in state or "sync" in state else "unknown" + LOGGER.debug("device %s: flags=%s -> role=%s", + dev_entry.get("device_name", "?"), flags, role) + return role + + +def _collect_arrays(config_devices=None) -> dict: + arrays = {} + sudo_denied = False # host-wide: once sudo refuses, skip further attempts + for md_name in _discover_arrays(config_devices=config_devices): + LOGGER.notice("processing array %s", md_name) + + sysfs = _read_sysfs_array(md_name) + sysdevs = _read_sysfs_devices(md_name) + usage = _read_array_usage(md_name) + queue = _read_sysfs_queue(md_name) + if sudo_denied: + detail, detail_err = None, _SUDO_DENIED_MSG + else: + detail, detail_err = _parse_mdadm_detail(md_name) + if detail_err is _SUDO_DENIED_MSG: + sudo_denied = True + + sync_done, sync_total = _sync_done_total_bytes(sysfs.get("sync_completed_raw", "none")) + bitmap = sysfs.get("bitmap", {}).copy() + cc = bitmap.get("can_clear") + parsed_cc = _parse_bool(cc) if isinstance(cc, str) else None + if parsed_cc is not None: + bitmap["can_clear"] = parsed_cc + + chunk = sysfs.get("chunk_size") or 0 + + arr = { + "name": md_name, + "array_name": None, + "uuid": sysfs.get("uuid", "none"), + "raid_level": sysfs.get("level", "none"), + "state": sysfs.get("array_state", "unknown"), + "degraded": sysfs.get("degraded", 0), + "raid_disks": sysfs.get("raid_disks", 0), + "active_devices": 0, + "working_devices": 0, + "failed_devices": 0, + "spare_devices": 0, + "size_bytes": sysfs.get("size_bytes", 0), + "array_size_blocks": sysfs.get("size_blocks", 0), + "metadata_version": sysfs.get("metadata_version", "unknown"), + "consistency_policy": sysfs.get("consistency_policy", "unknown"), + "mismatch_cnt": sysfs.get("mismatch_cnt", 0), + "chunk_size": chunk, + "layout": sysfs.get("layout", 0), + "safe_mode_delay_sec":sysfs.get("safe_mode_delay", 0.0), + "bitmap_type": sysfs.get("bitmap_type"), + "bitmap": bitmap, + "sync": { + "action": sysfs.get("sync_action", "idle"), + "last_action": sysfs.get("last_sync_action", "idle"), + "completed_pct":_normalize_sync_completed( + sysfs.get("sync_completed_raw", "none"), + sysfs.get("sync_action", "idle"), + sysfs.get("degraded", 0)), + "done_bytes": sync_done, + "total_bytes": sync_total, + "speed_bps": _normalize_sync_speed(sysfs.get("sync_speed_raw", "none")), + "speed_min_bps":_normalize_sync_speed(sysfs.get("sync_speed_min_raw", "none")), + "speed_max_bps":_normalize_sync_speed(sysfs.get("sync_speed_max_raw", "none")), + "min": sysfs.get("sync_min", "0"), + "max": sysfs.get("sync_max", "max"), + }, + "read_errors": [], + "queue": queue, + "usage": usage, + "uris": _collect_uris(md_name), + } + + if detail: + if detail.get("uuid"): arr["uuid"] = detail["uuid"] + if detail.get("array_name"): arr["array_name"] = detail["array_name"] + if detail.get("raid_level"): arr["raid_level"] = detail["raid_level"] + if detail.get("active_devices") is not None: arr["active_devices"] = detail["active_devices"] + if detail.get("working_devices") is not None: arr["working_devices"] = detail["working_devices"] + if detail.get("failed_devices") is not None: arr["failed_devices"] = detail["failed_devices"] + if detail.get("spare_devices") is not None: arr["spare_devices"] = detail["spare_devices"] + if detail.get("state") and arr["state"] == "unknown": + arr["state"] = detail["state"] + elif detail_err: + arr["read_errors"].append(detail_err) + + # Derive device counts from sysfs if mdadm --detail didn't supply them + if arr["active_devices"] == 0 and arr["working_devices"] == 0 and sysdevs: + for dv in sysdevs.values(): + flags = set(dv.get("state_flags", [])) + if "faulty" in flags or "blocked" in flags: + arr["failed_devices"] += 1 + elif "spare" in flags: + arr["spare_devices"] += 1 + elif "in_sync" in flags or "active" in (dv.get("state") or "").lower(): + arr["active_devices"] += 1 + arr["working_devices"] = arr["active_devices"] + arr["spare_devices"] + LOGGER.debug("%s: derived device counts from sysfs: active=%d working=%d spare=%d failed=%d", + md_name, arr["active_devices"], arr["working_devices"], + arr["spare_devices"], arr["failed_devices"]) + + # Build devices section + devices = {} + for dev_key, dv in sysdevs.items(): + entry = { + "device_name": dv["device_name"], + "slot": dv.get("slot"), + "state": dv.get("state", "unknown"), + "state_flags": dv.get("state_flags", []), + "errors": dv.get("errors", 0), + "size_blocks": dv.get("size_blocks"), + "size_bytes": dv.get("size_bytes"), + "offset_blocks": dv.get("offset_blocks"), + "recovery_start_blocks": dv.get("recovery_start_blocks"), + "is_missing": dv.get("is_missing", False), + "id_serial_short": None, + "id_model": None, + "device_uuid": None, + "events": None, + "read_errors": [], + } + entry["device_role"] = _derive_device_role(entry) + udev = dv.get("udev", {}) + entry["id_serial_short"] = udev.get("id_serial_short") + entry["id_model"] = udev.get("id_model") + + # Enrich with mdadm -E (superblock info), unless sudo already refused + if not sudo_denied: + examine, denied = _parse_mdadm_examine(f"/dev/{dv['device_name']}") + if denied: + sudo_denied = True + if _SUDO_DENIED_MSG not in arr["read_errors"]: + arr["read_errors"].append(_SUDO_DENIED_MSG) + elif examine: + if examine.get("device_uuid"): entry["device_uuid"] = examine["device_uuid"] + if examine.get("events") is not None: entry["events"] = examine["events"] + + devices[dev_key] = entry + + uuid = arr.get("uuid", "none") + if not uuid or uuid == "none": + uuid = md_name + arrays[uuid] = {"array": arr, "devices": devices} + + return arrays + + +def _build_counters(arrays: dict) -> dict: + counters = { + "arrays": len(arrays), + "devices_total": sum(len(v["devices"]) for v in arrays.values()), + "degraded_arrays": sum(1 for v in arrays.values() if v["array"].get("degraded", 0) > 0), + "arrays_syncing": sum( + 1 for v in arrays.values() + if v["array"].get("sync", {}).get("action", "idle") not in ("idle", "check") + ), + } + LOGGER.debug("counters: %s", counters) + return counters + + +def _classify_partial(arrays: dict) -> tuple: + """Scan collected arrays for non-fatal read errors. + + Returns (EXIT_PARTIAL_FAILURE, message) when any array or member device + recorded a read error - the data is still served - otherwise + (EXIT_SUCCESS, "").""" + msgs = [] + seen = set() + + def _add(msg): + if msg not in seen: + seen.add(msg) + msgs.append(msg) + + # Host-wide sudo denial is reported once, not once per array. + if any(_SUDO_DENIED_MSG in a.get("array", {}).get("read_errors", []) + for a in arrays.values()): + _add(_SUDO_DENIED_MSG) + + for uuid, adata in arrays.items(): + arr = adata.get("array", {}) + name = arr.get("name", uuid) + for err in arr.get("read_errors", []): + if err is _SUDO_DENIED_MSG: + continue + _add(f"{name}: {err}") + for dkey, dev in adata.get("devices", {}).items(): + for err in dev.get("read_errors", []): + _add(f"{name}/{dev.get('device_name', dkey)}: {err}") + if msgs: + summary = "; ".join(msgs[:5]) + if len(msgs) > 5: + summary += f"; (+{len(msgs) - 5} more)" + return EXIT_PARTIAL_FAILURE, summary + return EXIT_SUCCESS, "" + +# ========================================================================== +# Part 2 - SNMP type helpers +# ========================================================================== + +def _truthvalue(b) -> tuple: + return ("integer", "1" if b else "2") + +def _gauge(v) -> tuple: + return ("gauge", str(int(v) if v is not None else 0)) + +def _counter64(v) -> tuple: + return ("counter64", str(int(v) if v is not None else 0)) + +def _string(v) -> tuple: + return ("string", "" if v is None else str(v)) + +def _integer(v) -> tuple: + return ("integer", str(int(v) if v is not None else 0)) + +def _datetimeval(dt: datetime) -> tuple: + # DisplayString ISO 8601 - pass_persist string type is reliable for text. + # hex-string OctetString for DateAndTime TC is silently dropped by some + # net-snmp versions before the value reaches the requesting client. + return ("string", dt.isoformat()) + +def _map(table: dict, s) -> int: + return table.get(str(s).lower().strip(), 0) + +def _fnv1a_32(data: bytes) -> int: + """32-bit FNV-1a hash of a byte string; always returns a value in 1..4294967294.""" + h = 0x811c9dc5 + for b in data: + h ^= b + h = (h * 0x01000193) & 0xFFFFFFFF + return h or 1 # 0 is not a valid table index + + +def _probe_index(seed: int, used: set) -> int: + """Linear-probe for a free slot starting from seed.""" + idx = seed or 1 + while idx in used: + idx = (idx % (UINT32_MAX - 1)) + 1 + return idx + + +def _map_bitmap_type(arr: dict) -> int: + bitmap = arr.get("bitmap", {}) + if not bitmap or bitmap.get("location", "none") == "none": + return 0 + raw = str(arr.get("bitmap_type", "")).lower() + if "llbitmap" in raw: + return 3 + return 2 if str(bitmap.get("metadata", "")).lower() == "external" else 1 + +# RAID levels with no parity/copy layout concept -> notApplicable(255) +_LAYOUT_NA = 255 +_LAYOUT_LESS_LEVELS = {"linear", "raid0", "raid1"} + + +def _map_layout(arr: dict) -> int: + level = str(arr.get("raid_level", "")).lower().strip() + if level in _LAYOUT_LESS_LEVELS: + return _LAYOUT_NA + return int(arr.get("layout", 0) or 0) + + +def _sync_sector(val) -> int: + if val is None: + return UINT64_MAX + s = str(val).strip().lower() + if s in ("max", "none", ""): + return UINT64_MAX + try: + return int(s) + except ValueError: + return UINT64_MAX + +def _dev_flags(dev: dict) -> set: + raw = dev.get("state_flags", []) + if isinstance(raw, str): + return {f.strip() for f in raw.split(",") if f.strip()} + return set(raw) + +# ========================================================================== +# Part 3 - OID table +# ========================================================================== + +def _full(suffix: tuple) -> tuple: + return BASE_OID + suffix + +def _oid_str(full: tuple) -> str: + return "." + ".".join(str(x) for x in full) + +def _parse_oid(s: str) -> tuple: + s = s.strip().lstrip(".") + if not s: + return () + try: + return tuple(int(x) for x in s.split(".")) + except ValueError: + return () + + +class _State: + oid_keys: list + oid_map: dict + last_load: float + ttl: int + checksums: dict # table key -> hash() int + timestamps: dict # table key -> ISO 8601 string + config_devices: Optional[list] # None = auto-discover all md arrays + + def __init__(self): + self.oid_keys = [] + self.oid_map = {} + self.last_load = 0.0 + self.ttl = CACHE_TTL + self.checksums = {} + self.timestamps = {} + self.config_devices = None + + +_st = _State() + + +_TABLE_PREFIXES = { + "array_meta": _full((1, 4, 1)), + "array_health": _full((1, 7, 1)), + "array_sync": _full((1, 10, 1)), + "device_meta": _full((1, 13, 1)), + "device_health": _full((1, 16, 1)), +} + + +def _table_fingerprint(entries: list, prefix: tuple) -> int: + plen = len(prefix) + parts = sorted( + f"{typ}\x00{val}" + for oid, typ, val in entries if oid[:plen] == prefix + ) + return _fnv1a_32("\x01".join(parts).encode("utf-8", errors="replace")) + + +def _build(arrays: dict, counters: dict, ts: datetime, + error_code: int = EXIT_SUCCESS, error_string: str = "") -> None: + entries = [] + + def add(suffix, typ, val): + entries.append((_full(suffix), typ, val)) + + n_arrays = counters.get("arrays", 0) + n_devices = counters.get("devices_total", 0) + ts_iso = ts.isoformat() + + # ---- Metadata scalars (.1.1.*) ---- + # SNMP scalars are instantiated with a .0 suffix (e.g. mdadmVersion.0) + add((1,1,1,0), *_datetimeval(ts)) # mdadmLastUpdated - always current + add((1,1,2,0), *_gauge(VERSION)) + add((1,1,3,0), *_gauge(error_code)) # mdadmError + add((1,1,4,0), *_string(error_string)) # mdadmErrorString + add((1,1,5,0), *_gauge(n_arrays)) + add((1,1,6,0), *_gauge(counters.get("degraded_arrays", 0))) + add((1,1,7,0), *_gauge(counters.get("arrays_syncing", 0))) + add((1,1,8,0), *_gauge(n_devices)) + + # ---- Table-level RowCount scalars (LastChange filled in after fingerprinting) ---- + add((1,2,0), *_gauge(n_arrays)) + add((1,5,0), *_gauge(n_arrays)) + add((1,8,0), *_gauge(n_arrays)) + add((1,11,0), *_gauge(n_devices)) + add((1,14,0), *_gauge(n_devices)) + + # Sort arrays by kernel name (md0, md1 …) for stable 1-based indices + sorted_arrays = sorted( + arrays.items(), + key=lambda kv: kv[1].get("array", {}).get("name", kv[0]), + ) + + used_a_idx: set = set() + for uuid, adata in sorted_arrays: + arr = adata.get("array", {}) + devs_r = adata.get("devices", {}) + a_idx = _probe_index(_fnv1a_32(uuid.encode("utf-8", errors="replace")), used_a_idx) + used_a_idx.add(a_idx) + bitmap = arr.get("bitmap", {}) + sync = arr.get("sync", {}) + mount = arr.get("usage", {}).get("mount", {}) + swap = arr.get("usage", {}).get("swap", {}) + queue = arr.get("queue", {}) + + # Sort devices: slotted first, spares after + def _dev_sort(kv): + try: return (0, int(kv[1].get("slot", UINT32_MAX))) + except: return (1, kv[0]) + + devs = sorted(devs_r.items(), key=_dev_sort) + + # --- Array meta table .1.4.1.. --- + T = (1, 4, 1) + add(T+(2, a_idx), *_string(arr.get("uuid", uuid))) + add(T+(3, a_idx), *_string(arr.get("array_name") or arr.get("name", ""))) + add(T+(4, a_idx), *_integer(_map(_RAID_LEVEL, arr.get("raid_level", "")))) + add(T+(5, a_idx), *_string(arr.get("metadata_version", ""))) + add(T+(6, a_idx), *_integer(_map(_CONSISTENCY_POLICY, arr.get("consistency_policy", "")))) + add(T+(7, a_idx), *_integer(_map_layout(arr))) + add(T+(8, a_idx), *_gauge(arr.get("raid_disks", 0))) + add(T+(9, a_idx), *_counter64(arr.get("size_bytes", 0))) + add(T+(10, a_idx), *_counter64(arr.get("array_size_blocks", 0))) + comp = arr.get("component_size_blocks", + arr.get("size_bytes", 0) // 1024 if arr.get("size_bytes") else 0) + add(T+(11, a_idx), *_counter64(comp)) + add(T+(12, a_idx), *_gauge(arr.get("chunk_size", 0))) + add(T+(13, a_idx), *_integer(_map_bitmap_type(arr))) + add(T+(14, a_idx), *_string(bitmap.get("location", "none"))) + add(T+(15, a_idx), *_gauge(bitmap.get("chunksize", 0))) + add(T+(16, a_idx), *_string(bitmap.get("metadata", ""))) + add(T+(17, a_idx), *_gauge(queue.get("logical_block_size", 0))) + add(T+(18, a_idx), *_counter64(_sync_sector(arr.get("resync_start")))) + add(T+(19, a_idx), *_counter64(_sync_sector(arr.get("reshape_position")))) + add(T+(20, a_idx), *_gauge(bitmap.get("time_base", 0))) + short_name = arr.get("name", "") + add(T+(21, a_idx), *_string(short_name)) + add(T+(22, a_idx), *_string(" ".join(arr.get("uris", [f"/dev/{short_name}"] if short_name else [])))) + + # --- Array health table .1.7.1.. --- + H = (1, 7, 1) + add(H+(1, a_idx), *_integer(_map(_ARRAY_STATE, arr.get("state", "")))) + add(H+(2, a_idx), *_gauge(arr.get("degraded", 0))) + add(H+(3, a_idx), *_gauge(arr.get("active_devices", 0))) + add(H+(4, a_idx), *_gauge(arr.get("working_devices", 0))) + add(H+(5, a_idx), *_gauge(arr.get("spare_devices", 0))) + add(H+(6, a_idx), *_gauge(arr.get("failed_devices", 0))) + add(H+(7, a_idx), *_gauge(arr.get("mismatch_cnt", 0))) + add(H+(8, a_idx), *_gauge(int(float(arr.get("safe_mode_delay_sec", 0)) * 1000))) + add(H+(9, a_idx), *_truthvalue(mount.get("is_mounted", False))) + add(H+(10, a_idx), *_string(" ".join(mount.get("mount_points", [])))) + add(H+(11, a_idx), *_truthvalue(swap.get("is_enabled", False))) + add(H+(12, a_idx), *_gauge(bitmap.get("backlog", 0))) + add(H+(13, a_idx), *_gauge(bitmap.get("max_backlog_used", 0))) + add(H+(14, a_idx), *_truthvalue(bitmap.get("can_clear", True))) + add(H+(15, a_idx), *_counter64(arr.get("suspend_lo", 0))) + add(H+(16, a_idx), *_counter64(arr.get("suspend_hi", 0))) + add(H+(17, a_idx), *_gauge(arr.get("stripe_cache_size", 0))) + add(H+(18, a_idx), *_gauge(arr.get("strip_cache_active", arr.get("stripe_cache_active", 0)))) + jm = 1 if str(arr.get("journal_mode", "")).lower() == "write-back" else 0 + add(H+(19, a_idx), *_integer(jm)) + + # --- Array sync table .1.10.1.. --- + S = (1, 10, 1) + add(S+(1, a_idx), *_integer(_map(_SYNC_ACTION, sync.get("action", "idle")))) + add(S+(2, a_idx), *_integer(_map(_SYNC_ACTION, sync.get("last_action", "idle")))) + add(S+(3, a_idx), *_gauge(int(float(sync.get("completed_pct", 0)) * 100))) + add(S+(4, a_idx), *_counter64(sync.get("done_bytes", 0))) + add(S+(5, a_idx), *_counter64(sync.get("total_bytes", 0))) + add(S+(6, a_idx), *_counter64(sync.get("speed_bps", 0))) + add(S+(7, a_idx), *_counter64(sync.get("speed_min_bps", 0))) + add(S+(8, a_idx), *_counter64(sync.get("speed_max_bps", 0))) + add(S+(9, a_idx), *_counter64(_sync_sector(sync.get("min", 0)))) + add(S+(10, a_idx), *_counter64(_sync_sector(sync.get("max", "max")))) + + # --- Per-device rows --- + used_m_idx: set = set() + for dev_key, dev in devs: + serial = dev.get("id_serial_short") or "" + model = dev.get("id_model") or "" + seed_str = f"{serial}|{model}" if (serial or model) else dev.get("device_name", dev_key) + m_idx = _probe_index(_fnv1a_32(seed_str.encode("utf-8", errors="replace")), used_m_idx) + used_m_idx.add(m_idx) + fl = _dev_flags(dev) + + slot_raw = dev.get("slot") + try: slot_val = int(slot_raw) + except: slot_val = UINT32_MAX + + rstart = _sync_sector(dev.get("recovery_start_blocks")) + + # Device meta table .1.13.1... + DM = (1, 13, 1) + add(DM+(2, a_idx, m_idx), *_string(dev.get("device_name", ""))) + add(DM+(3, a_idx, m_idx), *_string(dev.get("device_uuid", ""))) + add(DM+(4, a_idx, m_idx), *_gauge(slot_val)) + add(DM+(5, a_idx, m_idx), *_integer(_map(_DEVICE_ROLE, dev.get("device_role", "")))) + add(DM+(6, a_idx, m_idx), *_string(dev.get("id_model", ""))) + add(DM+(7, a_idx, m_idx), *_string(dev.get("id_serial_short", ""))) + add(DM+(8, a_idx, m_idx), *_counter64(dev.get("size_bytes", 0))) + add(DM+(9, a_idx, m_idx), *_counter64(dev.get("offset_blocks", 0))) + add(DM+(10, a_idx, m_idx), *_counter64(dev.get("ppl_sector", 0))) + add(DM+(11, a_idx, m_idx), *_counter64(dev.get("ppl_size", 0))) + + # Device health table .1.16.1... + DH = (1, 16, 1) + add(DH+(1, a_idx, m_idx), *_string(dev.get("state", ""))) + add(DH+(2, a_idx, m_idx), *_truthvalue(dev.get("is_missing", False))) + add(DH+(3, a_idx, m_idx), *_gauge(dev.get("errors", 0))) + add(DH+(4, a_idx, m_idx), *_gauge(dev.get("events", 0))) + add(DH+(5, a_idx, m_idx), *_counter64(rstart)) + add(DH+(6, a_idx, m_idx), *_gauge(dev.get("bad_block_count", 0))) + add(DH+(7, a_idx, m_idx), *_gauge(dev.get("unack_bad_block_count", 0))) + + # ---- Fingerprint each table; advance LastChange only on actual change ---- + _LC_MAP = { + "array_meta": (1, 3, 0), + "array_health": (1, 6, 0), + "array_sync": (1, 9, 0), + "device_meta": (1, 12, 0), + "device_health": (1, 15, 0), + } + for tname, lc_suffix in _LC_MAP.items(): + fp = _table_fingerprint(entries, _TABLE_PREFIXES[tname]) + if fp != _st.checksums.get(tname): + _st.checksums[tname] = fp + _st.timestamps[tname] = ts_iso + LOGGER.notice("table %s changed (fingerprint %08x)", tname, fp & 0xFFFFFFFF) + lc_iso = _st.timestamps.get(tname, ts_iso) + add(lc_suffix, "string", lc_iso) + + entries.sort(key=lambda e: e[0]) + _st.oid_keys = [e[0] for e in entries] + _st.oid_map = {e[0]: (e[1], e[2]) for e in entries} + LOGGER.debug("OID table built: %d entries", len(entries)) + + +def _set_error_scalar(code: int, message: str) -> None: + """Patch only the mdadmError / mdadmErrorString scalars on the existing + OID map, leaving the last good table data in place.""" + _st.oid_map[_full((1, 1, 3, 0))] = _gauge(code) + _st.oid_map[_full((1, 1, 4, 0))] = _string(message) + + +def _handle_collection_error(exc: CollectionError, ts: datetime) -> None: + LOGGER.error("collection error %d: %s", exc.code, exc.message) + if exc.code in _CLEANUP_CODES: + # No usable data - serve empty tables so LibreNMS prunes stale sensors. + _build({}, _build_counters({}), ts, exc.code, exc.message) + elif _st.oid_keys: + # Transient - keep the last good data, just surface the error in-band. + _set_error_scalar(exc.code, exc.message) + else: + # Nothing to preserve yet; serve empty tables carrying the error. + _build({}, _build_counters({}), ts, exc.code, exc.message) + + +def _refresh() -> None: + now = time.monotonic() + if _st.oid_keys and now - _st.last_load < _st.ttl: + LOGGER.debug("cache hit (age=%.1fs ttl=%ds)", now - _st.last_load, _st.ttl) + return + LOGGER.notice("refreshing data (age=%.1fs ttl=%ds)", + now - _st.last_load if _st.last_load else 0, _st.ttl) + ts = datetime.now(timezone.utc) + counters = {} + try: + if shutil.which("mdadm") is None: + raise CollectionError( + EXIT_DEPENDENCY_MISSING, "mdadm binary not found in $PATH") + arrays = _collect_arrays(config_devices=_st.config_devices) + counters = _build_counters(arrays) + err_code, err_msg = _classify_partial(arrays) + _build(arrays, counters, ts, err_code, err_msg) + except CollectionError as exc: + _handle_collection_error(exc, ts) + except Exception as exc: + # Unexpected failure: treat as transient so we don't wipe sensors on a + # one-off bug. Preserve last good data if we have any. + LOGGER.error("unexpected collection failure: %s", exc) + if _st.oid_keys: + _set_error_scalar(EXIT_PARTIAL_FAILURE, + f"unexpected collection failure: {exc}") + else: + _build({}, _build_counters({}), ts, + EXIT_PARTIAL_FAILURE, f"unexpected collection failure: {exc}") + _st.last_load = now + LOGGER.notice("refresh complete in %.2fs: %d arrays, %d devices", + time.monotonic() - now, + counters.get("arrays", 0), + counters.get("devices_total", 0)) + +# ========================================================================== +# Part 4 - pass_persist protocol +# ========================================================================== + +def _respond(full: tuple) -> None: + entry = _st.oid_map.get(full) + if entry is None: + print("NONE", flush=True) + return + typ, val = entry + print(_oid_str(full), flush=True) + print(typ, flush=True) + print(val, flush=True) + + +def _handle_get(oid_str: str) -> None: + tup = _parse_oid(oid_str) + if tup in _st.oid_map: + LOGGER.debug("GET %s -> found", oid_str) + _respond(tup) + else: + LOGGER.debug("GET %s -> NONE", oid_str) + print("NONE", flush=True) + + +def _handle_getnext(oid_str: str) -> None: + tup = _parse_oid(oid_str) + idx = bisect.bisect_right(_st.oid_keys, tup) + if idx >= len(_st.oid_keys): + LOGGER.debug("GETNEXT %s -> NONE (end of table)", oid_str) + print("NONE", flush=True) + return + LOGGER.debug("GETNEXT %s -> %s", oid_str, _oid_str(_st.oid_keys[idx])) + _respond(_st.oid_keys[idx]) + + +def _load_config(path: str) -> dict: + if not path or not os.path.isfile(path): + return {} + try: + import yaml + with open(path) as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + return {} + return data + except ImportError: + # PyYAML unavailable - fall back to a minimal key=value parser + cfg: dict = {} + try: + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or line.startswith("---"): + continue + if ":" in line: + k, _, v = line.partition(":") + cfg[k.strip()] = v.strip() + except OSError: + pass + return cfg + except Exception as exc: + print(f"WARNING: could not read config {path}: {exc}", file=sys.stderr) + return {} + + +def main() -> None: + ap = argparse.ArgumentParser( + description="MDADM-MIB pass_persist agent") + ap.add_argument("--config", metavar="PATH", + default="/etc/snmp/extension/mdadm.yaml", + help="YAML config file (default: %(default)s)") + ap.add_argument("--ttl", type=int, default=None, metavar="SEC", + help=f"data refresh interval in seconds (default: {CACHE_TTL})") + ap.add_argument("--log-level", default=None, + choices=["DEBUG", "VERBOSE", "INFO", "NOTICE", "WARNING", "ERROR"], + help="logging verbosity (overrides config file)") + ap.add_argument("--log-file", metavar="PATH", default=None, + help="log file path (overrides config file)") + args = ap.parse_args() + + cfg = _load_config(args.config) + + # CLI args take precedence over config file; config file over built-in defaults + ttl = args.ttl or int(cfg.get("ttl", CACHE_TTL)) + log_level = args.log_level or str(cfg.get("log_level", "WARNING")).upper() + + script_dir = os.path.dirname(os.path.realpath(__file__)) + log_path = args.log_file or cfg.get("log_file") or os.path.join(script_dir, "mdadm.log") + + _st.ttl = ttl + + # Empty / missing list -> None means auto-discover all md arrays. + devices = cfg.get("devices") + _st.config_devices = devices if devices else None + + level = {"VERBOSE": VERBOSE, "NOTICE": NOTICE}.get(log_level, + getattr(logging, log_level, logging.WARNING)) + + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + handlers=[logging.FileHandler(log_path)], + ) + LOGGER.debug("config=%s log_level=%s log_file=%s ttl=%ds", + args.config, log_level, log_path, ttl) + + _refresh() + + for line in sys.stdin: + cmd = line.rstrip("\r\n") + if cmd == "PING": + print("PONG", flush=True) + elif cmd in ("get", "getnext"): + try: + oid = next(sys.stdin).rstrip("\r\n") + except StopIteration: + break + _refresh() + if not _st.oid_keys: + print("NONE", flush=True) + continue + if cmd == "get": + _handle_get(oid) + else: + _handle_getnext(oid) + else: + print("NONE", flush=True) + + +if __name__ == "__main__": + main() diff --git a/snmp/mdadm/mdadm.yaml.example b/snmp/mdadm/mdadm.yaml.example new file mode 100644 index 000000000..de304d030 --- /dev/null +++ b/snmp/mdadm/mdadm.yaml.example @@ -0,0 +1,17 @@ +--- +# mdadm pass_persist agent configuration +# +# Copy to /etc/snmp/extension/mdadm.yaml and adjust as needed. + +# Logging level: DEBUG, VERBOSE, INFO, NOTICE, WARNING, ERROR +log_level: WARNING + +# Log file path. Defaults to mdadm.log in the same directory as the script. +# log_file: /var/log/mdadm-snmp.log + +# Data refresh interval in seconds. The agent stays resident and re-collects +# at most once per ttl seconds. +ttl: 60 + +# Devices to poll. Empty list auto-discovers all md arrays. +devices: [] diff --git a/snmp/mdadm/sudoers.d-mdadm b/snmp/mdadm/sudoers.d-mdadm new file mode 100644 index 000000000..3774008ac --- /dev/null +++ b/snmp/mdadm/sudoers.d-mdadm @@ -0,0 +1,10 @@ +# Allow snmpd to run mdadm read-only commands without a password. +# Install: sudo cp sudoers.d-mdadm /etc/sudoers.d/mdadm +# sudo chmod 440 /etc/sudoers.d/mdadm +# +# Covers: +# mdadm --detail /dev/mdX - array health and device counts +# mdadm -E /dev/ - member superblock (UUID, events) + +Debian-snmp ALL=(root) NOPASSWD: /sbin/mdadm --detail /dev/md*, \ + /sbin/mdadm -E /dev/* From 0dedd0261077df0d3f097684fd7a8530702535a2 Mon Sep 17 00:00:00 2001 From: Torstein EIde <1884894+Torstein-Eide@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:16:48 +0200 Subject: [PATCH 4/4] SNMP: mdadm: add install and verification docs --- snmp/mdadm/README.md | 138 +++++++++++++++++++ snmp/mdadm/mdadm_install.sh | 261 ++++++++++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 snmp/mdadm/README.md create mode 100755 snmp/mdadm/mdadm_install.sh diff --git a/snmp/mdadm/README.md b/snmp/mdadm/README.md new file mode 100644 index 000000000..2fcb02ac4 --- /dev/null +++ b/snmp/mdadm/README.md @@ -0,0 +1,138 @@ +# LibreNMS mdadm SNMP Extension + +This directory contains a LibreNMS SNMP agent for Linux software RAID. The +`mdadm` script is a self-contained net-snmp **`pass_persist`** agent: `snmpd` +launches it, keeps it resident, and queries it on demand. It collects array and +device status directly from sysfs and `mdadm(8)` and serves every object in +`MDADM-MIB` (enterprise OID `1.3.6.1.4.1.60652.101`). + +Because it runs under `pass_persist`, there is **no cache file, cron job, or +systemd timer**. Data is refreshed in-process at most once per `ttl` seconds +(default 60). + +## Install + +Run the installer as root: + +```bash +sudo ./mdadm_install.sh +``` + +For non-interactive installs, set `AUTO_YES=1`: + +```bash +sudo AUTO_YES=1 ./mdadm_install.sh +``` + +The installer downloads the agent, writes a default config, installs the +sudoers rule, and adds the `pass_persist` line to the `snmpd` include snippet. + +## Installed Files + +- Agent script: `/usr/local/lib/snmpd/mdadm` +- Configuration: `/etc/snmp/extension/mdadm.yaml` +- Sudoers rule: `/etc/sudoers.d/mdadm` +- SNMP snippet: `/etc/snmp/snmpd.conf.d/librenms.conf` + +The snippet contains a single line: + +```text +pass_persist .1.3.6.1.4.1.60652.101 /usr/local/lib/snmpd/mdadm +``` + +## Sudoers + +`snmpd` typically runs as an unprivileged user (`Debian-snmp` or `snmp`). The +agent reads most data from sysfs, but `mdadm --detail` and `mdadm -E` need +root for device counts, array UUID, and event counters. The installed +`sudoers.d-mdadm` rule grants exactly those two read-only commands without a +password. Without it, the agent still works but reports reduced detail. + +## Configuration + +See `mdadm.yaml.example` for the full, commented template. The default config +discovers all md arrays: + +```yaml +--- +log_level: WARNING +ttl: 60 +devices: [] +``` + +To limit polling to specific arrays, edit `/etc/snmp/extension/mdadm.yaml`: + +```yaml +devices: + - name: md0 + description: Root filesystem + - name: md1 + description: Data volume +``` + +Recognised keys: + +- `log_level` - `DEBUG`, `VERBOSE`, `INFO`, `NOTICE`, `WARNING`, `ERROR`. +- `log_file` - log path (defaults to `mdadm.log` beside the script). +- `ttl` - in-process refresh interval in seconds (default 60). +- `devices` - list of arrays to poll; empty auto-discovers all md arrays. + +CLI flags (`--config`, `--ttl`, `--log-level`, `--log-file`) override the +config file. + +## Verification + +Run the agent by hand and speak the `pass_persist` protocol on stdin: + +```bash +printf 'PING\ngetnext\n.1.3.6.1.4.1.60652.101\n' \ + | /usr/local/lib/snmpd/mdadm --config /etc/snmp/extension/mdadm.yaml +``` + +You should see `PONG` followed by the first OID, type, and value. + +Once `snmpd` has reloaded the snippet, walk the MIB: + +```bash +snmpwalk -v2c -c public localhost .1.3.6.1.4.1.60652.101 +``` + +Load `MDADM-MIB.mib` to get symbolic names: + +```bash +snmpwalk -v2c -c public -m +MDADM-MIB localhost mdadmMIB +``` + +Restart `snmpd` after installation if your system does not automatically reload +the include directory. + +## Error reporting + +The agent is a resident `pass_persist` process, so it does not communicate +failures through a process exit code (snmpd would simply respawn it). Instead the +most recent collection result is published in-band as two scalars: + +- `mdadmError` (`.1.1.3`) - numeric code, `0` on success. +- `mdadmErrorString` (`.1.1.4`) - human-readable description, empty on success. + +| Code | Meaning | Behaviour | +|------|---------|-----------| +| 0 | All arrays collected cleanly | Normal data served | +| 1 | `mdadm` binary not in `$PATH` | Cleanup - empty tables, sensors removed | +| 2 | Auto-discovery found no arrays | Cleanup - empty tables, sensors removed | +| 3 | `/sys/block` unreadable | Skip - last good data preserved | +| 5 | Configured device entry missing `name` | Skip - last good data preserved | +| 6 | Some arrays had read errors, or `sudo mdadm` access is missing (data still served from sysfs) | Normal data served, error flagged | +| 7 | Configured devices listed but none exist in sysfs | Cleanup - empty tables, sensors removed | + +"Cleanup" codes serve empty tables so LibreNMS prunes stale sensors; "Skip" +codes are transient and keep the last good data so sensors are not lost on a +momentary failure. (Code 4, output-write failure, does not apply - there is no +output file in `pass_persist` mode.) + +If the sudoers rule is not installed, `sudo mdadm --detail` / `sudo mdadm -E` +are refused and the agent reports code 6 with an actionable message: arrays are +still discovered from sysfs, but per-array and per-device enrichment (UUIDs, +event counts, exact device-role counts) is skipped. Install `sudoers.d-mdadm` +to clear it. The agent calls sudo with `-n` (non-interactive) so a missing rule +fails fast instead of blocking on a password prompt. diff --git a/snmp/mdadm/mdadm_install.sh b/snmp/mdadm/mdadm_install.sh new file mode 100755 index 000000000..4afe77fc1 --- /dev/null +++ b/snmp/mdadm/mdadm_install.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +#--------------------------------------------------------------------------------------------------------------- +# +# Script name : mdadm_install.sh +# Description : Install script for LibreNMS SNMP extension "mdadm" +# Repository : +# Version : 2.0.0 +# Author : LibreNMS contributors +# License : MIT +# +# The mdadm agent is a net-snmp pass_persist agent. snmpd launches it, keeps it +# resident, and queries it on demand. There is no cache file, cron job, or +# systemd timer to install. +#--------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +ID="unknown" +ID_LIKE="unknown" +PKG_FAMILY="unknown" + +EXT_NAME="mdadm" +EXT_URL="https://raw.githubusercontent.com/librenms/librenms-agent/master/snmp/mdadm/mdadm" +SUDOERS_URL="https://raw.githubusercontent.com/librenms/librenms-agent/master/snmp/mdadm/sudoers.d-mdadm" +EXT_BIN="/usr/local/lib/snmpd/${EXT_NAME}" +EXT_CONF_DIR="/etc/snmp/extension" +EXT_CONF="${EXT_CONF_DIR}/${EXT_NAME}.yaml" +SUDOERS_FILE="/etc/sudoers.d/${EXT_NAME}" +SNMP_SNIPPET="/etc/snmp/snmpd.conf.d/librenms.conf" + +# pass_persist root OID - MDADM-MIB, enterprise 1.3.6.1.4.1.60652.101 +BASE_OID=".1.3.6.1.4.1.60652.101" + +SNMPD_MAIN_CONF="/etc/snmp/snmpd.conf" +SNMPD_INCLUDE_DIR_LINE="includeDir /etc/snmp/snmpd.conf.d" + +# Optional: disable interactivity for automation +# export AUTO_YES=1 +VERBOSE_LOG=${VERBOSE_LOG:-0} + +# Logging functions +_date() { + date +%Y-%m-%d_%H:%M:%S +} + +log_verbose() { + [[ "${VERBOSE_LOG}" -eq 1 ]] || return 0 + echo -e "\033[94m VERBOSE: $*\033[0m" +} + +log_info() { + echo "$( _date ): INFO: $*" +} + +log_notice() { + echo -e "\033[92m$( _date ): NOTICE: $* \033[0m" +} + +log_warn() { + echo -e "\033[93m$( _date ): WARN: $* \033[0m" +} + +log_error() { + echo -e "\033[91m$( _date ): ERROR: $* \033[0m" >&2 +} + +run_cmd() { + local -a cmd=("$@") + log_verbose "Running command: ${cmd[*]}" + "${cmd[@]}" +} + +error() { + log_error "$*" + exit 1 +} + +usage() { + echo "Usage: $0" + echo " Installs the mdadm pass_persist SNMP agent." + echo " Set AUTO_YES=1 for non-interactive installs." + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac +done + +if [[ $EUID -ne 0 ]]; then + log_error "This script must be run with sudo or as root" + exit 1 +fi + +log_notice "Installing ${EXT_NAME} SNMP extension." + +ask_yes_no() { + if [[ "${AUTO_YES:-}" == "1" ]]; then + return 0 + fi + local prompt="$1" + local answer + + while true; do + read -rp "$prompt [y/n]: " answer < /dev/tty + case "${answer,,}" in + y|yes) return 0 ;; + n|no) return 1 ;; + *) echo "Please answer y or n." ;; + esac + done +} + +is_installed() { + command -v "$1" >/dev/null 2>&1 +} + +if [[ -r /etc/os-release ]]; then + . /etc/os-release +else + error "Cannot detect OS (missing /etc/os-release)" +fi + +case "$ID_LIKE $ID" in + + *debian*|*ubuntu*) + PKG_FAMILY="deb" + SNMP_USER="Debian-snmp" + ;; + *rhel*|*fedora*|*centos*) + PKG_FAMILY="rpm" + SNMP_USER="snmp" + ;; + *) + error "Unsupported distro: ID=$ID ID_LIKE=$ID_LIKE" + ;; +esac + +debian_install() { + + if ! is_installed curl || ! is_installed snmpd || ! is_installed python3 || ! is_installed mdadm; then + if ask_yes_no "Install dependencies?"; then + run_cmd apt update + run_cmd apt install -y curl snmpd ca-certificates python3 mdadm + else + log_warn "Skipping dependencies." + fi + fi +} + +rpm_install() { + + if ! is_installed curl || ! is_installed snmpd || ! is_installed python3 || ! is_installed mdadm; then + if ask_yes_no "Install dependencies?"; then + run_cmd dnf install -y curl net-snmp net-snmp-utils ca-certificates python3 mdadm + run_cmd systemctl enable --now snmpd || true + else + log_warn "Skipping dependencies." + fi + fi +} + +install_deps() { + case "$PKG_FAMILY" in + deb) + debian_install + ;; + rpm) + rpm_install + ;; + esac +} + +install_deps + +if [[ -f "${SNMPD_MAIN_CONF}" ]]; then + if ! grep -Fqs "${SNMPD_INCLUDE_DIR_LINE}" "${SNMPD_MAIN_CONF}"; then + log_warn "Missing '${SNMPD_INCLUDE_DIR_LINE}' in ${SNMPD_MAIN_CONF}." + log_warn "Without it, snmpd may not load ${SNMP_SNIPPET}." + + if ask_yes_no "Append includeDir to ${SNMPD_MAIN_CONF}?"; then + install -d -m 0755 /etc/snmp/snmpd.conf.d + cp -a "${SNMPD_MAIN_CONF}" "${SNMPD_MAIN_CONF}.bak.$(date +%Y%m%d%H%M%S)" + printf '\n%s\n' "${SNMPD_INCLUDE_DIR_LINE}" >> "${SNMPD_MAIN_CONF}" + log_notice "Appended '${SNMPD_INCLUDE_DIR_LINE}' to ${SNMPD_MAIN_CONF}." + log_notice "Restart snmpd to apply changes." + else + log_warn "Skipping includeDir update." + fi + fi +else + log_warn "${SNMPD_MAIN_CONF} not found; cannot verify includeDir configuration." +fi + +install -v -d -m 0755 /usr/local/lib/snmpd +install -v -d -m 0755 "${EXT_CONF_DIR}" +install -v -d -m 0755 /etc/snmp/snmpd.conf.d + +log_info "Downloading ${EXT_NAME} agent from ${EXT_URL}..." +curl -fsSL "${EXT_URL}" -o "${EXT_BIN}" || error "Failed to download ${EXT_NAME}" +chmod 0755 "${EXT_BIN}" + +if [ ! -f "${EXT_CONF}" ]; then + log_info "Installing default configuration for ${EXT_NAME}..." + cat >"${EXT_CONF}" </dev/null 2>&1; then + install -m 0440 "${SUDOERS_FILE}.tmp" "${SUDOERS_FILE}" + rm -f "${SUDOERS_FILE}.tmp" + log_notice "Installed sudoers rule for ${SNMP_USER}." + else + rm -f "${SUDOERS_FILE}.tmp" + log_warn "Sudoers rule failed validation; skipping. Agent will run with reduced detail." + fi +else + log_warn "Failed to download sudoers rule; skipping. Agent will run with reduced detail." +fi + +PASS_PERSIST_LINE="pass_persist ${BASE_OID} ${EXT_BIN}" +if [ ! -f "${SNMP_SNIPPET}" ] || ! grep -Fqs "${PASS_PERSIST_LINE}" "${SNMP_SNIPPET}"; then + printf '%s\n' "${PASS_PERSIST_LINE}" >>"${SNMP_SNIPPET}" + log_notice "Added pass_persist line to ${SNMP_SNIPPET}." +fi + +# Restart snmpd so the pass_persist agent is picked up. +if is_installed systemctl && systemctl is-active --quiet snmpd; then + if ask_yes_no "Restart snmpd now?"; then + run_cmd systemctl restart snmpd + log_notice "snmpd restarted." + else + log_warn "Restart snmpd manually to load the agent." + fi +else + log_warn "Could not restart snmpd automatically; restart it manually to load the agent." +fi + +log_notice "Installed ${EXT_NAME} pass_persist agent."