towerops/lib/towerops/snmp/discovery.ex
Graham McIntire 4cfab3b1ee
Reliability improvements: agent channel, polling, discovery, batch ops
- Add Task.Supervisor for supervised background tasks in agent channel
- Discovery worker retries transient failures (max 3 attempts)
- Log silent polling timeouts with named task identification
- Track errors in batch ARP/MAC upserts instead of silently ignoring
- Discovery gracefully degrades on interface/sensor timeout (partial results)
- Synchronous job dispatch on agent join to prevent race condition
- Surface SNMP result, monitoring check, and backup processing errors
- Atomic delete-stale + upsert for neighbors/ARP/MAC (transaction)
- Discovery transaction timeout (60s) with duration logging
- Heartbeat timeout detection (5min) closes stale agent channels
- Live poll timeout (60s) notifies waiting LiveView on expiry
- Disconnect agent channel when token is disabled or deleted
- Debounce rapid assignment changes to prevent redundant job dispatches
- Sensor index deduplication via YAML index template substitution
2026-02-09 16:28:12 -06:00

1290 lines
45 KiB
Elixir

defmodule Towerops.Snmp.Discovery do
@moduledoc """
Core SNMP discovery orchestrator.
Handles the complete discovery workflow:
1. Test SNMP connectivity
2. Discover system information
3. Select appropriate device profile (Cisco, NetSNMP, or Base)
4. Discover interfaces and sensors using profile
5. Save discovered data to database
Can be run manually or scheduled as a background job.
"""
import Ecto.Query
alias Towerops.Devices
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Devices.Firmware
alias Towerops.Profiles.YamlProfiles
alias Towerops.Repo
alias Towerops.Snmp.Adapters.Replay
alias Towerops.Snmp.ArpDiscovery
alias Towerops.Snmp.Client
alias Towerops.Snmp.DeferredDiscovery
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.IpAddress
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.Processor
alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.Profiles.Dynamic
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.Storage
alias Towerops.Snmp.Vlan
alias Towerops.Workers.DiscoveryWorker
require Logger
@type system_info :: %{
optional(:sys_descr) => String.t(),
optional(:sys_object_id) => String.t(),
optional(:sys_name) => String.t(),
optional(:sys_uptime) => non_neg_integer(),
optional(:sys_contact) => String.t(),
optional(:sys_location) => String.t(),
optional(atom()) => term()
}
@type device_info :: %{
optional(:manufacturer) => String.t(),
optional(:model) => String.t(),
optional(:firmware_version) => String.t() | nil,
optional(:serial_number) => String.t() | nil,
optional(atom()) => term()
}
@type interface_data :: %{
required(:if_index) => non_neg_integer(),
required(:if_descr) => String.t(),
optional(:if_name) => String.t() | nil,
optional(:if_alias) => String.t() | nil,
optional(:if_type) => non_neg_integer() | nil,
optional(:if_mtu) => non_neg_integer() | nil,
optional(:if_speed) => non_neg_integer() | nil,
optional(:if_phys_address) => String.t() | nil,
optional(:if_admin_status) => non_neg_integer() | nil,
optional(:if_oper_status) => non_neg_integer() | nil,
optional(atom()) => term()
}
@type sensor_data :: %{
required(:sensor_type) => String.t(),
required(:sensor_index) => String.t(),
required(:sensor_oid) => String.t(),
required(:sensor_descr) => String.t(),
required(:sensor_unit) => String.t(),
required(:sensor_divisor) => number(),
optional(:last_value) => float() | nil,
optional(:status) => String.t(),
optional(atom()) => term()
}
@type profile :: module() | {:yaml, map()}
@type discovery_summary :: %{
enqueued: non_neg_integer(),
failed: non_neg_integer(),
errors: [term()]
}
@doc """
Runs discovery for a single device.
Returns {:ok, device} or {:error, reason}.
## Examples
iex> discover_device(device)
{:ok, %Device{manufacturer: "Cisco", model: "C2960"}}
iex> discover_device(device_without_snmp)
{:error, :snmp_not_enabled}
"""
@spec discover_device(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()}
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,
device_name: device.name,
device_ip: device.ip_address
)
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
Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
{:error, :device_unresponsive}
else
Logger.info(
"Device categorized as #{device_speed}, proceeding with discovery",
device_id: device.id,
device_speed: device_speed
)
do_discover_device(device, client_opts, timeouts)
end
else
{:error, :snmp_not_enabled}
end
end
@doc """
Discovers a device using pre-built client options.
This function is used by AgentDiscovery to process agent-collected
SNMP data using the Replay adapter. It accepts client_opts that
already contain the adapter and OID map.
## Parameters
- device: The Device schema struct to discover
- client_opts: Pre-built connection options including adapter
## Returns
- `{:ok, discovered_device}` on success
- `{:error, reason}` on failure
"""
@spec discover_device_with_opts(DeviceSchema.t(), keyword()) ::
{:ok, Device.t()} | {:error, term()}
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}")
# 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)
{device_speed, timeouts} =
if adapter == 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)
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)
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
adapter = Keyword.get(client_opts, :adapter)
# 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),
Logger.info("Updating device name...", device_id: device.id),
{:ok, device} <- update_device_name_from_snmp(device, system_info),
Logger.info("Selecting device profile...", device_id: device.id),
profile = select_profile(system_info, client_opts),
Logger.info("Profile selected, building device info...", device_id: device.id),
{:ok, device_info} <- build_device_info(client_opts, system_info, profile) do
# Interface and sensor discovery: fall back to empty on timeout with warning
# CRITICAL: Empty list on timeout means sync will NOT delete existing data
# because save_discovery_results skips sync when list is empty and device already exists
Logger.info("Discovering interfaces...", device_id: device.id)
interfaces =
case discover_interfaces_with_timeout(client_opts, profile, timeouts) do
{:ok, ifaces} ->
ifaces
{:error, :interface_discovery_timeout} ->
Logger.warning("Interface discovery timed out, using empty list", device_id: device.id)
[]
end
Logger.info("Discovering sensors...", device_id: device.id)
sensors =
case discover_sensors_with_timeout(client_opts, profile, timeouts) do
{:ok, s} ->
s
{:error, :sensor_discovery_timeout} ->
Logger.warning("Sensor discovery timed out, using empty list", device_id: device.id)
[]
end
# 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)
Logger.info("Discovering IP addresses...", device_id: device.id)
{:ok, ip_addresses} = discover_ip_addresses_with_timeout(client_opts, 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)
Logger.info("Discovering storage...", device_id: device.id)
{:ok, storage} = discover_storage_with_timeout(client_opts, timeouts)
Logger.info("Saving discovery results (including IP addresses and processors)...", 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)
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)
_ = 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)
_ = save_arp_entries(discovered_device.device_id, arp_entries, discovered_device.interfaces)
_ = update_device_discovery_time(device)
Logger.debug("SNMP discovery completed successfully for: #{device.name}")
is_rediscovery = device.last_discovery_at != nil
_ = log_discovery_event(device, discovered_device, is_rediscovery)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:discovery_completed, device.id}
)
{: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
# CRITICAL: Returns error on timeout instead of empty list to prevent data loss
# Empty list would cause sync_interfaces to delete all existing interfaces
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
# Sensor discovery with timeout
# CRITICAL: Returns error on timeout instead of empty list to prevent data loss
# Empty list would cause sync_sensors to delete all existing sensors
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
# 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
@doc """
Enqueues Oban discovery jobs for all SNMP-enabled devices in an organization.
Returns immediately after enqueuing jobs. Job status can be monitored via Oban dashboard.
## Examples
iex> discover_all(org_id)
{:ok, %{enqueued: 10, failed: 0}}
"""
@spec discover_all(String.t()) :: {:ok, discovery_summary()}
def discover_all(org_id) do
device_list =
DeviceSchema
|> join(:inner, [e], s in assoc(e, :site))
|> where([e, s], s.organization_id == ^org_id and e.snmp_enabled == true)
|> Repo.all()
Logger.debug("Enqueuing SNMP discovery for #{length(device_list)} devices in org #{org_id}")
# Enqueue Oban jobs for each device
results =
Enum.reduce(device_list, %{enqueued: 0, failed: 0, errors: []}, fn device, acc ->
case DiscoveryWorker.enqueue(device.id) do
:ok ->
%{acc | enqueued: acc.enqueued + 1}
{:error, reason} ->
Logger.error("Failed to enqueue discovery for device #{device.id}: #{inspect(reason)}")
%{acc | failed: acc.failed + 1, errors: [reason | acc.errors]}
end
end)
Logger.debug("Discovery jobs enqueued: #{results.enqueued} succeeded, #{results.failed} failed")
{:ok, results}
end
# Private functions
@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 = [
ip: device.ip_address,
version: snmp_config.version,
port: device.snmp_port || 161
]
# Add version-specific credentials
if snmp_config.version == "3" do
v3_config = Devices.get_snmpv3_config(device)
base_opts ++
[
security_name: v3_config.username,
security_level: v3_config.security_level,
auth_protocol: v3_config.auth_protocol,
auth_password: v3_config.auth_password,
priv_protocol: v3_config.priv_protocol,
priv_password: v3_config.priv_password
]
else
base_opts ++ [community: snmp_config.community]
end
end
@spec discover_system(Client.connection_opts()) :: {:ok, system_info()} | {:error, term()}
defp discover_system(client_opts) do
# Use Base profile for initial system discovery
Base.discover_system_info(client_opts)
end
@doc """
Selects the appropriate SNMP profile based on system information.
First tries to match against database-stored device profiles (imported from LibreNMS).
If no database match is found, falls back to the generic Base profile.
## Examples
iex> select_profile(%{sys_descr: "Cisco IOS"}, client_opts)
{:dynamic, %DeviceProfile{name: "ios"}}
iex> select_profile(%{sys_object_id: "1.3.6.1.4.1.41112"}, client_opts)
{:dynamic, %DeviceProfile{name: "airos"}}
"""
@spec select_profile(system_info(), keyword()) :: profile()
def select_profile(system_info, client_opts) do
Logger.debug("Attempting to match profile for device",
sys_object_id: Map.get(system_info, :sys_object_id),
sys_descr: Map.get(system_info, :sys_descr)
)
case YamlProfiles.match_profile(system_info, client_opts) do
%{name: name} = profile ->
Logger.debug("Selected YAML profile: #{name}",
profile_name: name,
has_snmpget: has_snmpget_conditions?(profile)
)
{:yaml, profile}
nil ->
Logger.debug("No YAML profile matched, using Base profile")
Base
end
end
defp has_snmpget_conditions?(profile) do
Enum.any?(profile.detection_blocks || [], fn block -> not is_nil(block[:snmpget]) end)
end
@spec build_device_info(Client.connection_opts(), system_info(), profile()) ::
{:ok, device_info()}
defp build_device_info(client_opts, system_info, {:yaml, profile}) do
# Use YAML profile to identify device
identified_info = Dynamic.identify_device(profile, client_opts, system_info)
# Collect raw debug data using Base profile (works for any device)
raw_data = Base.collect_raw_debug_data(client_opts)
# Add vendor-specific debug data (wireless sensors, etc.)
vendor_data = Dynamic.collect_vendor_debug_data(profile, client_opts)
raw_data_with_vendor = Map.merge(raw_data, vendor_data)
Logger.debug("Collected raw debug data for YAML profile: #{map_size(raw_data_with_vendor)} keys")
identified_info_with_debug =
identified_info
|> Map.put(:_raw_discovery_data, raw_data_with_vendor)
|> Map.put(:_last_discovery_at, DateTime.utc_now())
{:ok, identified_info_with_debug}
rescue
error ->
Logger.error("Failed to identify device with YAML profile: #{inspect(error)}")
{:ok, system_info}
end
defp build_device_info(client_opts, system_info, profile) when is_atom(profile) do
Logger.debug(
"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)
{:error, _reason} ->
Logger.warning("Failed to get profile-specific system info, using base info")
system_info
end
else
system_info
end
# 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")
identified_info
|> Map.put(:_raw_discovery_data, raw_data)
|> Map.put(:_last_discovery_at, DateTime.utc_now())
else
identified_info
end
Logger.debug(
"build_device_info: identified_info has _raw_discovery_data: #{inspect(Map.has_key?(identified_info_with_debug, :_raw_discovery_data))}"
)
{:ok, identified_info_with_debug}
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
@spec discover_interfaces(Client.connection_opts(), profile()) ::
{:ok, [interface_data()]}
defp discover_interfaces(client_opts, {:yaml, profile}) do
{:ok, interfaces} = Dynamic.discover_interfaces(profile, client_opts)
Logger.debug("Discovered #{length(interfaces)} interfaces")
{:ok, interfaces}
end
defp discover_interfaces(client_opts, profile) when is_atom(profile) do
case profile.discover_interfaces(client_opts) do
{:ok, interfaces} ->
Logger.debug("Discovered #{length(interfaces)} interfaces")
{:ok, interfaces}
{:error, _} ->
Logger.warning("Interface discovery failed, continuing without interfaces")
{:ok, []}
end
end
@spec discover_sensors(Client.connection_opts(), profile()) :: {:ok, [sensor_data()]}
defp discover_sensors(client_opts, {:yaml, profile}) do
{:ok, sensors} = Dynamic.discover_sensors(profile, client_opts)
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
end
defp discover_sensors(client_opts, profile) when is_atom(profile) do
{:ok, sensors} = profile.discover_sensors(client_opts)
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
end
@spec discover_vlans(Client.connection_opts(), profile()) :: {:ok, [map()]}
defp discover_vlans(client_opts, {:yaml, _profile}) do
# Dynamic profiles use Base VLAN discovery
{:ok, vlans} = Base.discover_vlans(client_opts)
Logger.debug("Discovered #{length(vlans)} VLANs")
{:ok, vlans}
end
defp discover_vlans(client_opts, profile) when is_atom(profile) do
if function_exported?(profile, :discover_vlans, 1) 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
{: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(),
device_info(),
[interface_data()],
[sensor_data()],
[map()],
[map()],
[map()]
) :: {:ok, Device.t()} | {:error, term()}
defp save_discovery_results(device, device_info, interfaces, sensors, vlans, ip_addresses, processors) do
# Sanitize raw_discovery_data BEFORE starting transaction to avoid DB timeout
# This can take several seconds for large discovery data (thousands of OIDs)
sanitized_device_info = prepare_device_info_for_save(device_info, device.id)
start = System.monotonic_time(:millisecond)
result =
Repo.transaction(
fn ->
# Upsert Device
snmp_device = upsert_device(device, sanitized_device_info)
# Sync interfaces, sensors, and VLANs (preserving historical data)
synced_interfaces = sync_interfaces(snmp_device, interfaces)
_ = sync_sensors(snmp_device, sensors)
_ = sync_vlans(snmp_device, vlans)
# Sync IP addresses and processors inside transaction for atomicity
device_with_interfaces = %{
snmp_device
| interfaces: Enum.map(synced_interfaces, &Map.put(&1, :device_id, snmp_device.device_id))
}
_ = sync_ip_addresses(device_with_interfaces, ip_addresses)
_ = sync_processors(device_with_interfaces, processors)
# Return device with interfaces loaded and device_id added
# Use snmp_device.device_id which references the Equipment table
device_with_interfaces
end,
timeout: 60_000
)
duration = System.monotonic_time(:millisecond) - start
if duration > 5_000 do
Logger.warning("Discovery transaction took #{duration}ms",
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
Logger.info("Preparing device_info for save...")
# Extract and sanitize raw discovery data (this can take several seconds)
raw_discovery_data =
device_info
|> Map.get(:_raw_discovery_data)
|> sanitize_for_json()
last_discovery_at = Map.get(device_info, :_last_discovery_at, DateTime.utc_now())
Logger.info("Sanitized raw_discovery_data (#{map_size(raw_discovery_data || %{})} keys)")
# Build device_attrs with sanitized data
device_info
|> Map.delete(:_raw_discovery_data)
|> Map.delete(:_last_discovery_at)
|> Map.put(:device_id, device_id)
|> Map.put(:raw_discovery_data, raw_discovery_data)
|> Map.put(:last_discovery_at, last_discovery_at)
end
@spec upsert_device(DeviceSchema.t(), map()) :: Device.t()
defp upsert_device(device, device_attrs) do
Logger.info("Upserting SNMP device for #{device.name}")
case Repo.get_by(Device, device_id: device.id) do
nil ->
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
)
end
snmp_device
existing_device ->
old_version = existing_device.firmware_version
new_version = device_attrs[:firmware_version]
snmp_device =
existing_device
|> 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
snmp_device
end
end
defp version_changed?(nil, new_version) when is_binary(new_version), do: false
defp version_changed?(old, new) when old == new, do: false
defp version_changed?(old, new) when is_binary(old) and is_binary(new), do: true
defp version_changed?(_, _), do: false
@spec sync_interfaces(Device.t(), [interface_data()]) :: [Interface.t()]
defp sync_interfaces(device, discovered_interfaces) do
# Get existing interfaces for this device, indexed by if_index
existing_interfaces =
from(i in Interface, where: i.snmp_device_id == ^device.id)
|> Repo.all()
|> Map.new(&{&1.if_index, &1})
# Get the set of discovered if_indices
discovered_indices = MapSet.new(discovered_interfaces, & &1.if_index)
# Delete interfaces that no longer exist on the device
existing_indices = MapSet.new(Map.keys(existing_interfaces))
removed_indices = MapSet.difference(existing_indices, discovered_indices)
if MapSet.size(removed_indices) != 0 do
removed_list = MapSet.to_list(removed_indices)
Repo.delete_all(
from i in Interface,
where: i.snmp_device_id == ^device.id and i.if_index in ^removed_list
)
Logger.debug("Deleted #{MapSet.size(removed_indices)} removed interfaces")
end
# Upsert each discovered interface
Enum.map(discovered_interfaces, fn interface_data ->
case Map.get(existing_interfaces, interface_data.if_index) do
nil ->
# New interface - insert
%Interface{}
|> Interface.changeset(Map.put(interface_data, :snmp_device_id, device.id))
|> Repo.insert!()
existing ->
# Existing interface - update (preserves ID and associated stats)
existing
|> Interface.changeset(interface_data)
|> Repo.update!()
end
end)
end
@spec sync_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
defp sync_sensors(device, discovered_sensors) do
# Deduplicate sensors by OID first - when multiple sensors share the same OID,
# prefer the one with a proper description (not containing "MIB::")
# This handles cases where YAML profiles and vendor profiles discover the same sensor
discovered_sensors = deduplicate_sensors_by_oid(discovered_sensors)
# Then deduplicate by sensor_index (keep first occurrence)
# This prevents duplicate key errors when profiles generate duplicate indices
discovered_sensors = Enum.uniq_by(discovered_sensors, & &1.sensor_index)
# Get existing sensors for this device, indexed by sensor_index
existing_sensors =
from(s in Sensor, where: s.snmp_device_id == ^device.id)
|> Repo.all()
|> Map.new(&{&1.sensor_index, &1})
# Get the set of discovered sensor_indices
discovered_indices = MapSet.new(discovered_sensors, & &1.sensor_index)
# Delete sensors that no longer exist on the device
existing_indices = MapSet.new(Map.keys(existing_sensors))
removed_indices = MapSet.difference(existing_indices, discovered_indices)
if MapSet.size(removed_indices) != 0 do
removed_list = MapSet.to_list(removed_indices)
Repo.delete_all(
from s in Sensor,
where: s.snmp_device_id == ^device.id and s.sensor_index in ^removed_list
)
Logger.debug("Deleted #{MapSet.size(removed_indices)} removed sensors")
end
# Upsert each discovered sensor
Enum.map(discovered_sensors, fn sensor_data ->
case Map.get(existing_sensors, sensor_data.sensor_index) do
nil ->
# New sensor - insert
%Sensor{}
|> Sensor.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
|> Repo.insert!()
existing ->
# Existing sensor - update (preserves ID and associated readings)
existing
|> Sensor.changeset(sensor_data)
|> Repo.update!()
end
end)
end
@spec sync_vlans(Device.t(), [map()]) :: [Vlan.t()]
defp sync_vlans(device, discovered_vlans) do
# Get existing VLANs for this device, indexed by vlan_id
existing_vlans =
from(v in Vlan, where: v.snmp_device_id == ^device.id)
|> Repo.all()
|> Map.new(&{&1.vlan_id, &1})
# Get the set of discovered vlan_ids
discovered_vlan_ids = MapSet.new(discovered_vlans, & &1.vlan_id)
# Delete VLANs that no longer exist on the device
existing_vlan_ids = MapSet.new(Map.keys(existing_vlans))
removed_vlan_ids = MapSet.difference(existing_vlan_ids, discovered_vlan_ids)
if MapSet.size(removed_vlan_ids) != 0 do
removed_list = MapSet.to_list(removed_vlan_ids)
Repo.delete_all(
from v in Vlan,
where: v.snmp_device_id == ^device.id and v.vlan_id in ^removed_list
)
Logger.debug("Deleted #{MapSet.size(removed_vlan_ids)} removed VLANs")
end
# Upsert each discovered VLAN
Enum.map(discovered_vlans, fn vlan_data ->
case Map.get(existing_vlans, vlan_data.vlan_id) do
nil ->
# New VLAN - insert
%Vlan{}
|> Vlan.changeset(Map.put(vlan_data, :snmp_device_id, device.id))
|> Repo.insert!()
existing ->
# Existing VLAN - update
existing
|> Vlan.changeset(vlan_data)
|> Repo.update!()
end
end)
end
@spec sync_ip_addresses(Device.t(), [map()]) :: :ok
@doc """
Sync IP addresses from discovery/polling results to database.
"""
def sync_ip_addresses(device, discovered_ip_addresses) do
# Build a map of if_index to interface ID from the device's interfaces
if_index_to_interface =
Map.new(device.interfaces, fn iface -> {iface.if_index, iface.id} end)
# Get existing IP addresses for all interfaces of this device
interface_ids = Map.values(if_index_to_interface)
existing_ip_addresses =
from(ip in IpAddress, where: ip.snmp_interface_id in ^interface_ids)
|> Repo.all()
|> Map.new(fn ip -> {{ip.snmp_interface_id, ip.ip_address}, ip} end)
# Track which IP addresses we've seen
discovered_keys =
discovered_ip_addresses
|> Enum.map(fn ip_data ->
interface_id = Map.get(if_index_to_interface, ip_data.if_index)
{interface_id, ip_data.ip_address}
end)
|> Enum.reject(fn {iface_id, _} -> is_nil(iface_id) end)
|> MapSet.new()
# Delete IP addresses that no longer exist
existing_keys = MapSet.new(Map.keys(existing_ip_addresses))
removed_keys = MapSet.difference(existing_keys, discovered_keys)
if MapSet.size(removed_keys) > 0 do
removed_list = MapSet.to_list(removed_keys)
Enum.each(removed_list, fn {interface_id, ip_address} ->
Repo.delete_all(
from ip in IpAddress,
where: ip.snmp_interface_id == ^interface_id and ip.ip_address == ^ip_address
)
end)
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
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)
Logger.info(
"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)
if 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(
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:snmp_interface_id, :ip_address]
)
end
end
@spec sync_processors(Device.t(), [map()]) :: :ok
@doc """
Sync processors from discovery/polling results to database.
"""
def sync_processors(device, discovered_processors) do
# Get existing processors for this device, indexed by processor_index
existing_processors =
from(p in Processor, where: p.snmp_device_id == ^device.id)
|> Repo.all()
|> Map.new(&{&1.processor_index, &1})
# Get the set of discovered processor_indices
discovered_indices = MapSet.new(discovered_processors, & &1.processor_index)
# Delete processors that no longer exist on the device
existing_indices = MapSet.new(Map.keys(existing_processors))
removed_indices = MapSet.difference(existing_indices, discovered_indices)
if MapSet.size(removed_indices) > 0 do
removed_list = MapSet.to_list(removed_indices)
Repo.delete_all(
from p in Processor,
where: p.snmp_device_id == ^device.id and p.processor_index in ^removed_list
)
Logger.debug("Deleted #{MapSet.size(removed_indices)} removed processors")
end
# Upsert each discovered processor
Enum.each(discovered_processors, fn processor_data ->
upsert_processor(device.id, processor_data, existing_processors)
end)
Logger.debug("Synced #{length(discovered_processors)} processors for device")
:ok
end
# Helper to upsert a single processor (insert or update)
defp upsert_processor(device_id, processor_data, existing_processors) do
case Map.get(existing_processors, processor_data.processor_index) do
nil ->
# New processor - insert with conflict handling
attrs = Map.put(processor_data, :snmp_device_id, device_id)
%Processor{}
|> Processor.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:snmp_device_id, :processor_index]
)
|> handle_processor_insert_result()
existing ->
# Existing processor - update
existing
|> Processor.changeset(processor_data)
|> Repo.update!()
end
end
defp handle_processor_insert_result({:ok, _}), do: :ok
defp handle_processor_insert_result({:error, changeset}) do
Logger.error("Failed to upsert processor: #{inspect(changeset)}")
end
@spec sync_storage(Device.t(), [map()]) :: :ok
@doc """
Sync storage from discovery/polling results to database.
"""
def sync_storage(device, discovered_storage) do
# Get existing storage for this device, indexed by storage_index
existing_storage =
from(s in Storage, where: s.snmp_device_id == ^device.id)
|> Repo.all()
|> Map.new(&{&1.storage_index, &1})
# Get the set of discovered storage_indices (convert string index to integer)
discovered_indices =
MapSet.new(discovered_storage, fn s ->
normalize_storage_index(s.storage_index)
end)
# Delete storage that no longer exists on the device
existing_indices = MapSet.new(Map.keys(existing_storage))
removed_indices = MapSet.difference(existing_indices, discovered_indices)
if MapSet.size(removed_indices) > 0 do
removed_list = MapSet.to_list(removed_indices)
Repo.delete_all(
from s in Storage,
where: s.snmp_device_id == ^device.id and s.storage_index in ^removed_list
)
Logger.debug("Deleted #{MapSet.size(removed_indices)} removed storage entries")
end
# Upsert each discovered storage
Enum.each(discovered_storage, fn storage_data ->
storage_index = normalize_storage_index(storage_data.storage_index)
storage_attrs = Map.put(storage_data, :storage_index, storage_index)
case Map.get(existing_storage, storage_index) do
nil ->
# New storage - insert
%Storage{}
|> Storage.changeset(Map.put(storage_attrs, :snmp_device_id, device.id))
|> Repo.insert!()
existing ->
# Existing storage - update
existing
|> Storage.changeset(storage_attrs)
|> Repo.update!()
end
end)
Logger.debug("Synced #{length(discovered_storage)} storage entries for device")
:ok
end
# Normalize storage index to integer (Base.discover_storage may return string from OID parsing)
defp normalize_storage_index(index) when is_integer(index), do: index
defp normalize_storage_index(index) when is_binary(index), do: String.to_integer(index)
@spec update_device_discovery_time(DeviceSchema.t()) ::
{:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
defp update_device_discovery_time(device) do
device
|> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
|> Repo.update()
end
@spec save_neighbors(binary(), [map()]) :: :ok
@doc """
Save discovered neighbors to database.
"""
def save_neighbors(device_id, neighbors) do
# Delete stale neighbors (not seen in last 5 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
Towerops.Snmp.delete_stale_neighbors(device_id, cutoff)
# Upsert each discovered neighbor
Enum.each(neighbors, fn neighbor_data ->
Towerops.Snmp.upsert_neighbor(neighbor_data)
end)
Logger.debug("Saved #{length(neighbors)} neighbors for device #{device_id}")
:ok
end
@spec save_arp_entries(binary(), [map()], [Interface.t()]) :: :ok
@doc """
Save discovered ARP entries to database.
"""
def save_arp_entries(device_id, arp_entries, interfaces) do
# Delete stale ARP entries (not seen in last 30 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -30, :minute)
Towerops.Snmp.delete_stale_arp_entries(device_id, cutoff)
# Upsert each discovered ARP entry
{success, _errors} = Towerops.Snmp.upsert_arp_entries(device_id, arp_entries, interfaces)
Logger.debug("Saved #{success} ARP entries for device #{device_id}")
:ok
end
@spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
{:ok, DeviceSchema.t()} | {:error, term()}
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 == ""
if name_empty? and Map.has_key?(system_info, :sys_name) do
sys_name = Map.get(system_info, :sys_name)
if sys_name && sys_name != "" do
Logger.info(
"Updating device name from SNMP sysName: #{sys_name}",
device_id: device.id
)
result =
device
|> Ecto.Changeset.change(name: sys_name)
|> Repo.update()
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
ipv4_count = Enum.count(ip_addresses, &(&1.ip_type == "ipv4"))
ipv6_count = Enum.count(ip_addresses, &(&1.ip_type == "ipv6"))
Logger.info(
"Discovered IP addresses: #{ipv4_count} IPv4, #{ipv6_count} IPv6 (total: #{length(ip_addresses)})",
device_id: device.id
)
end
:ok
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})"
end
interface_count =
case discovered_device.interfaces do
%Ecto.Association.NotLoaded{} -> 0
interfaces when is_list(interfaces) -> length(interfaces)
end
metadata = %{
manufacturer: discovered_device.manufacturer,
model: discovered_device.model,
firmware_version: discovered_device.firmware_version,
serial_number: discovered_device.serial_number,
interface_count: interface_count,
sensor_count:
Repo.aggregate(
from(s in Sensor, where: s.snmp_device_id == ^discovered_device.id),
:count
)
}
event_attrs = %{
device_id: device.id,
event_type: event_type,
severity: "info",
message: message,
metadata: metadata,
occurred_at: DateTime.utc_now()
}
# Publish event for EventLogger to persist
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:events",
{:device_event, event_attrs}
)
end
# Sanitizes data structures to ensure they are JSON-encodable
# Converts non-UTF8 binary strings to base64 representation
@spec sanitize_for_json(term()) :: term()
defp sanitize_for_json(nil), do: nil
# Handle DateTime and other structs before generic map clause
defp sanitize_for_json(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp sanitize_for_json(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp sanitize_for_json(%Date{} = d), do: Date.to_iso8601(d)
defp sanitize_for_json(%Time{} = t), do: Time.to_iso8601(t)
# Generic struct handling - convert to map first, then sanitize
defp sanitize_for_json(%{__struct__: _} = struct) do
struct
|> Map.from_struct()
|> sanitize_for_json()
end
defp sanitize_for_json(map) when is_map(map) do
Map.new(map, fn {k, v} -> {sanitize_for_json(k), sanitize_for_json(v)} end)
end
defp sanitize_for_json(list) when is_list(list) do
Enum.map(list, &sanitize_for_json/1)
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
end
defp sanitize_for_json(tuple) when is_tuple(tuple) do
tuple
|> Tuple.to_list()
|> Enum.map(&sanitize_for_json/1)
end
defp sanitize_for_json(other), do: other
# Deduplicate sensors by OID - when multiple sensors share the same OID,
# prefer the one with a proper description (not containing "MIB::")
# This handles cases where YAML profiles and vendor profiles discover the same sensor
defp deduplicate_sensors_by_oid(sensors) do
sensors
|> Enum.group_by(& &1.sensor_oid)
|> Enum.map(fn {_oid, group} ->
# Prefer sensor with proper description (no MIB name)
Enum.find(group, fn s -> not String.contains?(s.sensor_descr, "MIB::") end) ||
List.last(group)
end)
end
end