towerops/lib/towerops/snmp/discovery.ex
2026-01-03 14:58:06 -06:00

305 lines
9.9 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.Equipment.Equipment
alias Towerops.Repo
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.Profiles.Cisco
alias Towerops.Snmp.Profiles.Mikrotik
alias Towerops.Snmp.Profiles.NetSnmp
alias Towerops.Snmp.Sensor
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()
@type discovery_summary :: %{
success: non_neg_integer(),
failed: non_neg_integer(),
errors: [term()]
}
@doc """
Runs discovery for a single piece of equipment.
Returns {:ok, device} or {:error, reason}.
## Examples
iex> discover_equipment(equipment)
{:ok, %Device{manufacturer: "Cisco", model: "C2960"}}
iex> discover_equipment(equipment_without_snmp)
{:error, :snmp_not_enabled}
"""
@spec discover_equipment(Equipment.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_equipment(%Equipment{} = equipment) do
if equipment.snmp_enabled do
Logger.info("Starting SNMP discovery for equipment: #{equipment.name} (#{equipment.ip_address})")
client_opts = build_client_opts(equipment)
with {:ok, _} <- Client.test_connection(client_opts),
{:ok, system_info} <- discover_system(client_opts),
profile = select_profile(system_info),
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
{:ok, interfaces} <- discover_interfaces(client_opts, profile),
{:ok, sensors} <- discover_sensors(client_opts, profile),
{:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors) do
update_equipment_discovery_time(equipment)
Logger.info("SNMP discovery completed successfully for: #{equipment.name}")
{:ok, device}
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{equipment.name}: #{inspect(reason)}")
error
end
else
{:error, :snmp_not_enabled}
end
end
@doc """
Runs discovery for all SNMP-enabled equipment in an organization.
Returns a summary of successful and failed discoveries.
"""
@spec discover_all(String.t()) :: {:ok, discovery_summary()}
def discover_all(org_id) do
equipment_list =
Equipment
|> where([e], e.organization_id == ^org_id and e.snmp_enabled == true)
|> Repo.all()
Logger.info("Starting SNMP discovery for #{length(equipment_list)} devices in org #{org_id}")
results =
equipment_list
|> Task.async_stream(
&discover_equipment/1,
max_concurrency: 5,
timeout: 60_000,
on_timeout: :kill_task
)
|> Enum.reduce(%{success: 0, failed: 0, errors: []}, fn
{:ok, {:ok, _device}}, acc ->
%{acc | success: acc.success + 1}
{:ok, {:error, reason}}, acc ->
%{acc | failed: acc.failed + 1, errors: [reason | acc.errors]}
{:exit, :timeout}, acc ->
%{acc | failed: acc.failed + 1, errors: [:timeout | acc.errors]}
end)
Logger.info("Discovery completed: #{results.success} succeeded, #{results.failed} failed")
{:ok, results}
end
# Private functions
@spec build_client_opts(Equipment.t()) :: Client.connection_opts()
defp build_client_opts(equipment) do
[
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port || 161,
timeout: 5000
]
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
@spec select_profile(system_info()) :: profile()
defp select_profile(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
cond do
String.contains?(sys_descr, "RouterOS") ->
Logger.debug("Selected MikroTik profile")
Mikrotik
String.contains?(sys_descr, ["Cisco", "IOS"]) ->
Logger.debug("Selected Cisco profile")
Cisco
String.contains?(sys_descr, "Linux") ->
Logger.debug("Selected NetSNMP profile")
NetSnmp
true ->
Logger.debug("Selected Base profile")
Base
end
end
@spec build_device_info(Client.connection_opts(), system_info(), profile()) ::
{:ok, device_info()}
defp build_device_info(_client_opts, system_info, profile) do
# Let the profile identify the device (adds manufacturer, model, firmware_version)
identified_info = profile.identify_device(system_info)
{:ok, identified_info}
rescue
_error ->
Logger.error("Failed to identify device")
{:ok, system_info}
end
@spec discover_interfaces(Client.connection_opts(), profile()) ::
{:ok, [interface_data()]}
defp discover_interfaces(client_opts, 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, profile) do
case profile.discover_sensors(client_opts) do
{:ok, sensors} ->
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
{:error, _} ->
Logger.warning("Sensor discovery failed, continuing without sensors")
{:ok, []}
end
end
@spec save_discovery_results(
Equipment.t(),
device_info(),
[interface_data()],
[sensor_data()]
) :: {:ok, Device.t()} | {:error, term()}
defp save_discovery_results(equipment, device_info, interfaces, sensors) do
Repo.transaction(fn ->
# Upsert Device
device = upsert_device(equipment, device_info)
# Delete old interfaces/sensors and insert new ones
delete_old_data(device)
insert_interfaces(device, interfaces)
insert_sensors(device, sensors)
device
end)
end
@spec upsert_device(Equipment.t(), device_info()) :: Device.t()
defp upsert_device(equipment, device_info) do
case Repo.get_by(Device, equipment_id: equipment.id) do
nil ->
%Device{}
|> Device.changeset(Map.put(device_info, :equipment_id, equipment.id))
|> Repo.insert!()
existing_device ->
existing_device
|> Device.changeset(device_info)
|> Repo.update!()
end
end
@spec delete_old_data(Device.t()) :: {non_neg_integer(), nil | [term()]}
defp delete_old_data(device) do
# Delete old interfaces and sensors (cascade will handle stats/readings)
Repo.delete_all(from i in Interface, where: i.snmp_device_id == ^device.id)
Repo.delete_all(from s in Sensor, where: s.snmp_device_id == ^device.id)
end
@spec insert_interfaces(Device.t(), [interface_data()]) :: [Interface.t()]
defp insert_interfaces(device, interfaces) do
Enum.map(interfaces, fn interface_data ->
%Interface{}
|> Interface.changeset(Map.put(interface_data, :snmp_device_id, device.id))
|> Repo.insert!()
end)
end
@spec insert_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
defp insert_sensors(device, sensors) do
Enum.map(sensors, fn sensor_data ->
%Sensor{}
|> Sensor.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
|> Repo.insert!()
end)
end
@spec update_equipment_discovery_time(Equipment.t()) ::
{:ok, Equipment.t()} | {:error, Ecto.Changeset.t()}
defp update_equipment_discovery_time(equipment) do
equipment
|> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
|> Repo.update()
end
end