- 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
1290 lines
45 KiB
Elixir
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
|