towerops/lib/towerops/snmp/profiles/dynamic.ex
Graham McIntire 9094f42098
feat: add Dell PowerVault storage array text-based sensor parsing
Implement vendor module for Dell PowerVault storage arrays that parse
text-based sensor messages from FCMGMT-MIB instead of structured tables.

Created vendor post-processing module:
- lib/towerops/snmp/profiles/vendors/powervault.ex
  * Walks FCMGMT-MIB::connUnitSensorMessage (OID 1.3.6.1.3.94.1.8.1.6)
  * Parses text format "Sensor Name: Value Unit" with regex matching
  * Temperature: "25 C 77.0F" → 25°C (extracts Celsius)
  * Voltage: "12.1V" → 12.1V
  * Current: "0.5A" → 0.5A
  * Battery Charge: "95%" → 95% (with threshold limits)

Integrated into discovery pipeline:
- lib/towerops/snmp/profiles/dynamic.ex
  * Added "dell-powervault" case to apply_vendor_post_processing/3
  * Follows Arista vendor module pattern

Comprehensive test coverage:
- test/towerops/snmp/profiles/vendors/powervault_test.exs
  * 21 tests covering all sensor types and edge cases
  * Tests multi-sensor parsing, error handling, format validation
  * All tests passing with Mox SNMP adapter mocking

Technical notes:
- Client.walk returns map {oid => value}, converted to list for parsing
- Sensor indices: powervault_{type}.{oid_index} for uniqueness
- post_process_sensors/2 combines with existing sensors from base discovery

Result: Dell PowerVault arrays now supported with text message parsing.
Gap: CRITICAL (no sensors) → RESOLVED. Parity: 0% → 85%.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 08:38:30 -06:00

565 lines
20 KiB
Elixir

defmodule Towerops.Snmp.Profiles.Dynamic do
@moduledoc """
Dynamic profile that uses YAML-loaded profiles with MIB name translation.
Profiles are loaded from priv/profiles/ and cached in ETS.
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Profiles.Vendors.Registry, as: VendorRegistry
require Logger
@doc """
Identifies device using profile's device_oids.
Translates MIB names to numeric OIDs and polls the device.
"""
@spec identify_device(map(), Client.connection_opts(), Discovery.system_info()) ::
Discovery.device_info()
def identify_device(profile, client_opts, system_info) do
device_oids = Map.get(profile, :device_oids, %{})
device_data =
device_oids
|> Enum.map(fn {field, mib_name} -> fetch_device_oid_value(field, mib_name, client_opts) end)
|> Enum.reject(&is_nil/1)
|> Map.new()
# Get hardware model: YAML hardware OID → sysDescr_regex → vendor-specific
# → profile-based fallback → sysDescr
model =
Map.get(device_data, :hardware) ||
extract_hardware_from_regex(profile, system_info[:sys_descr]) ||
detect_vendor_hardware(profile, client_opts) ||
profile_based_model_fallback(profile, system_info[:sys_descr]) ||
system_info[:sys_descr]
# Get firmware version: YAML version OID → vendor-specific → format from device_data
firmware_version =
Map.get(device_data, :firmware_version) ||
detect_vendor_version(profile, client_opts) ||
format_firmware_version(profile, device_data)
# Get serial number: YAML serial_number OID → vendor-specific
serial_number =
Map.get(device_data, :serial_number) ||
detect_vendor_serial_number(profile, client_opts)
# Merge with system_info
system_info
|> Map.merge(%{
manufacturer: profile.vendor,
model: model,
firmware_version: firmware_version,
serial_number: serial_number,
latitude: Map.get(device_data, :latitude),
longitude: Map.get(device_data, :longitude)
})
|> Map.merge(device_data)
end
@doc """
Discovers sensors using profile's sensor_oids and table-based sensors.
Combines profile-specific sensors with standard ENTITY-SENSOR-MIB discovery.
Works like LibreNMS - discovers all sensor types (scalar and table-based).
"""
@spec discover_sensors(map(), Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]}
def discover_sensors(profile, client_opts) do
alias Towerops.Snmp.Profiles.Base
# Always discover ENTITY-SENSOR-MIB sensors (most devices support this)
{:ok, base_sensors} = Base.discover_sensors(client_opts)
# Scalar sensors from profile (direct OID polling)
scalar_sensors = Map.get(profile, :sensor_oids, [])
scalar_results =
scalar_sensors
|> Enum.map(&fetch_sensor_value(&1, client_opts))
|> Enum.reject(&is_nil/1)
# Table-based sensors from profile (OID walks for all sensor types)
table_sensor_oids = Map.get(profile, :table_sensor_oids, [])
processor_oids = Map.get(profile, :processor_oids, [])
count_sensor_oids = Map.get(profile, :count_sensor_oids, [])
state_sensor_oids = Map.get(profile, :state_sensor_oids, [])
# Walk table sensors (temperature, voltage, power, etc. with {{ $index }})
table_results = discover_table_sensors(table_sensor_oids, client_opts)
processor_results = discover_table_sensors(processor_oids, client_opts)
count_results = discover_table_sensors(count_sensor_oids, client_opts)
state_results = discover_state_sensors(state_sensor_oids, client_opts)
# Wireless sensors (vendor-specific OIDs like those in LibreNMS PHP files)
wireless_results = discover_vendor_wireless_sensors(profile, client_opts)
Logger.debug("Discovered #{length(wireless_results)} wireless sensors for profile #{profile.name}")
# Combine all sensors - profile-specific sensors first, then base sensors
all_sensors =
scalar_results ++
table_results ++
processor_results ++
count_results ++
state_results ++ wireless_results ++ base_sensors
# Deduplicate by OID - when multiple sensors have the same OID,
# prefer the one with a proper description (not a MIB name)
deduped_sensors = deduplicate_by_oid(all_sensors)
# Apply vendor-specific post-processing (e.g., Arista DOM power conversion + thresholds)
final_sensors = apply_vendor_post_processing(profile, deduped_sensors, client_opts)
{:ok, final_sensors}
end
@doc """
Uses Base profile for interface discovery.
"""
@spec discover_interfaces(map(), Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]}
def discover_interfaces(_profile, client_opts) do
alias Towerops.Snmp.Profiles.Base
Base.discover_interfaces(client_opts)
end
@doc """
Discovers wireless sensors using vendor-specific OIDs.
Mirrors LibreNMS PHP-based wireless sensor discovery (e.g., Airos.php, Epmp.php).
Returns sensors for frequency, rssi, snr, clients, ccq, power, etc.
"""
@spec discover_wireless_sensors(map(), Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]}
def discover_wireless_sensors(profile, client_opts) do
sensors = discover_vendor_wireless_sensors(profile, client_opts)
{:ok, sensors}
end
# Private helper functions
# Vendor-specific hardware detection using vendor modules (like LibreNMS OS PHP files)
defp detect_vendor_hardware(%{name: name}, client_opts) do
VendorRegistry.detect_hardware(name, client_opts)
end
defp detect_vendor_hardware(_profile, _client_opts), do: nil
# Vendor-specific version detection using vendor modules (like LibreNMS OS PHP files)
defp detect_vendor_version(%{name: name}, client_opts) do
case VendorRegistry.get_vendor(name) do
nil ->
nil
vendor ->
# Check if vendor module implements detect_version/1
if function_exported?(vendor, :detect_version, 1) do
vendor.detect_version(client_opts)
end
end
end
defp detect_vendor_version(_profile, _client_opts), do: nil
# Vendor-specific serial number detection using vendor modules
defp detect_vendor_serial_number(%{name: name}, client_opts) do
case VendorRegistry.get_vendor(name) do
nil ->
nil
vendor ->
# Check if vendor module implements detect_serial_number/1
if function_exported?(vendor, :detect_serial_number, 1) do
vendor.detect_serial_number(client_opts)
end
end
end
defp detect_vendor_serial_number(_profile, _client_opts), do: nil
# Extract hardware model from sysDescr using profile's sysDescr_regex
# LibreNMS uses named capture groups like (?<hardware>...) in the regex
defp extract_hardware_from_regex(%{hardware_regex: regex}, sys_descr)
when is_struct(regex, Regex) and is_binary(sys_descr) do
case Regex.named_captures(regex, sys_descr) do
%{"hardware" => hardware} when hardware != "" -> hardware
_ -> nil
end
end
defp extract_hardware_from_regex(_profile, _sys_descr), do: nil
# Profile-based model fallback for when vendor detection fails
# Uses profile type/category as a generic model name
defp profile_based_model_fallback(%{type: type, vendor: vendor}, _sys_descr)
when not is_nil(type) and not is_nil(vendor) do
# Use profile type (e.g., "wireless") as generic model
"#{String.capitalize(type)} Device"
end
defp profile_based_model_fallback(_profile, _sys_descr), do: nil
defp format_firmware_version(%{vendor: vendor}, device_data) when vendor in ["MikroTik", "Mikrotik RouterOS"] do
case {Map.get(device_data, :firmware_version), Map.get(device_data, :license_version)} do
{fw, license} when not is_nil(fw) and not is_nil(license) ->
"MikroTik RouterOS #{fw} (Level #{license})"
{fw, _} when not is_nil(fw) ->
"MikroTik RouterOS #{fw}"
_ ->
nil
end
end
defp format_firmware_version(%{vendor: vendor}, device_data) when vendor in ["Ubiquiti", "Ubiquiti AirOS"] do
# Extract version from format like "XW.v8.7.21.48401.251212.0939"
# Remove the ".v" prefix to get just "8.7.21.48401.251212.0939"
case Map.get(device_data, :firmware_version) do
nil ->
nil
version when is_binary(version) ->
case Regex.run(~r/\.v(.*)$/, version) do
[_, extracted_version] -> extracted_version
_ -> version
end
version ->
version
end
end
defp format_firmware_version(_profile, device_data) do
# For other devices, use raw firmware version
Map.get(device_data, :firmware_version)
end
defp fetch_device_oid_value(field, mib_name, client_opts) do
# Client.get already handles MIB resolution via ToweropsNative NIF
case Client.get(client_opts, mib_name) do
{:ok, value} ->
{field, value}
{:error, _} ->
nil
end
end
defp fetch_sensor_value(sensor_oid, client_opts) do
mib_name = Map.get(sensor_oid, :mib_name)
# Client.get already handles MIB resolution via ToweropsNative NIF
case Client.get(client_opts, mib_name) do
{:ok, value} when is_number(value) and value > 0 ->
%{
sensor_type: sensor_oid[:sensor_type],
sensor_index: sensor_oid[:sensor_type],
sensor_oid: mib_name,
sensor_descr: sensor_oid[:sensor_descr] || sensor_oid[:sensor_type],
sensor_unit: sensor_oid[:sensor_unit] || "",
sensor_divisor: sensor_oid[:sensor_divisor] || 1,
last_value: value / 1
}
_ ->
nil
end
end
# Discover table-based sensors by walking the base OID
defp discover_table_sensors(sensor_defs, client_opts) do
Enum.flat_map(sensor_defs, &walk_table_sensor(&1, client_opts))
end
defp walk_table_sensor(sensor_def, client_opts) do
base_oid = sensor_def[:base_oid]
descr_oid = sensor_def[:descr_oid]
case Client.walk(client_opts, base_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
# If there's a descr_oid, walk it to get actual sensor names
descr_map = walk_descr_oid(descr_oid, client_opts)
parse_table_walk_results(results, sensor_def, descr_map)
_ ->
[]
end
end
# Walk the description OID to get actual sensor names (e.g., mtxrGaugeName)
defp walk_descr_oid(nil, _client_opts), do: %{}
defp walk_descr_oid(descr_mib_name, client_opts) do
# Client.walk already handles MIB resolution via ToweropsNative NIF
case Client.walk(client_opts, descr_mib_name) do
{:ok, results} when is_map(results) ->
# Build a map of index -> description
Map.new(results, fn {oid, value} ->
# Extract the index from the full OID (last component)
index = oid |> String.split(".") |> List.last()
{index, to_string(value)}
end)
_ ->
%{}
end
end
defp parse_table_walk_results(results, sensor_def, descr_map) when is_map(results) do
results
|> Enum.with_index(1)
|> Enum.map(fn {{oid, value}, idx} ->
# Extract the index from the OID to look up the description
oid_index = oid |> String.split(".") |> List.last()
build_table_sensor(sensor_def, oid, value, idx, descr_map, oid_index)
end)
|> Enum.reject(&is_nil/1)
end
# Build a sensor from table walk result
defp build_table_sensor(sensor_def, oid, value, idx, descr_map, oid_index) when is_number(value) do
# Use index_template from YAML if available, otherwise generate default index
sensor_index =
case sensor_def[:index_template] do
nil ->
"#{sensor_def[:sensor_type]}_#{idx}"
template ->
# Replace {{ $index }} with the actual OID index
# Coerce to string since YAML parses bare numbers (e.g. `index: 0`) as integers
template |> to_string() |> String.replace("{{ $index }}", oid_index)
end
%{
sensor_type: sensor_def[:sensor_type],
sensor_index: sensor_index,
sensor_oid: oid,
sensor_descr: build_sensor_descr(sensor_def, idx, descr_map, oid_index),
sensor_unit: sensor_def[:sensor_unit] || "",
sensor_divisor: sensor_def[:sensor_divisor] || 1,
last_value: value / 1
}
end
defp build_table_sensor(_sensor_def, _oid, _value, _idx, _descr_map, _oid_index), do: nil
# Discover state sensors (sensors with discrete states like GPS Status, DFS Status)
defp discover_state_sensors(sensor_defs, client_opts) do
Enum.flat_map(sensor_defs, &walk_state_sensor(&1, client_opts))
end
defp walk_state_sensor(sensor_def, client_opts) do
base_oid = sensor_def[:base_oid]
case Client.walk(client_opts, base_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
parse_state_walk_results(results, sensor_def)
_ ->
[]
end
end
defp parse_state_walk_results(results, sensor_def) when is_map(results) do
results
|> Enum.with_index(1)
|> Enum.map(fn {{oid, value}, idx} ->
# Extract the index from the OID for template substitution
oid_index = oid |> String.split(".") |> List.last()
build_state_sensor(sensor_def, oid, value, idx, oid_index)
end)
|> Enum.reject(&is_nil/1)
end
# Build a state sensor with value-to-description mapping
defp build_state_sensor(sensor_def, oid, value, idx, oid_index) when is_number(value) do
states = sensor_def[:states] || %{}
# Convert value to integer for state lookup (states are discrete)
int_value = trunc(value)
# Map numeric state to description, or use the raw value
state_descr = Map.get(states, int_value, "State #{int_value}")
# Convert states map keys to strings for JSON storage
states_for_json = Map.new(states, fn {k, v} -> {to_string(k), v} end)
# Use index_template from YAML if available, otherwise generate default index
sensor_index =
case sensor_def[:index_template] do
nil ->
"#{sensor_def[:oid_name] || "state"}_#{idx}"
template ->
# Replace {{ $index }} with the actual OID index
# Coerce to string since YAML parses bare numbers (e.g. `index: 0`) as integers
template |> to_string() |> String.replace("{{ $index }}", oid_index)
end
%{
sensor_type: "state",
sensor_index: sensor_index,
sensor_oid: oid,
sensor_descr: build_sensor_descr_default(sensor_def, idx),
sensor_unit: "",
sensor_divisor: 1,
last_value: value / 1,
state_descr: state_descr,
metadata: %{"states" => states_for_json}
}
end
defp build_state_sensor(_sensor_def, _oid, _value, _idx, _oid_index), do: nil
# Build sensor description, using the descr_map if available for actual device names
defp build_sensor_descr(sensor_def, idx, descr_map, oid_index) do
# First, try to get the description from the descr_map (walked from device)
case Map.get(descr_map, oid_index) do
descr when is_binary(descr) and descr != "" ->
# Use the actual device name directly
descr
_ ->
# Fall back to the default description building logic
build_sensor_descr_default(sensor_def, idx)
end
end
defp build_sensor_descr_default(sensor_def, idx) do
base_descr = sensor_def[:sensor_descr] || sensor_def[:oid_name] || sensor_def[:sensor_type]
# If the description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName"),
# use the sensor_type as fallback since we can't resolve MIB names to actual values here
base_descr =
if is_binary(base_descr) and String.contains?(base_descr, "::") do
sensor_def[:sensor_type] || "sensor"
else
base_descr
end
# If there's only one instance, don't add index
if idx == 1 do
String.capitalize(to_string(base_descr))
else
"#{String.capitalize(to_string(base_descr))} #{idx}"
end
end
@doc """
Collects all discovered sensor data for debugging purposes.
Includes YAML-defined sensors (processors, state, count, table) and wireless sensors.
"""
@spec collect_vendor_debug_data(map(), Client.connection_opts()) :: map()
def collect_vendor_debug_data(profile, client_opts) do
# Collect all sensors using the same discovery logic
{:ok, all_sensors} = discover_sensors(profile, client_opts)
# Format sensors for debug display with state descriptions
discovered_sensors =
Enum.map(all_sensors, fn sensor ->
base = %{
oid: sensor.sensor_oid,
descr: sensor.sensor_descr,
type: sensor.sensor_type,
value: sensor.last_value,
unit: sensor.sensor_unit || ""
}
# Add state description if present
if Map.has_key?(sensor, :state_descr) do
Map.put(base, :state_descr, sensor.state_descr)
else
base
end
end)
# Also collect raw wireless OID values (including errors for debugging)
wireless_oids = get_vendor_wireless_oids(profile)
wireless_raw =
Map.new(wireless_oids, fn sensor_def ->
value =
case Client.get(client_opts, sensor_def.oid) do
{:ok, val} -> val
{:error, reason} -> "ERROR: #{inspect(reason)}"
end
{sensor_def.sensor_type, %{oid: sensor_def.oid, descr: sensor_def.sensor_descr, value: value}}
end)
%{
discovered_sensors: discovered_sensors,
wireless_sensors: wireless_raw
}
end
# Get the wireless OID definitions for a profile (for debug data collection)
defp get_vendor_wireless_oids(%{name: name}) do
VendorRegistry.get_wireless_oid_defs(name)
end
defp get_vendor_wireless_oids(_profile), do: []
# Vendor-specific wireless sensor discovery using vendor modules
defp discover_vendor_wireless_sensors(%{name: name}, client_opts) do
VendorRegistry.discover_wireless_sensors(name, client_opts)
end
defp discover_vendor_wireless_sensors(_profile, _client_opts), do: []
# Deduplicate sensors by OID - when multiple sensors share the same OID,
# prefer the one with a proper description (not containing MIB symbolic names).
# Normalizes OIDs by stripping leading dots so ".1.3.6.1..." and "1.3.6.1..."
# are treated as the same OID (gosnmp returns leading dots, definitions don't).
defp deduplicate_by_oid(sensors) do
sensors
|> Enum.group_by(&String.trim_leading(&1.sensor_oid, "."))
|> Enum.map(fn {_oid, group} ->
# Prefer sensor with proper description (no MIB symbolic name like "MIKROTIK-MIB::xxx")
# Check case-insensitively since build_sensor_descr capitalizes only first letter
Enum.find(group, fn s -> not has_mib_symbolic_name?(s.sensor_descr) end) ||
List.last(group)
end)
end
# Check if a description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName")
# These should be filtered out in favor of actual sensor names from the device
defp has_mib_symbolic_name?(descr) when is_binary(descr) do
# MIB symbolic names contain "::" pattern (case-insensitive check)
String.contains?(String.downcase(descr), "::")
end
defp has_mib_symbolic_name?(_), do: false
# Applies vendor-specific post-processing to discovered sensors.
# This allows vendors to enhance or transform sensors after base discovery.
# For example, Arista converts DOM power sensors from watts to dBm and adds
# vendor-specific thresholds.
@spec apply_vendor_post_processing(map(), [map()], Client.connection_opts()) :: [map()]
defp apply_vendor_post_processing(%{name: name} = _profile, sensors, client_opts) do
case name do
"arista_eos" ->
# Apply Arista-specific enhancements
alias Towerops.Snmp.Profiles.Vendors.Arista
Arista.post_process_sensors(sensors, client_opts)
"arista-mos" ->
# Apply Arista-specific enhancements
alias Towerops.Snmp.Profiles.Vendors.Arista
Arista.post_process_sensors(sensors, client_opts)
"dell-powervault" ->
# Parse text-based sensor messages from FCMGMT-MIB
alias Towerops.Snmp.Profiles.Vendors.Powervault
Powervault.post_process_sensors(sensors, client_opts)
_ ->
# No vendor-specific post-processing
sensors
end
end
defp apply_vendor_post_processing(_profile, sensors, _client_opts), do: sensors
end