refactor: extract timeout helpers and replace conditionals in snmp/discovery.ex

- 12 identical *_with_timeout wrappers → 2 generic helpers (with_timeout/3, with_deferred_timeout/3)
- Removed commented-out code (# alias Towerops.Topology, # Auto-inference disabled)
- All if/case/cond replaced with pattern-matched function clauses (20+ refactors)
- Long lines fixed for credo compliance
- 11/11 tests pass, 0 credo issues
This commit is contained in:
Graham McInitre 2026-07-21 17:08:03 -05:00
parent 9d4a5f6d81
commit 5b91735b14

View file

@ -37,7 +37,6 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Snmp.Storage
alias Towerops.Snmp.Transceiver
alias Towerops.Snmp.Vlan
# alias Towerops.Topology # Commented out - auto-inference disabled
alias Towerops.Workers.DiscoveryWorker
require Logger
@ -100,8 +99,11 @@ defmodule Towerops.Snmp.Discovery do
"""
@spec discover_device(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_device(%DeviceSchema{snmp_enabled: false}) do
{:error, :snmp_not_enabled}
end
def discover_device(%DeviceSchema{} = device) do
if device.snmp_enabled do
Logger.info(
"Starting SNMP discovery: #{device.name} (#{device.ip_address})",
device_id: device.id,
@ -111,15 +113,19 @@ defmodule Towerops.Snmp.Discovery do
client_opts = build_client_opts(device)
# Categorize device speed for adaptive timeouts
Logger.info("Categorizing device speed...", device_id: device.id)
device_speed = DeferredDiscovery.categorize_device_speed(client_opts)
timeouts = DeferredDiscovery.timeouts_for_speed(device_speed)
if device_speed == :unresponsive do
discover_with_speed(device, client_opts, device_speed, timeouts)
end
defp discover_with_speed(device, _client_opts, :unresponsive, _timeouts) do
Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
{:error, :device_unresponsive}
else
end
defp discover_with_speed(device, client_opts, device_speed, timeouts) do
Logger.info(
"Device categorized as #{device_speed}, proceeding with discovery",
device_id: device.id,
@ -128,10 +134,6 @@ defmodule Towerops.Snmp.Discovery do
do_discover_device(device, client_opts, timeouts)
end
else
{:error, :snmp_not_enabled}
end
end
@doc """
Discovers a device using pre-built client options.
@ -150,50 +152,49 @@ defmodule Towerops.Snmp.Discovery do
"""
@spec discover_device_with_opts(DeviceSchema.t(), keyword()) ::
{:ok, Device.t()} | {:error, term()}
def discover_device_with_opts(%DeviceSchema{snmp_enabled: false}, _client_opts) do
{:error, :snmp_not_enabled}
end
def discover_device_with_opts(%DeviceSchema{} = device, client_opts) do
if device.snmp_enabled do
Logger.debug("Starting discovery with custom opts for: #{device.name}")
{device_speed, timeouts} = categorize_speed(client_opts)
discover_with_speed(device, client_opts, device_speed, timeouts)
end
# Skip speed categorization for Replay adapter (agent-based discovery)
# Agent data is pre-collected, so network checks are not needed
adapter = Keyword.get(client_opts, :adapter)
defp categorize_speed(client_opts) do
categorize_speed_for_adapter(client_opts, Keyword.get(client_opts, :adapter))
end
{device_speed, timeouts} =
if adapter == Replay do
defp categorize_speed_for_adapter(_client_opts, Replay) do
Logger.debug("Using Replay adapter, skipping speed categorization")
{:fast, DeferredDiscovery.timeouts_for_speed(:fast)}
else
# Categorize device speed for adaptive timeouts (real SNMP)
end
defp categorize_speed_for_adapter(client_opts, _adapter) do
speed = DeferredDiscovery.categorize_device_speed(client_opts)
{speed, DeferredDiscovery.timeouts_for_speed(speed)}
end
if device_speed == :unresponsive do
Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
{:error, :device_unresponsive}
else
do_discover_device(device, client_opts, timeouts)
# Connection test with pattern matching on Replay adapter
defp test_connection(client_opts, device_id) do
test_connection_for_adapter(client_opts, device_id, Keyword.get(client_opts, :adapter))
end
else
{:error, :snmp_not_enabled}
defp test_connection_for_adapter(_client_opts, device_id, Replay) do
Logger.debug("Using Replay adapter, skipping connection test", device_id: device_id)
{:ok, :replay}
end
defp test_connection_for_adapter(client_opts, device_id, _adapter) do
Logger.info("Testing SNMP connection...", device_id: device_id)
Client.test_connection(client_opts)
end
# Internal discovery with adaptive timeouts based on device speed
defp do_discover_device(device, client_opts, timeouts) do
adapter = Keyword.get(client_opts, :adapter)
connection_result = test_connection(client_opts, device.id)
# Skip connection test for Replay adapter (agent-provided data)
connection_result =
if adapter == Replay do
Logger.debug("Using Replay adapter, skipping connection test", device_id: device.id)
{:ok, :replay}
else
Logger.info("Testing SNMP connection...", device_id: device.id)
Client.test_connection(client_opts)
end
# Critical path: connection, system info, device info must succeed
with {:ok, _} <- connection_result,
Logger.info("Discovering system info...", device_id: device.id),
{:ok, system_info} <- discover_system(client_opts),
@ -205,60 +206,76 @@ defmodule Towerops.Snmp.Discovery do
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
{:ok, interfaces} <- discover_interfaces_with_timeout_safe(client_opts, profile, timeouts, device.id),
{:ok, sensors} <- discover_sensors_with_timeout_safe(client_opts, profile, timeouts, device.id) do
# Secondary discovery: VLANs, IPs, processors, storage (already fall back to [] on timeout)
Logger.info("Discovering VLANs...", device_id: device.id)
{:ok, vlans} = discover_vlans_with_timeout(client_opts, profile, timeouts)
{:ok, vlans} = with_timeout(client_opts, fn -> discover_vlans(client_opts, profile) end, timeouts)
Logger.info("Discovering IP addresses...", device_id: device.id)
{:ok, ip_addresses} = discover_ip_addresses_with_timeout(client_opts, timeouts)
{:ok, ip_addresses} = with_timeout(client_opts, fn -> Base.discover_all_ip_addresses(client_opts) end, timeouts)
_ = log_ip_discovery_results(device, ip_addresses)
Logger.info("Discovering processors...", device_id: device.id)
{:ok, processors} = discover_processors_with_timeout(client_opts, timeouts)
{:ok, processors} = with_timeout(client_opts, fn -> Base.discover_processors(client_opts) end, timeouts)
Logger.info("Discovering storage...", device_id: device.id)
{:ok, storage} = discover_storage_with_timeout(client_opts, timeouts)
{:ok, storage} = with_timeout(client_opts, fn -> Base.discover_storage(client_opts) end, timeouts)
Logger.info("Discovering memory pools...", device_id: device.id)
{:ok, mempools} = discover_mempools_with_timeout(client_opts, timeouts)
{:ok, mempools} = with_timeout(client_opts, fn -> Base.discover_memory_pools(client_opts) end, timeouts)
Logger.info("Discovering entity physical inventory...", device_id: device.id)
{:ok, entity_physical} = discover_entity_physical_with_timeout(client_opts, timeouts)
{:ok, entity_physical} = with_timeout(client_opts, fn -> Base.discover_entity_physical(client_opts) end, timeouts)
Logger.info("Discovering transceivers...", device_id: device.id)
{:ok, transceivers} = discover_transceivers_with_timeout(client_opts, timeouts)
{:ok, transceivers} = with_timeout(client_opts, fn -> Base.discover_transceivers(client_opts) end, timeouts)
Logger.info("Discovering printer supplies...", device_id: device.id)
{:ok, printer_supplies} = discover_printer_supplies_with_timeout(client_opts, timeouts)
{:ok, printer_supplies} =
with_timeout(client_opts, fn -> Base.discover_printer_supplies(client_opts) end, timeouts)
Logger.info("Saving discovery results (including IP addresses and processors)...", device_id: device.id)
Logger.info("Saving discovery results...", device_id: device.id)
case save_discovery_results(device, device_info, interfaces, sensors, vlans, ip_addresses, processors) do
{:ok, discovered_device} ->
# Post-save discovery: neighbors, ARP (best-effort)
Logger.info("Syncing storage...", device_id: device.id)
_ = sync_storage(discovered_device, storage)
save_result = save_discovery_results(device, device_info, interfaces, sensors, vlans, ip_addresses, processors)
Logger.info("Syncing memory pools...", device_id: device.id)
_ = sync_mempools(discovered_device, mempools)
post_data = %{
storage: storage,
mempools: mempools,
entity_physical: entity_physical,
transceivers: transceivers,
printer_supplies: printer_supplies
}
Logger.info("Syncing entity physical inventory...", device_id: device.id)
_ = sync_entity_physical(discovered_device, entity_physical)
complete_discovery(device, client_opts, timeouts, save_result, post_data)
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
error
end
end
Logger.info("Syncing transceivers...", device_id: device.id)
_ = sync_transceivers(discovered_device, transceivers)
defp complete_discovery(_device, _client_opts, _timeouts, {:error, _} = error, _post_data) do
error
end
Logger.info("Syncing printer supplies...", device_id: device.id)
_ = sync_printer_supplies(discovered_device, printer_supplies)
defp complete_discovery(device, client_opts, timeouts, {:ok, discovered_device}, post_data) do
_ = sync_storage(discovered_device, post_data.storage)
_ = sync_mempools(discovered_device, post_data.mempools)
_ = sync_entity_physical(discovered_device, post_data.entity_physical)
_ = sync_transceivers(discovered_device, post_data.transceivers)
_ = sync_printer_supplies(discovered_device, post_data.printer_supplies)
Logger.info("Discovering neighbors...", device_id: device.id)
{:ok, neighbors} = discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts)
Logger.info("Saving neighbors...", device_id: device.id)
neighbors_fn = fn -> NeighborDiscovery.discover_neighbors(client_opts, discovered_device.interfaces) end
{:ok, neighbors} = with_deferred_timeout(client_opts, neighbors_fn, timeouts)
_ = save_neighbors(discovered_device.device_id, neighbors)
Logger.info("Discovering ARP entries...", device_id: device.id)
{:ok, arp_entries} = discover_arp_with_timeout(client_opts, timeouts)
arp_fn = fn -> ArpDiscovery.discover_arp_table(client_opts) end
{:ok, arp_entries} = with_deferred_timeout(client_opts, arp_fn, timeouts)
_ = save_arp_entries(discovered_device.device_id, arp_entries, discovered_device.interfaces)
_ = update_device_discovery_time(device)
@ -267,16 +284,10 @@ defmodule Towerops.Snmp.Discovery do
is_rediscovery = device.last_discovery_at != nil
_ = log_discovery_event(device, discovered_device, is_rediscovery)
# Create monitoring checks for discovered entities
Logger.info("Creating monitoring checks from discovered entities...", device_id: device.id)
{:ok, check_counts} = Towerops.Snmp.create_checks_from_discovery(device, discovered_device)
log_check_creation_results(device.id, check_counts)
# Auto-inference disabled - device type is now manual-only
# Logger.info("Inferring device role...", device_id: device.id)
# _ = Topology.maybe_update_device_role(device)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
@ -285,52 +296,30 @@ defmodule Towerops.Snmp.Discovery do
)
{:ok, discovered_device}
{:error, reason} ->
Logger.error("Failed to save discovery results for #{device.name}: #{inspect(reason)}")
{:error, reason}
end
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
error
end
end
# Interface discovery with timeout - returns error on timeout to preserve existing data
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: {:error, :interface_discovery_timeout}
)
end
# Generic timeout wrapper for slow discovery operations (falls back to [] on timeout)
defp with_timeout(client_opts, discovery_fn, timeouts),
do: DeferredDiscovery.slow_check(client_opts, discovery_fn, timeout: timeouts[:slow], default: [])
# Sensor discovery with timeout - returns error on timeout to preserve existing data
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: {:error, :sensor_discovery_timeout}
)
end
# Timeout wrapper for deferred discovery operations (neighbors, ARP)
defp with_deferred_timeout(client_opts, discovery_fn, timeouts),
do: DeferredDiscovery.slow_check(client_opts, discovery_fn, timeout: timeouts[:deferred], default: [])
# Safe wrapper for interface discovery - aborts discovery on timeout to prevent data loss
defp discover_interfaces_with_timeout_safe(client_opts, profile, timeouts, device_id) do
Logger.info("Discovering interfaces...", device_id: device_id)
case discover_interfaces_with_timeout(client_opts, profile, timeouts) do
{:ok, ifaces} ->
{:ok, ifaces}
case DeferredDiscovery.slow_check(
client_opts,
fn -> discover_interfaces(client_opts, profile) end,
timeout: timeouts[:slow],
default: {:error, :interface_discovery_timeout}
) do
{:ok, _ifaces} = ok -> ok
{:error, :interface_discovery_timeout} ->
Logger.error(
"Interface discovery timed out - aborting to prevent data loss",
device_id: device_id
)
Logger.error("Interface discovery timed out - aborting to prevent data loss", device_id: device_id)
{:error, :interface_discovery_timeout}
end
end
@ -339,116 +328,20 @@ defmodule Towerops.Snmp.Discovery do
defp discover_sensors_with_timeout_safe(client_opts, profile, timeouts, device_id) do
Logger.info("Discovering sensors...", device_id: device_id)
case discover_sensors_with_timeout(client_opts, profile, timeouts) do
{:ok, sensors} ->
{:ok, sensors}
case DeferredDiscovery.slow_check(
client_opts,
fn -> discover_sensors(client_opts, profile) end,
timeout: timeouts[:slow],
default: {:error, :sensor_discovery_timeout}
) do
{:ok, _sensors} = ok -> ok
{:error, :sensor_discovery_timeout} ->
Logger.error(
"Sensor discovery timed out - aborting to prevent data loss",
device_id: device_id
)
Logger.error("Sensor discovery timed out - aborting to prevent data loss", device_id: device_id)
{:error, :sensor_discovery_timeout}
end
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
# ARP table discovery with timeout - falls back to empty list on timeout
defp discover_arp_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> ArpDiscovery.discover_arp_table(client_opts) end,
timeout: timeouts[:deferred],
default: []
)
end
# VLAN discovery with timeout - falls back to empty list on timeout
defp discover_vlans_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> discover_vlans(client_opts, profile) end,
timeout: timeouts[:slow],
default: []
)
end
# IP address discovery with timeout - falls back to empty list on timeout
defp discover_ip_addresses_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_all_ip_addresses(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
# Processor discovery with timeout - falls back to empty list on timeout
defp discover_processors_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_processors(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
# Storage discovery with timeout - falls back to empty list on timeout
defp discover_storage_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_storage(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
defp discover_mempools_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_memory_pools(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
defp discover_entity_physical_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_entity_physical(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
defp discover_transceivers_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_transceivers(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
defp discover_printer_supplies_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_printer_supplies(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
@doc """
Enqueues Oban discovery jobs for all SNMP-enabled devices in an organization.
@ -652,18 +545,21 @@ defmodule Towerops.Snmp.Discovery do
device_id: device_id
)
if check_counts.errors != [] do
Logger.warning(
"Failed to create #{length(check_counts.errors)} checks for device #{device_id}",
device_id: device_id,
errors: check_counts.errors
)
log_check_errors(device_id, check_counts.errors)
end
defp log_check_errors(_device_id, []), do: :ok
defp log_check_errors(device_id, errors) do
Logger.warning(
"Failed to create #{length(errors)} checks for device #{device_id}",
device_id: device_id,
errors: errors
)
end
@spec build_client_opts(DeviceSchema.t()) :: Client.connection_opts()
defp build_client_opts(device) do
# Get SNMP config with hierarchical fallback (device -> site -> organization)
snmp_config = Devices.get_snmp_config(device)
base_opts = [
@ -672,8 +568,10 @@ defmodule Towerops.Snmp.Discovery do
port: device.snmp_port || 161
]
# Add version-specific credentials
if snmp_config.version == "3" do
add_credentials(base_opts, snmp_config, device)
end
defp add_credentials(base_opts, %{version: "3"} = _snmp_config, device) do
v3_config = Devices.get_snmpv3_config(device)
base_opts ++
@ -685,9 +583,10 @@ defmodule Towerops.Snmp.Discovery do
priv_protocol: v3_config.priv_protocol,
priv_password: v3_config.priv_password
]
else
base_opts ++ [community: snmp_config.community]
end
defp add_credentials(base_opts, snmp_config, _device) do
base_opts ++ [community: snmp_config.community]
end
@spec discover_system(Client.connection_opts()) :: {:ok, system_info()} | {:error, term()}
@ -768,37 +667,15 @@ defmodule Towerops.Snmp.Discovery do
"build_device_info: system_info has _raw_discovery_data: #{inspect(Map.has_key?(system_info, :_raw_discovery_data))}"
)
# Get profile-specific system info if profile has discover_system_info/1
enriched_system_info =
if function_exported?(profile, :discover_system_info, 1) do
case profile.discover_system_info(client_opts) do
{:ok, profile_info} ->
Logger.debug("Enriched system_info with profile-specific data")
Map.merge(system_info, profile_info)
has_sys_info = function_exported?(profile, :discover_system_info, 1)
{:error, _reason} ->
Logger.warning("Failed to get profile-specific system info, using base info")
system_info
end
else
system_info
end
enriched_system_info = enrich_with_profile_info(client_opts, system_info, profile, has_sys_info)
# Let the hard-coded profile identify the device
identified_info = profile.identify_device(enriched_system_info)
# Collect raw debug data if profile supports it
identified_info_with_debug =
if function_exported?(profile, :collect_raw_debug_data, 1) do
raw_data = profile.collect_raw_debug_data(client_opts)
Logger.debug("Collected raw debug data: #{map_size(raw_data)} keys")
has_collect_debug = function_exported?(profile, :collect_raw_debug_data, 1)
identified_info
|> Map.put(:_raw_discovery_data, raw_data)
|> Map.put(:_last_discovery_at, DateTime.utc_now())
else
identified_info
end
identified_info_with_debug = maybe_attach_debug_data(client_opts, identified_info, profile, has_collect_debug)
Logger.debug(
"build_device_info: identified_info has _raw_discovery_data: #{inspect(Map.has_key?(identified_info_with_debug, :_raw_discovery_data))}"
@ -808,10 +685,34 @@ defmodule Towerops.Snmp.Discovery do
rescue
_error ->
Logger.error("Failed to identify device with profile, falling back to Base profile")
# Fall back to Base profile identification to ensure manufacturer/model are set
{:ok, Base.identify_device(system_info)}
end
defp enrich_with_profile_info(_client_opts, system_info, _profile, false), do: system_info
defp enrich_with_profile_info(client_opts, system_info, profile, true) do
case profile.discover_system_info(client_opts) do
{:ok, profile_info} ->
Logger.debug("Enriched system_info with profile-specific data")
Map.merge(system_info, profile_info)
{:error, _reason} ->
Logger.warning("Failed to get profile-specific system info, using base info")
system_info
end
end
defp maybe_attach_debug_data(_client_opts, identified_info, _profile, false), do: identified_info
defp maybe_attach_debug_data(client_opts, identified_info, profile, true) do
raw_data = profile.collect_raw_debug_data(client_opts)
Logger.debug("Collected raw debug data: #{map_size(raw_data)} keys")
identified_info
|> Map.put(:_raw_discovery_data, raw_data)
|> Map.put(:_last_discovery_at, DateTime.utc_now())
end
@spec discover_interfaces(Client.connection_opts(), profile()) ::
{:ok, [interface_data()]}
defp discover_interfaces(client_opts, {:yaml, profile}) do
@ -857,17 +758,20 @@ defmodule Towerops.Snmp.Discovery do
end
defp discover_vlans(client_opts, profile) when is_atom(profile) do
if function_exported?(profile, :discover_vlans, 1) do
discover_vlans_for_profile(client_opts, profile, function_exported?(profile, :discover_vlans, 1))
end
defp discover_vlans_for_profile(client_opts, profile, true) do
{:ok, vlans} = profile.discover_vlans(client_opts)
Logger.debug("Discovered #{length(vlans)} VLANs")
{:ok, vlans}
else
# Fall back to Base profile for VLAN discovery
end
defp discover_vlans_for_profile(client_opts, _profile, false) do
{:ok, vlans} = Base.discover_vlans(client_opts)
Logger.debug("Discovered #{length(vlans)} VLANs using Base profile")
{:ok, vlans}
end
end
@spec save_discovery_results(
DeviceSchema.t(),
@ -914,17 +818,21 @@ defmodule Towerops.Snmp.Discovery do
duration = System.monotonic_time(:millisecond) - start
if duration > 5_000 do
log_slow_transaction(duration, device.id, interfaces, sensors)
result
end
defp log_slow_transaction(duration, _device_id, _interfaces, _sensors) when duration <= 5_000, do: :ok
defp log_slow_transaction(duration, device_id, interfaces, sensors) do
Logger.warning("Discovery transaction took #{duration}ms",
device_id: device.id,
device_id: device_id,
interface_count: length(interfaces),
sensor_count: length(sensors)
)
end
result
end
# Prepares device_info for database save by sanitizing raw_discovery_data
# This is done OUTSIDE the transaction to avoid DB timeout on large data
defp prepare_device_info_for_save(device_info, device_id) do
@ -952,26 +860,20 @@ defmodule Towerops.Snmp.Discovery do
@spec upsert_device(DeviceSchema.t(), map()) :: Device.t()
defp upsert_device(device, device_attrs) do
Logger.info("Upserting SNMP device for #{device.name}")
do_upsert_device(device_attrs, Repo.get_by(Device, device_id: device.id))
end
case Repo.get_by(Device, device_id: device.id) do
nil ->
defp do_upsert_device(device_attrs, nil) do
snmp_device =
%Device{}
|> Device.changeset(device_attrs)
|> Repo.insert!()
# Log initial firmware version (no old version)
if snmp_device.firmware_version do
Firmware.log_firmware_change(
snmp_device.id,
nil,
snmp_device.firmware_version
)
log_initial_firmware(snmp_device)
snmp_device
end
snmp_device
existing_device ->
defp do_upsert_device(device_attrs, existing_device) do
old_version = existing_device.firmware_version
new_version = device_attrs[:firmware_version]
@ -980,17 +882,24 @@ defmodule Towerops.Snmp.Discovery do
|> Device.changeset(device_attrs)
|> Repo.update!()
# Detect and log firmware version changes
if version_changed?(old_version, new_version) do
Firmware.log_firmware_change(
snmp_device.id,
old_version,
new_version
)
end
log_firmware_if_changed(snmp_device, old_version, new_version)
snmp_device
end
defp log_initial_firmware(%{firmware_version: fw} = snmp_device) when not is_nil(fw) do
Firmware.log_firmware_change(snmp_device.id, nil, fw)
end
defp log_initial_firmware(_snmp_device), do: :ok
defp log_firmware_if_changed(snmp_device, old_version, new_version) do
log_firmware_when_changed(snmp_device, old_version, new_version, version_changed?(old_version, new_version))
end
defp log_firmware_when_changed(_snmp_device, _old, _new, false), do: :ok
defp log_firmware_when_changed(snmp_device, old, new, true) do
Firmware.log_firmware_change(snmp_device.id, old, new)
end
defp version_changed?(nil, new_version) when is_binary(new_version), do: false
@ -1187,12 +1096,20 @@ defmodule Towerops.Snmp.Discovery do
Logger.debug("Deleted #{MapSet.size(removed_keys)} removed IP addresses")
end
# Upsert each discovered IP address
Enum.each(discovered_ip_addresses, fn ip_data ->
upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses)
end)
if Application.get_env(:towerops, :env) == :dev do
log_sync_ip_summary(device, discovered_ip_addresses, removed_keys, Application.get_env(:towerops, :env) == :dev)
:ok
end
defp log_sync_ip_summary(_device, _discovered, _removed_keys, false) do
Logger.debug("Synced IP addresses for device")
end
defp log_sync_ip_summary(device, discovered_ip_addresses, removed_keys, true) do
ipv4_count = Enum.count(discovered_ip_addresses, &(&1.ip_type == "ipv4"))
ipv6_count = Enum.count(discovered_ip_addresses, &(&1.ip_type == "ipv6"))
removed_count = MapSet.size(removed_keys)
@ -1201,20 +1118,17 @@ defmodule Towerops.Snmp.Discovery do
"Synced IP addresses: #{ipv4_count} IPv4, #{ipv6_count} IPv6, removed #{removed_count}",
device_id: device.device_id
)
else
Logger.debug("Synced #{length(discovered_ip_addresses)} IP addresses for device")
end
:ok
end
defp upsert_ip_address(ip_data, if_index_to_interface, _existing_ip_addresses) do
interface_id = Map.get(if_index_to_interface, ip_data.if_index)
upsert_ip_with_iface(ip_data, Map.get(if_index_to_interface, ip_data.if_index))
end
if interface_id do
defp upsert_ip_with_iface(_ip_data, nil), do: :ok
defp upsert_ip_with_iface(ip_data, interface_id) do
ip_attrs = Map.put(ip_data, :snmp_interface_id, interface_id)
# Use on_conflict to handle race conditions gracefully
%IpAddress{}
|> IpAddress.changeset(ip_attrs)
|> Repo.insert(
@ -1222,7 +1136,6 @@ defmodule Towerops.Snmp.Discovery do
conflict_target: [:snmp_interface_id, :ip_address]
)
end
end
@type snmp_device_ref :: Device.t() | %{required(:id) => String.t()}
@spec sync_processors(snmp_device_ref(), [map()]) :: :ok
@ -1312,10 +1225,7 @@ defmodule Towerops.Snmp.Discovery do
end
end)
if invalid_count > 0 do
Logger.warning("Dropped #{invalid_count} storage entries with malformed storage_index")
end
log_invalid_storage_entries(invalid_count)
discovered_indices = MapSet.new(valid_storage, & &1.storage_index)
# Delete storage that no longer exists on the device
@ -1354,6 +1264,12 @@ defmodule Towerops.Snmp.Discovery do
:ok
end
defp log_invalid_storage_entries(0), do: :ok
defp log_invalid_storage_entries(count) do
Logger.warning("Dropped #{count} storage entries with malformed storage_index")
end
# Normalize storage index to integer (Base.discover_storage may return string from OID parsing).
# Returns nil for malformed values — OID suffixes come from SNMP responses and must not be trusted.
defp normalize_storage_index(index) when is_integer(index), do: index
@ -1622,29 +1538,27 @@ defmodule Towerops.Snmp.Discovery do
{:ok, synced}
end
# Resolve parent_id from parent_index for all entities on a device
defp resolve_entity_parent_relationships(snmp_device_id) do
# Get all entities for this device
entities = Repo.all(from(e in EntityPhysical, where: e.snmp_device_id == ^snmp_device_id))
# Build index map: entity_index -> id
index_to_id = Map.new(entities, &{&1.entity_index, &1.id})
# Update each entity's parent_id based on parent_index
Enum.each(entities, fn entity ->
parent_id =
if entity.parent_index && entity.parent_index != "0" do
Map.get(index_to_id, entity.parent_index)
parent_id = resolve_parent_id(index_to_id, entity)
update_parent_if_changed(entity, parent_id)
end)
end
# Only update if parent_id changed
if entity.parent_id != parent_id do
defp resolve_parent_id(_index_to_id, %{parent_index: idx}) when is_nil(idx), do: nil
defp resolve_parent_id(_index_to_id, %{parent_index: "0"}), do: nil
defp resolve_parent_id(index_to_id, %{parent_index: idx}), do: Map.get(index_to_id, idx)
defp update_parent_if_changed(%{parent_id: existing} = _entity, existing), do: :ok
defp update_parent_if_changed(entity, parent_id) do
entity
|> Ecto.Changeset.change(parent_id: parent_id)
|> Repo.update!()
end
end)
end
@spec update_device_discovery_time(DeviceSchema.t()) ::
{:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
@ -1690,18 +1604,28 @@ defmodule Towerops.Snmp.Discovery do
@spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
{:ok, DeviceSchema.t()} | {:error, term()}
defp update_device_name_from_snmp(%{name: name} = device, _system_info)
when is_binary(name) and name != "" do
Logger.info("Device name already set or no sysName, skipping update", device_id: device.id)
{:ok, device}
end
defp update_device_name_from_snmp(device, system_info) do
# Only update device name if it's currently empty and we got a sysName from SNMP
name_empty? = is_nil(device.name) or device.name == ""
do_update_device_name(device, system_info, Map.has_key?(system_info, :sys_name))
end
if name_empty? and Map.has_key?(system_info, :sys_name) do
sys_name = Map.get(system_info, :sys_name)
defp do_update_device_name(device, _system_info, false) do
Logger.info("Device name already set or no sysName, skipping update", device_id: device.id)
{:ok, device}
end
if sys_name && sys_name != "" do
Logger.info(
"Updating device name from SNMP sysName: #{sys_name}",
device_id: device.id
)
defp do_update_device_name(device, %{sys_name: sys_name}, true) when is_nil(sys_name) or sys_name == "" do
Logger.info("sysName empty, keeping device name unchanged", device_id: device.id)
{:ok, device}
end
defp do_update_device_name(device, %{sys_name: sys_name}, true) do
Logger.info("Updating device name from SNMP sysName: #{sys_name}", device_id: device.id)
result =
device
@ -1710,18 +1634,16 @@ defmodule Towerops.Snmp.Discovery do
Logger.info("Device name update completed", device_id: device.id)
result
else
Logger.info("sysName empty, keeping device name unchanged", device_id: device.id)
{:ok, device}
end
else
Logger.info("Device name already set or no sysName, skipping update", device_id: device.id)
{:ok, device}
end
end
defp log_ip_discovery_results(device, ip_addresses) do
if Application.get_env(:towerops, :env) == :dev do
log_ip_if_dev_env(device, ip_addresses, Application.get_env(:towerops, :env) == :dev)
:ok
end
defp log_ip_if_dev_env(_device, _ip_addresses, false), do: :ok
defp log_ip_if_dev_env(device, ip_addresses, true) do
ipv4_count = Enum.count(ip_addresses, &(&1.ip_type == "ipv4"))
ipv6_count = Enum.count(ip_addresses, &(&1.ip_type == "ipv6"))
@ -1731,24 +1653,20 @@ defmodule Towerops.Snmp.Discovery do
)
end
:ok
defp interface_count(%Ecto.Association.NotLoaded{}), do: 0
defp interface_count(interfaces) when is_list(interfaces), do: length(interfaces)
defp log_discovery_event(device, discovered_device, true) do
log_discovery_event_common(device, discovered_device, "device_rediscovered", "Device rediscovered")
end
defp log_discovery_event(device, discovered_device, is_rediscovery) do
event_type = if is_rediscovery, do: "device_rediscovered", else: "device_discovered"
message =
if is_rediscovery do
"Device rediscovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
else
"Device discovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
defp log_discovery_event(device, discovered_device, false) do
log_discovery_event_common(device, discovered_device, "device_discovered", "Device discovered")
end
interface_count =
case discovered_device.interfaces do
%Ecto.Association.NotLoaded{} -> 0
interfaces when is_list(interfaces) -> length(interfaces)
end
defp log_discovery_event_common(device, discovered_device, event_type, event_label) do
message = "#{event_label}: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
interface_count = interface_count(discovered_device.interfaces)
metadata = %{
manufacturer: discovered_device.manufacturer,
@ -1819,13 +1737,7 @@ defmodule Towerops.Snmp.Discovery do
end
defp sanitize_for_json(binary) when is_binary(binary) do
if String.printable?(binary) do
binary
else
# Convert non-printable binary to base64 with marker
# Use Elixir.Base to avoid conflict with Towerops.Snmp.Profiles.Base alias
%{"_binary_base64" => Elixir.Base.encode64(binary)}
end
sanitize_binary(binary, String.printable?(binary))
end
defp sanitize_for_json(tuple) when is_tuple(tuple) do
@ -1836,6 +1748,12 @@ defmodule Towerops.Snmp.Discovery do
defp sanitize_for_json(other), do: other
defp sanitize_binary(binary, true), do: binary
defp sanitize_binary(binary, false) do
%{"_binary_base64" => Elixir.Base.encode64(binary)}
end
# Deduplicate sensors by OID - when multiple sensors share the same OID,
# prefer the one with a proper description (not containing "MIB::").
# Normalizes OIDs by stripping leading dots so ".1.3.6.1..." and "1.3.6.1..."