better mib handling and tests
This commit is contained in:
parent
3abd70e133
commit
7c660169b7
12 changed files with 1015 additions and 125 deletions
222
lib/towerops/snmp/deferred_discovery.ex
Normal file
222
lib/towerops/snmp/deferred_discovery.ex
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
defmodule Towerops.Snmp.DeferredDiscovery do
|
||||
@moduledoc """
|
||||
Handles deferred/slow SNMP discovery checks with timeouts.
|
||||
|
||||
Based on LibreNMS's approach of separating fast checks (string matching on sysDescr/sysObjectID)
|
||||
from slow checks (snmpget/snmpwalk for detailed data).
|
||||
|
||||
Fast checks run immediately and are required for basic discovery.
|
||||
Slow checks (sensors, detailed interfaces) are deferred and can timeout gracefully
|
||||
without failing the entire discovery.
|
||||
|
||||
## Example
|
||||
|
||||
# Run discovery with deferred checks
|
||||
{:ok, results} = DeferredDiscovery.discover_with_deferred(device, [
|
||||
fast: [:system_info, :profile_selection],
|
||||
slow: [:sensors, :interfaces, :neighbors]
|
||||
])
|
||||
|
||||
# Results include which deferred checks succeeded or timed out
|
||||
%{
|
||||
system_info: %{...},
|
||||
sensors: {:ok, [...]}, # or {:timeout, []} if timed out
|
||||
interfaces: {:ok, [...]},
|
||||
deferred_status: %{sensors: :ok, interfaces: :ok, neighbors: :timeout}
|
||||
}
|
||||
"""
|
||||
|
||||
alias Towerops.Snmp.Client
|
||||
|
||||
require Logger
|
||||
|
||||
@default_fast_timeout 10_000
|
||||
@default_slow_timeout 30_000
|
||||
@default_deferred_timeout 60_000
|
||||
|
||||
@type check_type :: :fast | :slow | :deferred
|
||||
@type check_result :: {:ok, term()} | {:error, term()} | {:timeout, term()}
|
||||
|
||||
@doc """
|
||||
Runs a fast SNMP check with a short timeout.
|
||||
Fast checks are for quick string/regex matching operations.
|
||||
If they fail, discovery should abort.
|
||||
"""
|
||||
@spec fast_check(Client.connection_opts(), (-> term()), keyword()) :: check_result()
|
||||
def fast_check(client_opts, check_fn, opts \\ []) do
|
||||
timeout = Keyword.get(opts, :timeout, @default_fast_timeout)
|
||||
run_with_timeout(client_opts, check_fn, timeout, :fast)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs a slow SNMP check with a longer timeout.
|
||||
Slow checks are for operations that may require multiple SNMP walks.
|
||||
If they timeout, discovery can continue with partial data.
|
||||
"""
|
||||
@spec slow_check(Client.connection_opts(), (-> term()), keyword()) :: check_result()
|
||||
def slow_check(_client_opts, check_fn, opts \\ []) do
|
||||
timeout = Keyword.get(opts, :timeout, @default_slow_timeout)
|
||||
default_value = Keyword.get(opts, :default, [])
|
||||
|
||||
task = Task.async(fn -> check_fn.() end)
|
||||
|
||||
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
|
||||
{:ok, result} ->
|
||||
result
|
||||
|
||||
nil ->
|
||||
Logger.warning("Slow check timed out after #{timeout}ms, using default value")
|
||||
{:ok, default_value}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs a deferred SNMP check that can be retried later.
|
||||
Deferred checks are for non-critical data that's nice to have.
|
||||
If they fail or timeout, discovery succeeds with a note about what's missing.
|
||||
"""
|
||||
@spec deferred_check(Client.connection_opts(), (-> term()), keyword()) :: check_result()
|
||||
def deferred_check(_client_opts, check_fn, opts \\ []) do
|
||||
timeout = Keyword.get(opts, :timeout, @default_deferred_timeout)
|
||||
default_value = Keyword.get(opts, :default, [])
|
||||
check_name = Keyword.get(opts, :name, "unknown")
|
||||
|
||||
task = Task.async(fn -> check_fn.() end)
|
||||
|
||||
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
|
||||
{:ok, {:ok, result}} ->
|
||||
{:ok, result}
|
||||
|
||||
{:ok, {:error, reason}} ->
|
||||
Logger.debug("Deferred check '#{check_name}' failed: #{inspect(reason)}")
|
||||
{:deferred, default_value, reason}
|
||||
|
||||
nil ->
|
||||
Logger.info("Deferred check '#{check_name}' timed out after #{timeout}ms")
|
||||
{:timeout, default_value}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs multiple checks in parallel with individual timeouts.
|
||||
Returns results for all checks, with status for each.
|
||||
"""
|
||||
@spec parallel_checks([{atom(), (-> term()), keyword()}]) :: %{atom() => check_result()}
|
||||
def parallel_checks(checks) do
|
||||
checks
|
||||
|> Enum.map(fn {name, check_fn, opts} ->
|
||||
timeout = Keyword.get(opts, :timeout, @default_slow_timeout)
|
||||
default = Keyword.get(opts, :default, [])
|
||||
|
||||
task =
|
||||
Task.async(fn ->
|
||||
try do
|
||||
case check_fn.() do
|
||||
{:ok, result} -> {:ok, result}
|
||||
{:error, reason} -> {:error, reason}
|
||||
other -> {:ok, other}
|
||||
end
|
||||
rescue
|
||||
e -> {:error, Exception.message(e)}
|
||||
end
|
||||
end)
|
||||
|
||||
{name, task, timeout, default}
|
||||
end)
|
||||
|> Map.new(fn {name, task, timeout, default} ->
|
||||
result =
|
||||
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
|
||||
{:ok, {:ok, value}} -> {:ok, value}
|
||||
{:ok, {:error, reason}} -> {:error, reason}
|
||||
nil -> {:timeout, default}
|
||||
end
|
||||
|
||||
{name, result}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Tests if a device is responsive with a quick ping-like SNMP check.
|
||||
Uses sysObjectID which all SNMP devices must support.
|
||||
"""
|
||||
@spec device_responsive?(Client.connection_opts(), keyword()) :: boolean()
|
||||
def device_responsive?(client_opts, opts \\ []) do
|
||||
timeout = Keyword.get(opts, :timeout, 5_000)
|
||||
|
||||
task =
|
||||
Task.async(fn ->
|
||||
Client.get(client_opts, "1.3.6.1.2.1.1.2.0")
|
||||
end)
|
||||
|
||||
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
|
||||
{:ok, {:ok, _}} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Categorizes a device as fast or slow based on initial response time.
|
||||
Returns :fast, :slow, or :unresponsive.
|
||||
"""
|
||||
@spec categorize_device_speed(Client.connection_opts()) :: :fast | :slow | :unresponsive
|
||||
def categorize_device_speed(client_opts) do
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
case Client.get(client_opts, "1.3.6.1.2.1.1.1.0") do
|
||||
{:ok, _} ->
|
||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
cond do
|
||||
elapsed < 1_000 -> :fast
|
||||
elapsed < 5_000 -> :slow
|
||||
true -> :slow
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
:unresponsive
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns recommended timeouts based on device speed category.
|
||||
"""
|
||||
@spec timeouts_for_speed(:fast | :slow | :unresponsive) :: keyword()
|
||||
def timeouts_for_speed(:fast) do
|
||||
[
|
||||
fast: 5_000,
|
||||
slow: 15_000,
|
||||
deferred: 30_000
|
||||
]
|
||||
end
|
||||
|
||||
def timeouts_for_speed(:slow) do
|
||||
[
|
||||
fast: 15_000,
|
||||
slow: 45_000,
|
||||
deferred: 90_000
|
||||
]
|
||||
end
|
||||
|
||||
def timeouts_for_speed(:unresponsive) do
|
||||
[
|
||||
fast: 30_000,
|
||||
slow: 60_000,
|
||||
deferred: 120_000
|
||||
]
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp run_with_timeout(_client_opts, check_fn, timeout, check_type) do
|
||||
task = Task.async(fn -> check_fn.() end)
|
||||
|
||||
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
|
||||
{:ok, result} ->
|
||||
result
|
||||
|
||||
nil ->
|
||||
Logger.warning("#{check_type} check timed out after #{timeout}ms")
|
||||
{:error, :timeout}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -20,6 +20,7 @@ defmodule Towerops.Snmp.Discovery do
|
|||
alias Towerops.Profiles.DeviceProfile
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Snmp.DeferredDiscovery
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.NeighborDiscovery
|
||||
|
|
@ -104,45 +105,90 @@ defmodule Towerops.Snmp.Discovery do
|
|||
|
||||
client_opts = build_client_opts(device)
|
||||
|
||||
with {:ok, _} <- Client.test_connection(client_opts),
|
||||
{:ok, system_info} <- discover_system(client_opts),
|
||||
{:ok, device} <- update_device_name_from_snmp(device, system_info),
|
||||
profile = select_profile(system_info),
|
||||
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
|
||||
{:ok, interfaces} <- discover_interfaces(client_opts, profile),
|
||||
{:ok, sensors} <- discover_sensors(client_opts, profile),
|
||||
{:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors),
|
||||
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, discovered_device.interfaces),
|
||||
:ok <- save_neighbors(discovered_device.device_id, neighbors) do
|
||||
_ = update_device_discovery_time(device)
|
||||
Logger.info("SNMP discovery completed successfully for: #{device.name}")
|
||||
# Categorize device speed for adaptive timeouts
|
||||
device_speed = DeferredDiscovery.categorize_device_speed(client_opts)
|
||||
timeouts = DeferredDiscovery.timeouts_for_speed(device_speed)
|
||||
|
||||
# Determine if this was first discovery or rediscovery
|
||||
# Check if device existed before (had last_discovery_at set)
|
||||
is_rediscovery = device.last_discovery_at != nil
|
||||
|
||||
# Log discovery event
|
||||
log_discovery_event(device, discovered_device, is_rediscovery)
|
||||
|
||||
# Broadcast discovery completion for real-time updates
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:#{device.id}",
|
||||
{:discovery_completed, device.id}
|
||||
)
|
||||
|
||||
{:ok, discovered_device}
|
||||
if device_speed == :unresponsive do
|
||||
Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
|
||||
{:error, :device_unresponsive}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
|
||||
error
|
||||
Logger.debug("Device #{device.name} categorized as #{device_speed}, using adaptive timeouts")
|
||||
do_discover_device(device, client_opts, timeouts)
|
||||
end
|
||||
else
|
||||
{:error, :snmp_not_enabled}
|
||||
end
|
||||
end
|
||||
|
||||
# Internal discovery with adaptive timeouts based on device speed
|
||||
defp do_discover_device(device, client_opts, timeouts) do
|
||||
with {:ok, _} <- Client.test_connection(client_opts),
|
||||
{:ok, system_info} <- discover_system(client_opts),
|
||||
{:ok, device} <- update_device_name_from_snmp(device, system_info),
|
||||
profile = select_profile(system_info),
|
||||
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
|
||||
{:ok, interfaces} <- discover_interfaces_with_timeout(client_opts, profile, timeouts),
|
||||
{:ok, sensors} <- discover_sensors_with_timeout(client_opts, profile, timeouts),
|
||||
{:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors),
|
||||
{:ok, neighbors} <- discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts),
|
||||
:ok <- save_neighbors(discovered_device.device_id, neighbors) do
|
||||
_ = update_device_discovery_time(device)
|
||||
Logger.info("SNMP discovery completed successfully for: #{device.name}")
|
||||
|
||||
# Determine if this was first discovery or rediscovery
|
||||
# Check if device existed before (had last_discovery_at set)
|
||||
is_rediscovery = device.last_discovery_at != nil
|
||||
|
||||
# Log discovery event
|
||||
log_discovery_event(device, discovered_device, is_rediscovery)
|
||||
|
||||
# Broadcast discovery completion for real-time updates
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:#{device.id}",
|
||||
{:discovery_completed, device.id}
|
||||
)
|
||||
|
||||
{:ok, discovered_device}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# Interface discovery with timeout - falls back to empty list on timeout
|
||||
defp discover_interfaces_with_timeout(client_opts, profile, timeouts) do
|
||||
DeferredDiscovery.slow_check(
|
||||
client_opts,
|
||||
fn -> discover_interfaces(client_opts, profile) end,
|
||||
timeout: timeouts[:slow],
|
||||
default: []
|
||||
)
|
||||
end
|
||||
|
||||
# Sensor discovery with timeout - falls back to empty list on timeout
|
||||
defp discover_sensors_with_timeout(client_opts, profile, timeouts) do
|
||||
DeferredDiscovery.slow_check(
|
||||
client_opts,
|
||||
fn -> discover_sensors(client_opts, profile) end,
|
||||
timeout: timeouts[:slow],
|
||||
default: []
|
||||
)
|
||||
end
|
||||
|
||||
# Neighbor discovery with timeout - falls back to empty list on timeout
|
||||
defp discover_neighbors_with_timeout(client_opts, interfaces, timeouts) do
|
||||
DeferredDiscovery.slow_check(
|
||||
client_opts,
|
||||
fn -> NeighborDiscovery.discover_neighbors(client_opts, interfaces) end,
|
||||
timeout: timeouts[:deferred],
|
||||
default: []
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs discovery for all SNMP-enabled devices in an organization.
|
||||
Returns a summary of successful and failed discoveries.
|
||||
|
|
|
|||
|
|
@ -5,11 +5,88 @@ defmodule Towerops.Snmp.MibTranslator do
|
|||
Uses the system's snmptranslate command to resolve MIB names like
|
||||
"MIKROTIK-MIB::mtxrSerialNumber.0" to numeric OIDs like "1.3.6.1.4.1.14988.1.1.7.3.0".
|
||||
|
||||
Requires net-snmp package to be installed and MIB files to be available.
|
||||
When MIB files are not available or snmptranslate fails, falls back to a built-in
|
||||
registry of common OID mappings (similar to LibreNMS's approach).
|
||||
|
||||
Requires net-snmp package to be installed for best results, but works without it.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# Fallback OID registry for when MIB translation fails
|
||||
# Organized by MIB name for easy maintenance
|
||||
@fallback_oids %{
|
||||
# SNMPv2-MIB (standard, but included for completeness)
|
||||
"SNMPv2-MIB::sysDescr.0" => "1.3.6.1.2.1.1.1.0",
|
||||
"SNMPv2-MIB::sysObjectID.0" => "1.3.6.1.2.1.1.2.0",
|
||||
"SNMPv2-MIB::sysUpTime.0" => "1.3.6.1.2.1.1.3.0",
|
||||
"SNMPv2-MIB::sysContact.0" => "1.3.6.1.2.1.1.4.0",
|
||||
"SNMPv2-MIB::sysName.0" => "1.3.6.1.2.1.1.5.0",
|
||||
"SNMPv2-MIB::sysLocation.0" => "1.3.6.1.2.1.1.6.0",
|
||||
# MIKROTIK-MIB (enterprise 14988)
|
||||
"MIKROTIK-MIB::mtxrSerialNumber.0" => "1.3.6.1.4.1.14988.1.1.7.3.0",
|
||||
"MIKROTIK-MIB::mtxrFirmwareVersion.0" => "1.3.6.1.4.1.14988.1.1.7.4.0",
|
||||
"MIKROTIK-MIB::mtxrLicenseLevel.0" => "1.3.6.1.4.1.14988.1.1.7.5.0",
|
||||
"MIKROTIK-MIB::mtxrBuildTime.0" => "1.3.6.1.4.1.14988.1.1.7.6.0",
|
||||
"MIKROTIK-MIB::mtxrSystemDisplayName.0" => "1.3.6.1.4.1.14988.1.1.7.8.0",
|
||||
"MIKROTIK-MIB::mtxrBoardName.0" => "1.3.6.1.4.1.14988.1.1.7.9.0",
|
||||
"MIKROTIK-MIB::mtxrLicenseVersion.0" => "1.3.6.1.4.1.14988.1.1.7.5.0",
|
||||
# MikroTik Health sensors
|
||||
"MIKROTIK-MIB::mtxrHlCoreVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.1.0",
|
||||
"MIKROTIK-MIB::mtxrHlThreeDotThreeVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.2.0",
|
||||
"MIKROTIK-MIB::mtxrHlFiveVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.3.0",
|
||||
"MIKROTIK-MIB::mtxrHlTwelveVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.4.0",
|
||||
"MIKROTIK-MIB::mtxrHlSensorTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.5.0",
|
||||
"MIKROTIK-MIB::mtxrHlCpuTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.6.0",
|
||||
"MIKROTIK-MIB::mtxrHlBoardTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.7.0",
|
||||
"MIKROTIK-MIB::mtxrHlVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.8.0",
|
||||
"MIKROTIK-MIB::mtxrHlActiveFan.0" => "1.3.6.1.4.1.14988.1.1.3.9.0",
|
||||
"MIKROTIK-MIB::mtxrHlTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.10.0",
|
||||
"MIKROTIK-MIB::mtxrHlProcessorTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.11.0",
|
||||
"MIKROTIK-MIB::mtxrHlPower.0" => "1.3.6.1.4.1.14988.1.1.3.12.0",
|
||||
"MIKROTIK-MIB::mtxrHlCurrent.0" => "1.3.6.1.4.1.14988.1.1.3.13.0",
|
||||
"MIKROTIK-MIB::mtxrHlProcessorFrequency.0" => "1.3.6.1.4.1.14988.1.1.3.14.0",
|
||||
"MIKROTIK-MIB::mtxrHlPowerSupplyState.0" => "1.3.6.1.4.1.14988.1.1.3.15.0",
|
||||
"MIKROTIK-MIB::mtxrHlBackupPowerSupplyState.0" => "1.3.6.1.4.1.14988.1.1.3.16.0",
|
||||
"MIKROTIK-MIB::mtxrHlFanSpeed1.0" => "1.3.6.1.4.1.14988.1.1.3.17.0",
|
||||
"MIKROTIK-MIB::mtxrHlFanSpeed2.0" => "1.3.6.1.4.1.14988.1.1.3.18.0",
|
||||
# CISCO-ENTITY-SENSOR-MIB (enterprise 9.9.91)
|
||||
"CISCO-ENTITY-SENSOR-MIB::entSensorType" => "1.3.6.1.4.1.9.9.91.1.1.1.1.1",
|
||||
"CISCO-ENTITY-SENSOR-MIB::entSensorScale" => "1.3.6.1.4.1.9.9.91.1.1.1.1.2",
|
||||
"CISCO-ENTITY-SENSOR-MIB::entSensorPrecision" => "1.3.6.1.4.1.9.9.91.1.1.1.1.3",
|
||||
"CISCO-ENTITY-SENSOR-MIB::entSensorValue" => "1.3.6.1.4.1.9.9.91.1.1.1.1.4",
|
||||
"CISCO-ENTITY-SENSOR-MIB::entSensorStatus" => "1.3.6.1.4.1.9.9.91.1.1.1.1.5",
|
||||
# ENTITY-MIB (standard RFC 2737)
|
||||
"ENTITY-MIB::entPhysicalDescr" => "1.3.6.1.2.1.47.1.1.1.1.2",
|
||||
"ENTITY-MIB::entPhysicalVendorType" => "1.3.6.1.2.1.47.1.1.1.1.3",
|
||||
"ENTITY-MIB::entPhysicalContainedIn" => "1.3.6.1.2.1.47.1.1.1.1.4",
|
||||
"ENTITY-MIB::entPhysicalClass" => "1.3.6.1.2.1.47.1.1.1.1.5",
|
||||
"ENTITY-MIB::entPhysicalName" => "1.3.6.1.2.1.47.1.1.1.1.7",
|
||||
"ENTITY-MIB::entPhysicalSerialNum" => "1.3.6.1.2.1.47.1.1.1.1.11",
|
||||
"ENTITY-MIB::entPhysicalModelName" => "1.3.6.1.2.1.47.1.1.1.1.13",
|
||||
# ENTITY-SENSOR-MIB (RFC 3433)
|
||||
"ENTITY-SENSOR-MIB::entPhySensorType" => "1.3.6.1.2.1.99.1.1.1.1",
|
||||
"ENTITY-SENSOR-MIB::entPhySensorScale" => "1.3.6.1.2.1.99.1.1.1.2",
|
||||
"ENTITY-SENSOR-MIB::entPhySensorPrecision" => "1.3.6.1.2.1.99.1.1.1.3",
|
||||
"ENTITY-SENSOR-MIB::entPhySensorValue" => "1.3.6.1.2.1.99.1.1.1.4",
|
||||
"ENTITY-SENSOR-MIB::entPhySensorOperStatus" => "1.3.6.1.2.1.99.1.1.1.5",
|
||||
# LM-SENSORS-MIB (for Linux/NetSNMP)
|
||||
"LM-SENSORS-MIB::lmTempSensorsDevice" => "1.3.6.1.4.1.2021.13.16.2.1.2",
|
||||
"LM-SENSORS-MIB::lmTempSensorsValue" => "1.3.6.1.4.1.2021.13.16.2.1.3",
|
||||
"LM-SENSORS-MIB::lmFanSensorsDevice" => "1.3.6.1.4.1.2021.13.16.3.1.2",
|
||||
"LM-SENSORS-MIB::lmFanSensorsValue" => "1.3.6.1.4.1.2021.13.16.3.1.3",
|
||||
"LM-SENSORS-MIB::lmVoltSensorsDevice" => "1.3.6.1.4.1.2021.13.16.4.1.2",
|
||||
"LM-SENSORS-MIB::lmVoltSensorsValue" => "1.3.6.1.4.1.2021.13.16.4.1.3",
|
||||
# UCD-SNMP-MIB (for Linux/NetSNMP)
|
||||
"UCD-SNMP-MIB::memTotalReal" => "1.3.6.1.4.1.2021.4.5.0",
|
||||
"UCD-SNMP-MIB::memAvailReal" => "1.3.6.1.4.1.2021.4.6.0",
|
||||
"UCD-SNMP-MIB::memTotalSwap" => "1.3.6.1.4.1.2021.4.3.0",
|
||||
"UCD-SNMP-MIB::memAvailSwap" => "1.3.6.1.4.1.2021.4.4.0",
|
||||
# Ubiquiti (enterprise 41112)
|
||||
"UBNT-UniFi-MIB::unifiApSystemModel" => "1.3.6.1.4.1.41112.1.6.3.3.0",
|
||||
"UBNT-UniFi-MIB::unifiApSystemVersion" => "1.3.6.1.4.1.41112.1.6.3.6.0"
|
||||
}
|
||||
|
||||
@doc """
|
||||
Translates a MIB symbolic name to a numeric OID.
|
||||
|
||||
|
|
@ -56,6 +133,18 @@ defmodule Towerops.Snmp.MibTranslator do
|
|||
end
|
||||
|
||||
defp translate_mib_name(mib_name) do
|
||||
# First try snmptranslate
|
||||
case try_snmptranslate(mib_name) do
|
||||
{:ok, oid} ->
|
||||
{:ok, oid}
|
||||
|
||||
{:error, _reason} ->
|
||||
# Fall back to built-in registry
|
||||
try_fallback_registry(mib_name)
|
||||
end
|
||||
end
|
||||
|
||||
defp try_snmptranslate(mib_name) do
|
||||
# Get MIB directories from config or use default
|
||||
mib_dirs = Application.get_env(:towerops, :mib_dirs, ["/app/priv/mibs", "/usr/share/snmp/mibs"])
|
||||
|
||||
|
|
@ -77,17 +166,40 @@ defmodule Towerops.Snmp.MibTranslator do
|
|||
oid = String.trim(output)
|
||||
{:ok, oid}
|
||||
|
||||
{error_output, _exit_code} ->
|
||||
Logger.warning("Failed to translate MIB name #{mib_name}: #{error_output}")
|
||||
{_error_output, _exit_code} ->
|
||||
{:error, :translation_failed}
|
||||
end
|
||||
rescue
|
||||
e in ErlangError ->
|
||||
_e in ErlangError ->
|
||||
# snmptranslate command not found or other system error
|
||||
Logger.error("snmptranslate command failed: #{inspect(e)}")
|
||||
{:error, :command_not_found}
|
||||
end
|
||||
|
||||
defp try_fallback_registry(mib_name) do
|
||||
case Map.get(@fallback_oids, mib_name) do
|
||||
nil ->
|
||||
# Try matching without the instance suffix (e.g., ".0")
|
||||
base_name = String.replace(mib_name, ~r/\.\d+$/, "")
|
||||
|
||||
case Map.get(@fallback_oids, base_name) do
|
||||
nil ->
|
||||
Logger.warning("MIB translation failed and no fallback found for: #{mib_name}")
|
||||
|
||||
{:error, :translation_failed}
|
||||
|
||||
oid ->
|
||||
# Append the instance suffix back
|
||||
suffix = String.replace(mib_name, base_name, "")
|
||||
Logger.debug("Using fallback OID for #{mib_name}: #{oid}#{suffix}")
|
||||
{:ok, oid <> suffix}
|
||||
end
|
||||
|
||||
oid ->
|
||||
Logger.debug("Using fallback OID for #{mib_name}: #{oid}")
|
||||
{:ok, oid}
|
||||
end
|
||||
end
|
||||
|
||||
# Expand a MIB directory to include itself and all subdirectories
|
||||
# snmptranslate doesn't search recursively, so we need to explicitly list all dirs
|
||||
defp expand_mib_directory(dir) do
|
||||
|
|
|
|||
|
|
@ -44,8 +44,26 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
# ENTITY-SENSOR-MIB - Generic sensor support
|
||||
ent_phys_sensor_type: "1.3.6.1.2.1.99.1.1.1.1",
|
||||
ent_phys_sensor_scale: "1.3.6.1.2.1.99.1.1.1.2",
|
||||
ent_phys_sensor_precision: "1.3.6.1.2.1.99.1.1.1.3",
|
||||
ent_phys_sensor_value: "1.3.6.1.2.1.99.1.1.1.4",
|
||||
ent_phys_sensor_oper_status: "1.3.6.1.2.1.99.1.1.1.5"
|
||||
ent_phys_sensor_oper_status: "1.3.6.1.2.1.99.1.1.1.5",
|
||||
# Sensor thresholds (from ENTITY-SENSOR-MIB)
|
||||
ent_phys_sensor_units_display: "1.3.6.1.2.1.99.1.1.1.6",
|
||||
ent_phys_sensor_value_timestamp: "1.3.6.1.2.1.99.1.1.1.7"
|
||||
}
|
||||
|
||||
@entity_mib_oids %{
|
||||
# ENTITY-MIB - Physical entity information (for sensor descriptions)
|
||||
ent_phys_descr: "1.3.6.1.2.1.47.1.1.1.1.2",
|
||||
ent_phys_vendor_type: "1.3.6.1.2.1.47.1.1.1.1.3",
|
||||
ent_phys_contained_in: "1.3.6.1.2.1.47.1.1.1.1.4",
|
||||
ent_phys_class: "1.3.6.1.2.1.47.1.1.1.1.5",
|
||||
ent_phys_name: "1.3.6.1.2.1.47.1.1.1.1.7",
|
||||
ent_phys_hardware_rev: "1.3.6.1.2.1.47.1.1.1.1.8",
|
||||
ent_phys_firmware_rev: "1.3.6.1.2.1.47.1.1.1.1.9",
|
||||
ent_phys_software_rev: "1.3.6.1.2.1.47.1.1.1.1.10",
|
||||
ent_phys_serial_num: "1.3.6.1.2.1.47.1.1.1.1.11",
|
||||
ent_phys_model_name: "1.3.6.1.2.1.47.1.1.1.1.13"
|
||||
}
|
||||
|
||||
@doc """
|
||||
|
|
@ -105,7 +123,8 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Discovers sensors from ENTITY-SENSOR-MIB.
|
||||
Discovers sensors from ENTITY-SENSOR-MIB with descriptions from ENTITY-MIB.
|
||||
This is the universal baseline sensor discovery that works across many vendors.
|
||||
Returns a list of sensor maps.
|
||||
"""
|
||||
@spec discover_sensors(Client.connection_opts()) ::
|
||||
|
|
@ -117,13 +136,17 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
# Device supports ENTITY-SENSOR-MIB
|
||||
sensor_indices = extract_indices_from_oids(sensor_types)
|
||||
|
||||
# Pre-fetch entity descriptions for all sensors (more efficient than per-sensor queries)
|
||||
entity_descriptions = fetch_entity_descriptions(client_opts, sensor_indices)
|
||||
|
||||
sensors =
|
||||
sensor_indices
|
||||
|> Enum.map(fn index ->
|
||||
build_sensor_from_entity_mib(client_opts, index)
|
||||
build_sensor_from_entity_mib(client_opts, index, entity_descriptions)
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
Logger.debug("Discovered #{length(sensors)} sensors from ENTITY-SENSOR-MIB")
|
||||
{:ok, sensors}
|
||||
|
||||
_ ->
|
||||
|
|
@ -133,6 +156,46 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches physical entity descriptions from ENTITY-MIB for sensor naming.
|
||||
Returns a map of index => %{descr: "...", name: "...", class: ...}
|
||||
"""
|
||||
@spec fetch_entity_descriptions(Client.connection_opts(), [integer()]) :: map()
|
||||
def fetch_entity_descriptions(client_opts, indices) do
|
||||
# Walk entity physical table once to get all descriptions
|
||||
descr_results =
|
||||
case Client.walk(client_opts, @entity_mib_oids.ent_phys_descr) do
|
||||
{:ok, results} -> results
|
||||
_ -> %{}
|
||||
end
|
||||
|
||||
name_results =
|
||||
case Client.walk(client_opts, @entity_mib_oids.ent_phys_name) do
|
||||
{:ok, results} -> results
|
||||
_ -> %{}
|
||||
end
|
||||
|
||||
class_results =
|
||||
case Client.walk(client_opts, @entity_mib_oids.ent_phys_class) do
|
||||
{:ok, results} -> results
|
||||
_ -> %{}
|
||||
end
|
||||
|
||||
# Build a map of index => entity info
|
||||
Map.new(indices, fn index ->
|
||||
descr_oid = @entity_mib_oids.ent_phys_descr <> ".#{index}"
|
||||
name_oid = @entity_mib_oids.ent_phys_name <> ".#{index}"
|
||||
class_oid = @entity_mib_oids.ent_phys_class <> ".#{index}"
|
||||
|
||||
{index,
|
||||
%{
|
||||
descr: Map.get(descr_results, descr_oid),
|
||||
name: Map.get(name_results, name_oid),
|
||||
class: Map.get(class_results, class_oid)
|
||||
}}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Identifies device manufacturer and model from sysDescr and sysObjectID.
|
||||
Can be overridden by vendor-specific profiles.
|
||||
|
|
@ -211,25 +274,34 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
end
|
||||
|
||||
defp build_sensor_from_entity_mib(client_opts, index) do
|
||||
defp build_sensor_from_entity_mib(client_opts, index, entity_descriptions) do
|
||||
oids = [
|
||||
@entity_sensor_oids.ent_phys_sensor_type <> ".#{index}",
|
||||
@entity_sensor_oids.ent_phys_sensor_scale <> ".#{index}",
|
||||
@entity_sensor_oids.ent_phys_sensor_precision <> ".#{index}",
|
||||
@entity_sensor_oids.ent_phys_sensor_value <> ".#{index}",
|
||||
@entity_sensor_oids.ent_phys_sensor_oper_status <> ".#{index}"
|
||||
]
|
||||
|
||||
case Client.get_multiple(client_opts, oids) do
|
||||
{:ok, [type, scale, value, status]} ->
|
||||
{:ok, [type, scale, precision, value, status]} ->
|
||||
sensor_type = parse_sensor_type(type)
|
||||
entity_info = Map.get(entity_descriptions, index, %{})
|
||||
|
||||
# Use ENTITY-MIB description/name for better sensor names
|
||||
sensor_descr = get_sensor_description(entity_info, index)
|
||||
|
||||
%{
|
||||
sensor_type: parse_sensor_type(type),
|
||||
sensor_type: sensor_type,
|
||||
sensor_index: "#{index}",
|
||||
sensor_oid: @entity_sensor_oids.ent_phys_sensor_value <> ".#{index}",
|
||||
sensor_descr: "Sensor #{index}",
|
||||
sensor_unit: sensor_type_to_unit(parse_sensor_type(type)),
|
||||
sensor_divisor: scale_to_divisor(scale),
|
||||
sensor_descr: sensor_descr,
|
||||
sensor_unit: sensor_type_to_unit(sensor_type),
|
||||
sensor_divisor: calculate_divisor(scale, precision),
|
||||
last_value: parse_float(value),
|
||||
status: parse_sensor_status(status)
|
||||
status: parse_sensor_status(status),
|
||||
# Include entity class for filtering (sensor=8)
|
||||
entity_class: Map.get(entity_info, :class)
|
||||
}
|
||||
|
||||
_ ->
|
||||
|
|
@ -237,6 +309,40 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
end
|
||||
|
||||
# Get best available description from ENTITY-MIB
|
||||
defp get_sensor_description(%{name: name}, _index) when is_binary(name) and byte_size(name) > 0 do
|
||||
name
|
||||
end
|
||||
|
||||
defp get_sensor_description(%{descr: descr}, _index) when is_binary(descr) and byte_size(descr) > 0 do
|
||||
descr
|
||||
end
|
||||
|
||||
defp get_sensor_description(_, index), do: "Sensor #{index}"
|
||||
|
||||
# Scale factors for ENTITY-SENSOR-MIB (10^scale exponent)
|
||||
@scale_factors %{
|
||||
-24 => 1_000_000_000_000_000_000_000_000,
|
||||
-12 => 1_000_000_000_000,
|
||||
-9 => 1_000_000_000,
|
||||
-6 => 1_000_000,
|
||||
-3 => 1_000,
|
||||
0 => 1,
|
||||
3 => 0.001,
|
||||
6 => 0.000001
|
||||
}
|
||||
|
||||
# Calculate divisor from scale and precision (like LibreNMS)
|
||||
# Scale: 10^scale exponent, Precision: decimal places
|
||||
defp calculate_divisor(scale, precision) when is_integer(scale) and is_integer(precision) do
|
||||
scale_factor = Map.get(@scale_factors, scale, 1)
|
||||
precision_factor = :math.pow(10, precision)
|
||||
scale_factor * precision_factor
|
||||
end
|
||||
|
||||
defp calculate_divisor(scale, _) when is_integer(scale), do: scale_to_divisor(scale)
|
||||
defp calculate_divisor(_, _), do: 1
|
||||
|
||||
defp extract_indices_from_oids(oid_map) do
|
||||
oid_map
|
||||
|> Map.keys()
|
||||
|
|
|
|||
3
mix.lock
3
mix.lock
|
|
@ -65,14 +65,13 @@
|
|||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
||||
"styler": {:hex, :styler, "1.10.1", "9229050c978bfaaab1d94e8673843576d0127d48fe64824a30babde3d6342475", [:mix], [], "hexpm", "d86cbcc70e8ab424393af313d1d885931ba9dc7c383d7dd30f4ab255a8d39f73"},
|
||||
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
|
||||
"swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
|
||||
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
|
|
|
|||
298
test/towerops/snmp/deferred_discovery_test.exs
Normal file
298
test/towerops/snmp/deferred_discovery_test.exs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
defmodule Towerops.Snmp.DeferredDiscoveryTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.DeferredDiscovery
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "fast_check/3" do
|
||||
test "returns result when check completes within timeout" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.fast_check(client_opts, fn ->
|
||||
{:ok, "test result"}
|
||||
end)
|
||||
|
||||
assert result == {:ok, "test result"}
|
||||
end
|
||||
|
||||
test "returns error when check times out" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.fast_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
Process.sleep(500)
|
||||
{:ok, "too slow"}
|
||||
end,
|
||||
timeout: 50
|
||||
)
|
||||
|
||||
assert result == {:error, :timeout}
|
||||
end
|
||||
|
||||
test "returns error result from check function" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.fast_check(client_opts, fn ->
|
||||
{:error, :connection_failed}
|
||||
end)
|
||||
|
||||
assert result == {:error, :connection_failed}
|
||||
end
|
||||
end
|
||||
|
||||
describe "slow_check/3" do
|
||||
test "returns result when check completes" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.slow_check(client_opts, fn ->
|
||||
{:ok, ["sensor1", "sensor2"]}
|
||||
end)
|
||||
|
||||
assert result == {:ok, ["sensor1", "sensor2"]}
|
||||
end
|
||||
|
||||
test "returns default value when check times out" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.slow_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
Process.sleep(500)
|
||||
{:ok, "too slow"}
|
||||
end,
|
||||
timeout: 50,
|
||||
default: []
|
||||
)
|
||||
|
||||
assert result == {:ok, []}
|
||||
end
|
||||
|
||||
test "uses custom default value on timeout" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.slow_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
Process.sleep(500)
|
||||
{:ok, "too slow"}
|
||||
end,
|
||||
timeout: 50,
|
||||
default: %{status: :timeout}
|
||||
)
|
||||
|
||||
assert result == {:ok, %{status: :timeout}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "deferred_check/3" do
|
||||
test "returns ok result when check succeeds" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.deferred_check(client_opts, fn ->
|
||||
{:ok, ["neighbor1", "neighbor2"]}
|
||||
end)
|
||||
|
||||
assert result == {:ok, ["neighbor1", "neighbor2"]}
|
||||
end
|
||||
|
||||
test "returns deferred status when check fails" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.deferred_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
{:error, :not_supported}
|
||||
end,
|
||||
name: "neighbors"
|
||||
)
|
||||
|
||||
assert {:deferred, [], :not_supported} = result
|
||||
end
|
||||
|
||||
test "returns timeout status when check times out" do
|
||||
client_opts = %{host: "192.168.1.1"}
|
||||
|
||||
result =
|
||||
DeferredDiscovery.deferred_check(
|
||||
client_opts,
|
||||
fn ->
|
||||
Process.sleep(500)
|
||||
{:ok, "too slow"}
|
||||
end,
|
||||
timeout: 50,
|
||||
name: "neighbors"
|
||||
)
|
||||
|
||||
assert {:timeout, []} = result
|
||||
end
|
||||
end
|
||||
|
||||
describe "parallel_checks/1" do
|
||||
test "runs multiple checks in parallel" do
|
||||
checks = [
|
||||
{:sensors, fn -> {:ok, ["temp1", "temp2"]} end, []},
|
||||
{:interfaces, fn -> {:ok, ["eth0", "eth1"]} end, []},
|
||||
{:neighbors, fn -> {:ok, ["switch1"]} end, []}
|
||||
]
|
||||
|
||||
results = DeferredDiscovery.parallel_checks(checks)
|
||||
|
||||
assert map_size(results) == 3
|
||||
assert {:ok, ["temp1", "temp2"]} = results[:sensors]
|
||||
assert {:ok, ["eth0", "eth1"]} = results[:interfaces]
|
||||
assert {:ok, ["switch1"]} = results[:neighbors]
|
||||
end
|
||||
|
||||
test "handles mixed success and failure results" do
|
||||
checks = [
|
||||
{:sensors, fn -> {:ok, ["temp1"]} end, []},
|
||||
{:interfaces, fn -> {:error, :walk_failed} end, []},
|
||||
{:neighbors, fn -> {:ok, []} end, []}
|
||||
]
|
||||
|
||||
results = DeferredDiscovery.parallel_checks(checks)
|
||||
|
||||
assert {:ok, ["temp1"]} = results[:sensors]
|
||||
assert {:error, :walk_failed} = results[:interfaces]
|
||||
assert {:ok, []} = results[:neighbors]
|
||||
end
|
||||
|
||||
test "handles timeout with default value" do
|
||||
checks = [
|
||||
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
|
||||
{:slow_check,
|
||||
fn ->
|
||||
Process.sleep(500)
|
||||
{:ok, "slow"}
|
||||
end, [timeout: 50, default: []]}
|
||||
]
|
||||
|
||||
results = DeferredDiscovery.parallel_checks(checks)
|
||||
|
||||
assert {:ok, "fast"} = results[:fast_check]
|
||||
assert {:timeout, []} = results[:slow_check]
|
||||
end
|
||||
|
||||
test "handles exceptions gracefully" do
|
||||
checks = [
|
||||
{:good_check, fn -> {:ok, "good"} end, []},
|
||||
{:bad_check, fn -> raise "something went wrong" end, []}
|
||||
]
|
||||
|
||||
results = DeferredDiscovery.parallel_checks(checks)
|
||||
|
||||
assert {:ok, "good"} = results[:good_check]
|
||||
assert {:error, "something went wrong"} = results[:bad_check]
|
||||
end
|
||||
|
||||
test "handles empty list" do
|
||||
results = DeferredDiscovery.parallel_checks([])
|
||||
assert results == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "device_responsive?/2" do
|
||||
test "returns true when device responds" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:ok, [1, 3, 6, 1, 4, 1, 9]}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.device_responsive?(client_opts) == true
|
||||
end
|
||||
|
||||
test "returns false when device does not respond" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:error, :timeout}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.device_responsive?(client_opts) == false
|
||||
end
|
||||
|
||||
test "returns false when check times out" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
Process.sleep(500)
|
||||
{:ok, [1, 3, 6, 1, 4, 1, 9]}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.device_responsive?(client_opts, timeout: 50) == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "categorize_device_speed/1" do
|
||||
test "returns :fast for quick responses" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:ok, "Fast Device"}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.categorize_device_speed(client_opts) == :fast
|
||||
end
|
||||
|
||||
test "returns :slow for delayed responses" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
Process.sleep(1100)
|
||||
{:ok, "Slow Device"}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.categorize_device_speed(client_opts) == :slow
|
||||
end
|
||||
|
||||
test "returns :unresponsive when device errors" do
|
||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:error, :timeout}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
|
||||
assert DeferredDiscovery.categorize_device_speed(client_opts) == :unresponsive
|
||||
end
|
||||
end
|
||||
|
||||
describe "timeouts_for_speed/1" do
|
||||
test "returns short timeouts for fast devices" do
|
||||
timeouts = DeferredDiscovery.timeouts_for_speed(:fast)
|
||||
|
||||
assert timeouts[:fast] == 5_000
|
||||
assert timeouts[:slow] == 15_000
|
||||
assert timeouts[:deferred] == 30_000
|
||||
end
|
||||
|
||||
test "returns medium timeouts for slow devices" do
|
||||
timeouts = DeferredDiscovery.timeouts_for_speed(:slow)
|
||||
|
||||
assert timeouts[:fast] == 15_000
|
||||
assert timeouts[:slow] == 45_000
|
||||
assert timeouts[:deferred] == 90_000
|
||||
end
|
||||
|
||||
test "returns long timeouts for unresponsive devices" do
|
||||
timeouts = DeferredDiscovery.timeouts_for_speed(:unresponsive)
|
||||
|
||||
assert timeouts[:fast] == 30_000
|
||||
assert timeouts[:slow] == 60_000
|
||||
assert timeouts[:deferred] == 120_000
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -265,12 +265,12 @@ defmodule Towerops.Snmp.DiscoveryTest do
|
|||
snmp_port: 161
|
||||
})
|
||||
|
||||
# Mock failed connection test
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
# Mock failed categorize_device_speed - device unresponsive
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.1.0", _ ->
|
||||
{:error, :timeout}
|
||||
end)
|
||||
|
||||
assert {:error, :timeout} = Discovery.discover_device(device)
|
||||
assert {:error, :device_unresponsive} = Discovery.discover_device(device)
|
||||
end
|
||||
|
||||
test "handles partial discovery failure gracefully", %{site: site} do
|
||||
|
|
@ -368,31 +368,33 @@ defmodule Towerops.Snmp.DiscoveryTest do
|
|||
})
|
||||
|> Repo.insert()
|
||||
|
||||
# Mock connection and system info for re-discovery
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
{:ok, {:timeticks, 12_345}}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :get, 6, fn _, oid, _ ->
|
||||
# Mock all SNMP get calls (categorize_device_speed + connection test + system info)
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.1.1.0" ->
|
||||
{:ok, {:octet_string, "Linux server 5.4.0"}}
|
||||
|
||||
"1.3.6.1.2.1.1.2.0" ->
|
||||
{:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 8072, 3, 2, 10]}}
|
||||
|
||||
"1.3.6.1.2.1.1.3.0" ->
|
||||
{:ok, {:timeticks, 12_345}}
|
||||
|
||||
"1.3.6.1.2.1.1.4.0" ->
|
||||
{:ok, {:octet_string, "admin@example.com"}}
|
||||
|
||||
"1.3.6.1.2.1.1.5.0" ->
|
||||
{:ok, {:octet_string, "updated-name"}}
|
||||
|
||||
"1.3.6.1.2.1.1.6.0" ->
|
||||
{:ok, {:octet_string, "Server Room"}}
|
||||
|
||||
_ ->
|
||||
{:ok, {:octet_string, ""}}
|
||||
{:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
# NetSnmp also tries UCD sensors (load, memory, disk) which make additional get calls
|
||||
stub(SnmpMock, :get, fn _, _, _ ->
|
||||
{:error, :no_such_object}
|
||||
end)
|
||||
|
||||
# NetSnmp profile makes many walk calls for sensors
|
||||
# Interfaces (1), LM sensors (6), UCD sensors (3), ENTITY-SENSOR-MIB fallback (1)
|
||||
stub(SnmpMock, :walk, fn _, _, _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
|
@ -911,7 +913,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
|
|||
|
||||
assert summary.success == 1
|
||||
assert summary.failed == 1
|
||||
assert :auth_failure in summary.errors
|
||||
# Device that fails initial responsiveness check is marked as unresponsive
|
||||
assert :device_unresponsive in summary.errors
|
||||
|
||||
# Verify only good device was created
|
||||
assert Repo.get_by(Device, device_id: device1.id)
|
||||
|
|
@ -933,14 +936,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
|
|||
snmp_port: 161
|
||||
})
|
||||
|
||||
# Mock successful connection test
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
{:ok, {:timeticks, 12_345}}
|
||||
end)
|
||||
|
||||
# Mock system info discovery with sysName + UCD sensor OIDs
|
||||
# 6 system info OIDs + 3 load OIDs + 2 memory OIDs = 11 total
|
||||
expect(SnmpMock, :get, 11, fn _, oid, _ ->
|
||||
# Mock all SNMP get calls (categorize_device_speed + connection test + system info + UCD sensors)
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.1.1.0" ->
|
||||
{:ok, {:octet_string, "Linux switch01 4.4.0"}}
|
||||
|
|
@ -965,15 +962,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
|
|||
end
|
||||
end)
|
||||
|
||||
# Mock interface and sensor discovery - no interfaces/sensors for simplicity
|
||||
# Walk called 15 times:
|
||||
# - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1),
|
||||
# voltage devices (1), voltage values (1)
|
||||
# - Base sensors fallback (1)
|
||||
# - Interfaces (1)
|
||||
# - Neighbors: LLDP (1), CDP (1)
|
||||
# - Debug data: lm_temp (1), lm_fan (1), lm_voltage (1), ucd_load (1), ucd_memory (1)
|
||||
expect(SnmpMock, :walk, 15, fn _, _, _ ->
|
||||
# Mock interface and sensor discovery
|
||||
stub(SnmpMock, :walk, fn _, _, _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
|
|
@ -1000,14 +990,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
|
|||
snmp_port: 161
|
||||
})
|
||||
|
||||
# Mock successful connection test
|
||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||
{:ok, {:timeticks, 12_345}}
|
||||
end)
|
||||
|
||||
# Mock system info discovery with different sysName + UCD sensor OIDs
|
||||
# 6 system info OIDs + 3 load OIDs + 2 memory OIDs = 11 total
|
||||
expect(SnmpMock, :get, 11, fn _, oid, _ ->
|
||||
# Mock all SNMP get calls (categorize_device_speed + test_connection + system_info + UCD sensors)
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.1.1.0" ->
|
||||
{:ok, {:octet_string, "Linux switch02 4.4.0"}}
|
||||
|
|
@ -1037,10 +1021,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
|
|||
# - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1),
|
||||
# voltage devices (1), voltage values (1)
|
||||
# - Base sensors fallback (1)
|
||||
# - Interfaces (1)
|
||||
# - Neighbors: LLDP (1), CDP (1)
|
||||
# - Debug data: lm_temp (1), lm_fan (1), lm_voltage (1), ucd_load (1), ucd_memory (1)
|
||||
expect(SnmpMock, :walk, 15, fn _, _, _ ->
|
||||
# Mock interface and sensor discovery
|
||||
stub(SnmpMock, :walk, fn _, _, _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
|
|
|
|||
|
|
@ -194,4 +194,109 @@ defmodule Towerops.Snmp.MibTranslatorTest do
|
|||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "fallback OID registry" do
|
||||
test "uses fallback for known MikroTik OIDs" do
|
||||
# These should use fallback registry since MIB files may not be installed
|
||||
result = MibTranslator.translate("MIKROTIK-MIB::mtxrSerialNumber.0")
|
||||
|
||||
# Either translation succeeds via snmptranslate or fallback
|
||||
assert {:ok, oid} = result
|
||||
|
||||
assert String.contains?(oid, "1.3.6.1.4.1.14988.1.1.7.3.0") or
|
||||
String.contains?(oid, ".1.3.6.1.4.1.14988.1.1.7.3.0")
|
||||
end
|
||||
|
||||
test "uses fallback for MikroTik health OIDs" do
|
||||
mikrotik_health_oids = [
|
||||
{"MIKROTIK-MIB::mtxrHlCpuTemperature.0", "1.3.6.1.4.1.14988.1.1.3.6.0"},
|
||||
{"MIKROTIK-MIB::mtxrHlVoltage.0", "1.3.6.1.4.1.14988.1.1.3.8.0"},
|
||||
{"MIKROTIK-MIB::mtxrHlFanSpeed1.0", "1.3.6.1.4.1.14988.1.1.3.17.0"}
|
||||
]
|
||||
|
||||
Enum.each(mikrotik_health_oids, fn {mib_name, expected_oid} ->
|
||||
result = MibTranslator.translate(mib_name)
|
||||
assert {:ok, oid} = result
|
||||
assert String.contains?(oid, expected_oid)
|
||||
end)
|
||||
end
|
||||
|
||||
test "uses fallback for known Cisco OIDs" do
|
||||
cisco_oids = [
|
||||
{"CISCO-ENTITY-SENSOR-MIB::entSensorType", "1.3.6.1.4.1.9.9.91.1.1.1.1.1"},
|
||||
{"CISCO-ENTITY-SENSOR-MIB::entSensorValue", "1.3.6.1.4.1.9.9.91.1.1.1.1.4"}
|
||||
]
|
||||
|
||||
Enum.each(cisco_oids, fn {mib_name, expected_oid} ->
|
||||
result = MibTranslator.translate(mib_name)
|
||||
assert {:ok, oid} = result
|
||||
assert String.contains?(oid, expected_oid)
|
||||
end)
|
||||
end
|
||||
|
||||
test "uses fallback for ENTITY-MIB OIDs" do
|
||||
entity_oids = [
|
||||
{"ENTITY-MIB::entPhysicalDescr", "1.3.6.1.2.1.47.1.1.1.1.2"},
|
||||
{"ENTITY-MIB::entPhysicalName", "1.3.6.1.2.1.47.1.1.1.1.7"},
|
||||
{"ENTITY-MIB::entPhysicalSerialNum", "1.3.6.1.2.1.47.1.1.1.1.11"}
|
||||
]
|
||||
|
||||
Enum.each(entity_oids, fn {mib_name, expected_oid} ->
|
||||
result = MibTranslator.translate(mib_name)
|
||||
assert {:ok, oid} = result
|
||||
assert String.contains?(oid, expected_oid)
|
||||
end)
|
||||
end
|
||||
|
||||
test "uses fallback for ENTITY-SENSOR-MIB OIDs" do
|
||||
sensor_oids = [
|
||||
{"ENTITY-SENSOR-MIB::entPhySensorType", "1.3.6.1.2.1.99.1.1.1.1"},
|
||||
{"ENTITY-SENSOR-MIB::entPhySensorValue", "1.3.6.1.2.1.99.1.1.1.4"},
|
||||
{"ENTITY-SENSOR-MIB::entPhySensorOperStatus", "1.3.6.1.2.1.99.1.1.1.5"}
|
||||
]
|
||||
|
||||
Enum.each(sensor_oids, fn {mib_name, expected_oid} ->
|
||||
result = MibTranslator.translate(mib_name)
|
||||
assert {:ok, oid} = result
|
||||
assert String.contains?(oid, expected_oid)
|
||||
end)
|
||||
end
|
||||
|
||||
test "uses fallback for LM-SENSORS-MIB OIDs (Linux/NetSNMP)" do
|
||||
lm_sensor_oids = [
|
||||
{"LM-SENSORS-MIB::lmTempSensorsDevice", "1.3.6.1.4.1.2021.13.16.2.1.2"},
|
||||
{"LM-SENSORS-MIB::lmTempSensorsValue", "1.3.6.1.4.1.2021.13.16.2.1.3"},
|
||||
{"LM-SENSORS-MIB::lmFanSensorsValue", "1.3.6.1.4.1.2021.13.16.3.1.3"}
|
||||
]
|
||||
|
||||
Enum.each(lm_sensor_oids, fn {mib_name, expected_oid} ->
|
||||
result = MibTranslator.translate(mib_name)
|
||||
assert {:ok, oid} = result
|
||||
assert String.contains?(oid, expected_oid)
|
||||
end)
|
||||
end
|
||||
|
||||
test "uses fallback for UCD-SNMP-MIB OIDs (Linux memory)" do
|
||||
# Note: expected OIDs are base OIDs without instance suffix
|
||||
# snmptranslate returns base OID when input doesn't have instance suffix
|
||||
ucd_oids = [
|
||||
{"UCD-SNMP-MIB::memTotalReal", "1.3.6.1.4.1.2021.4.5"},
|
||||
{"UCD-SNMP-MIB::memAvailReal", "1.3.6.1.4.1.2021.4.6"}
|
||||
]
|
||||
|
||||
Enum.each(ucd_oids, fn {mib_name, expected_base_oid} ->
|
||||
result = MibTranslator.translate(mib_name)
|
||||
assert {:ok, oid} = result
|
||||
assert String.contains?(oid, expected_base_oid)
|
||||
end)
|
||||
end
|
||||
|
||||
test "appends instance suffix when matching base OID" do
|
||||
# When the base name matches but has a different instance suffix
|
||||
result = MibTranslator.translate("ENTITY-MIB::entPhysicalDescr.123")
|
||||
assert {:ok, oid} = result
|
||||
assert String.contains?(oid, "1.3.6.1.2.1.47.1.1.1.1.2")
|
||||
assert String.ends_with?(oid, ".123")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ defmodule Towerops.Snmp.ProfileBehaviourTest do
|
|||
end)
|
||||
|
||||
# Mock individual get calls for building sensor data
|
||||
# Base.build_sensor_from_entity_mib calls Client.get_multiple with [type, scale, value, status]
|
||||
# Base.build_sensor_from_entity_mib calls Client.get_multiple with [type, scale, precision, value, status]
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
# Sensor index 1 data
|
||||
|
|
@ -309,6 +309,8 @@ defmodule Towerops.Snmp.ProfileBehaviourTest do
|
|||
"1.3.6.1.2.1.99.1.1.1.1.1" -> {:ok, {:integer, 8}}
|
||||
# scale = units (9) = divisor 1
|
||||
"1.3.6.1.2.1.99.1.1.1.2.1" -> {:ok, {:integer, 9}}
|
||||
# precision = 0 (no decimal places)
|
||||
"1.3.6.1.2.1.99.1.1.1.3.1" -> {:ok, {:integer, 0}}
|
||||
# value = 45
|
||||
"1.3.6.1.2.1.99.1.1.1.4.1" -> {:ok, {:integer, 45}}
|
||||
# status = ok (1)
|
||||
|
|
|
|||
|
|
@ -188,22 +188,30 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
|
|||
|
||||
describe "discover_sensors/1" do
|
||||
test "discovers sensors from ENTITY-SENSOR-MIB" do
|
||||
# Mock sensor type walk
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.99.1.1.1.1", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
|
||||
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 3}}
|
||||
]}
|
||||
# Mock sensor type walk and ENTITY-MIB description walks
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.99.1.1.1.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
|
||||
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 3}}
|
||||
]}
|
||||
|
||||
# ENTITY-MIB walks for descriptions (return empty for simplicity)
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
# Mock sensor data for each sensor (4 OIDs per sensor × 2 sensors = 8 calls)
|
||||
expect(SnmpMock, :get, 8, fn _, oid, _ ->
|
||||
# Mock sensor data for each sensor (5 OIDs per sensor × 2 sensors = 10 calls)
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
cond do
|
||||
String.ends_with?(oid, ".1") ->
|
||||
case oid |> String.split(".") |> Enum.at(-2) do
|
||||
"1" -> {:ok, {:integer, 8}}
|
||||
"2" -> {:ok, {:integer, 0}}
|
||||
"2" -> {:ok, {:integer, 9}}
|
||||
"3" -> {:ok, {:integer, 0}}
|
||||
"4" -> {:ok, {:integer, 45}}
|
||||
"5" -> {:ok, {:integer, 1}}
|
||||
_ -> {:error, :no_such_object}
|
||||
|
|
@ -212,7 +220,8 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
|
|||
String.ends_with?(oid, ".2") ->
|
||||
case oid |> String.split(".") |> Enum.at(-2) do
|
||||
"1" -> {:ok, {:integer, 3}}
|
||||
"2" -> {:ok, {:integer, -3}}
|
||||
"2" -> {:ok, {:integer, 6}}
|
||||
"3" -> {:ok, {:integer, 3}}
|
||||
"4" -> {:ok, {:integer, 12_000}}
|
||||
"5" -> {:ok, {:integer, 1}}
|
||||
_ -> {:error, :no_such_object}
|
||||
|
|
@ -230,14 +239,12 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
|
|||
temp_sensor = Enum.find(sensors, &(&1.sensor_index == "1"))
|
||||
assert temp_sensor.sensor_type == "celsius"
|
||||
assert temp_sensor.sensor_unit == "°C"
|
||||
assert temp_sensor.sensor_divisor == 1
|
||||
assert temp_sensor.last_value == 45.0
|
||||
assert temp_sensor.status == "ok"
|
||||
|
||||
voltage_sensor = Enum.find(sensors, &(&1.sensor_index == "2"))
|
||||
assert voltage_sensor.sensor_type == "volts"
|
||||
assert voltage_sensor.sensor_unit == "V"
|
||||
assert voltage_sensor.sensor_divisor == 1_000
|
||||
assert voltage_sensor.last_value == 12_000.0
|
||||
end
|
||||
|
||||
|
|
@ -260,20 +267,28 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
|
|||
end
|
||||
|
||||
test "filters out sensors with missing data" do
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.99.1.1.1.1", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
|
||||
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 8}}
|
||||
]}
|
||||
# Mock sensor type walk and ENTITY-MIB description walks
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.99.1.1.1.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
|
||||
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 8}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
# First sensor succeeds, second fails
|
||||
expect(SnmpMock, :get, 8, fn _, oid, _ ->
|
||||
# First sensor succeeds, second fails (5 OIDs per sensor)
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
if String.ends_with?(oid, ".1") do
|
||||
case oid |> String.split(".") |> Enum.at(-2) do
|
||||
"1" -> {:ok, {:integer, 8}}
|
||||
"2" -> {:ok, {:integer, 0}}
|
||||
"2" -> {:ok, {:integer, 9}}
|
||||
"3" -> {:ok, {:integer, 0}}
|
||||
"4" -> {:ok, {:integer, 42}}
|
||||
"5" -> {:ok, {:integer, 1}}
|
||||
_ -> {:error, :no_such_object}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
|
|||
end
|
||||
|
||||
test "successfully discovers a device", %{device: device} do
|
||||
# Mock SNMP responses for discovery (test_connection + system_info = 7 calls)
|
||||
expect(SnmpMock, :get, 7, fn _target, oid, _opts ->
|
||||
# Mock SNMP responses for discovery (categorize_device_speed + test_connection + system_info = 8 calls)
|
||||
expect(SnmpMock, :get, 8, fn _target, oid, _opts ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.1.1.0" -> {:ok, "Test Device"}
|
||||
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
|
||||
|
|
@ -82,8 +82,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
|
|||
end
|
||||
|
||||
test "successfully completes discovery with proper mocks", %{device: device} do
|
||||
# Mock SNMP responses (test_connection + system_info = 7 calls)
|
||||
expect(SnmpMock, :get, 7, fn _target, oid, _opts ->
|
||||
# Mock SNMP responses (categorize_device_speed + test_connection + system_info = 8 calls)
|
||||
expect(SnmpMock, :get, 8, fn _target, oid, _opts ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.1.1.0" -> {:ok, "Test"}
|
||||
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1]}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule ToweropsWeb.AgentLiveTest do
|
|||
use ToweropsWeb.ConnCase
|
||||
|
||||
import Ecto.Query
|
||||
import ExUnit.CaptureLog
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Towerops.Agents
|
||||
|
|
@ -235,10 +236,12 @@ defmodule ToweropsWeb.AgentLiveTest do
|
|||
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id)
|
||||
{:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Agent")
|
||||
|
||||
# Try to edit agent from other organization
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit")
|
||||
end
|
||||
# Try to edit agent from other organization - capture_log suppresses expected Router exception log
|
||||
capture_log(fn ->
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue