add state sensor schema, migration, and discovery
This commit is contained in:
parent
7c660169b7
commit
c3e26a44d6
6 changed files with 670 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -45,7 +45,7 @@ k8s/.envrc
|
|||
/towerops-agent/
|
||||
|
||||
# TODO tracking (personal notes, not for version control)
|
||||
TODOS.md
|
||||
TODO.md
|
||||
|
||||
# SNMP profiles export (temporary/local data)
|
||||
profiles.json
|
||||
|
|
@ -60,3 +60,4 @@ profiles.json
|
|||
|
||||
# Application MIB files (now committed to git and included in Docker image)
|
||||
# Only ignore the top-level librenms mibs directory (symlink)
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
ent_phys_model_name: "1.3.6.1.2.1.47.1.1.1.1.13"
|
||||
}
|
||||
|
||||
@entity_state_oids %{
|
||||
# ENTITY-STATE-MIB - Operational state monitoring
|
||||
ent_state_oper: "1.3.6.1.2.1.131.1.1.1.1"
|
||||
}
|
||||
|
||||
# ENTITY-MIB physical class values to track for state sensors
|
||||
# 6 = powerSupply, 7 = fan
|
||||
@state_sensor_classes [6, 7]
|
||||
|
||||
@doc """
|
||||
Discovers system information from SNMPv2-MIB.
|
||||
Returns a map with device metadata.
|
||||
|
|
@ -196,6 +205,63 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Discovers state sensors from ENTITY-MIB and ENTITY-STATE-MIB.
|
||||
State sensors track discrete status values for power supplies, fans, and modules.
|
||||
Returns a list of state sensor maps.
|
||||
"""
|
||||
@spec discover_state_sensors(Client.connection_opts()) :: {:ok, [map()]}
|
||||
def discover_state_sensors(client_opts) do
|
||||
# Walk entity physical class to find entities with state
|
||||
# Client.walk returns {:ok, %{oid => value}} map
|
||||
case Client.walk(client_opts, @entity_mib_oids.ent_phys_class) do
|
||||
{:ok, class_results} when is_map(class_results) and map_size(class_results) > 0 ->
|
||||
do_discover_state_sensors(client_opts, class_results)
|
||||
|
||||
{:ok, _} ->
|
||||
Logger.debug("No ENTITY-MIB physical entities found")
|
||||
{:ok, []}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("ENTITY-MIB walk failed: #{inspect(reason)}")
|
||||
{:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_discover_state_sensors(client_opts, class_results) do
|
||||
# Build a map of index => class (Client.walk already extracts values)
|
||||
class_map = build_index_value_map(class_results)
|
||||
|
||||
# Filter to only power supplies and fans
|
||||
state_indices =
|
||||
class_map
|
||||
|> Enum.filter(fn {_index, class} -> class in @state_sensor_classes end)
|
||||
|> Enum.map(fn {index, _class} -> index end)
|
||||
|
||||
if Enum.empty?(state_indices) do
|
||||
Logger.debug("No power supply or fan entities found")
|
||||
{:ok, []}
|
||||
else
|
||||
build_state_sensors_from_indices(client_opts, state_indices, class_map)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_state_sensors_from_indices(client_opts, state_indices, class_map) do
|
||||
# Fetch descriptions and operational states
|
||||
descr_map = fetch_entity_descr_map(client_opts)
|
||||
state_map = fetch_entity_state_map(client_opts)
|
||||
|
||||
state_sensors =
|
||||
state_indices
|
||||
|> Enum.map(fn index ->
|
||||
build_state_sensor(index, class_map, descr_map, state_map)
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
Logger.debug("Discovered #{length(state_sensors)} state sensors")
|
||||
{:ok, state_sensors}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Identifies device manufacturer and model from sysDescr and sysObjectID.
|
||||
Can be overridden by vendor-specific profiles.
|
||||
|
|
@ -442,6 +508,83 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
defp parse_sensor_status(3), do: "nonoperational"
|
||||
defp parse_sensor_status(_), do: "unknown"
|
||||
|
||||
# State sensor helper functions
|
||||
|
||||
# Build a map of index => value from Client.walk results (already a Map)
|
||||
# Client.walk returns %{"full.oid.string" => extracted_value}
|
||||
defp build_index_value_map(walk_map) when is_map(walk_map) do
|
||||
Map.new(walk_map, fn {oid, value} ->
|
||||
index = extract_index_from_oid(oid)
|
||||
{index, value}
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_index_value_map(_), do: %{}
|
||||
|
||||
defp extract_index_from_oid(oid) when is_binary(oid) do
|
||||
oid
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
end
|
||||
|
||||
defp fetch_entity_descr_map(client_opts) do
|
||||
case Client.walk(client_opts, @entity_mib_oids.ent_phys_descr) do
|
||||
{:ok, results} when is_map(results) -> build_index_value_map(results)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_entity_state_map(client_opts) do
|
||||
case Client.walk(client_opts, @entity_state_oids.ent_state_oper) do
|
||||
{:ok, results} when is_map(results) -> build_index_value_map(results)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_state_sensor(index, class_map, descr_map, state_map) do
|
||||
class = Map.get(class_map, index)
|
||||
descr = Map.get(descr_map, index, "Entity #{index}")
|
||||
state_value = Map.get(state_map, index)
|
||||
|
||||
entity_type = physical_class_to_type(class)
|
||||
status = entity_state_to_status(state_value)
|
||||
|
||||
%{
|
||||
sensor_index: index,
|
||||
sensor_oid: @entity_state_oids.ent_state_oper <> ".#{index}",
|
||||
sensor_descr: descr,
|
||||
entity_type: entity_type,
|
||||
state_value: state_value,
|
||||
state_descr: entity_state_to_descr(state_value),
|
||||
status: status,
|
||||
last_checked_at: DateTime.utc_now()
|
||||
}
|
||||
end
|
||||
|
||||
# ENTITY-MIB physical class to type mapping
|
||||
defp physical_class_to_type(6), do: "power_supply"
|
||||
defp physical_class_to_type(7), do: "fan"
|
||||
defp physical_class_to_type(9), do: "module"
|
||||
defp physical_class_to_type(3), do: "chassis"
|
||||
defp physical_class_to_type(5), do: "container"
|
||||
defp physical_class_to_type(8), do: "sensor"
|
||||
defp physical_class_to_type(_), do: "unknown"
|
||||
|
||||
# ENTITY-STATE-MIB operational status mapping
|
||||
# 1=unknown, 2=disabled, 3=enabled, 4=testing
|
||||
# We map to: ok, warning, critical, unknown
|
||||
defp entity_state_to_status(1), do: "unknown"
|
||||
defp entity_state_to_status(2), do: "ok"
|
||||
defp entity_state_to_status(3), do: "warning"
|
||||
defp entity_state_to_status(4), do: "unknown"
|
||||
defp entity_state_to_status(_), do: "unknown"
|
||||
|
||||
defp entity_state_to_descr(1), do: "unknown"
|
||||
defp entity_state_to_descr(2), do: "enabled"
|
||||
defp entity_state_to_descr(3), do: "disabled"
|
||||
defp entity_state_to_descr(4), do: "testing"
|
||||
defp entity_state_to_descr(_), do: "unknown"
|
||||
|
||||
@doc """
|
||||
Collects raw debug data for troubleshooting.
|
||||
Includes system OIDs and interface/sensor tables.
|
||||
|
|
|
|||
79
lib/towerops/snmp/state_sensor.ex
Normal file
79
lib/towerops/snmp/state_sensor.ex
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
defmodule Towerops.Snmp.StateSensor do
|
||||
@moduledoc """
|
||||
SNMP state sensor schema for monitoring device health status.
|
||||
|
||||
Unlike numeric sensors that track values like temperature or voltage,
|
||||
state sensors track discrete states like power supply status, fan status,
|
||||
and module operational status.
|
||||
|
||||
State values are mapped to generic status categories:
|
||||
- ok: Normal operation
|
||||
- warning: Degraded but functional
|
||||
- critical: Failed or about to fail
|
||||
- unknown: State not determined
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Ecto.Association.NotLoaded
|
||||
alias Towerops.Snmp.Device
|
||||
|
||||
@valid_statuses ~w(ok warning critical unknown)
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "snmp_state_sensors" do
|
||||
field :sensor_index, :string
|
||||
field :sensor_oid, :string
|
||||
field :sensor_descr, :string
|
||||
field :entity_type, :string
|
||||
field :state_value, :integer
|
||||
field :state_descr, :string
|
||||
field :status, :string, default: "unknown"
|
||||
field :last_checked_at, :utc_datetime
|
||||
field :metadata, :map, default: %{}
|
||||
|
||||
belongs_to :snmp_device, Device
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: Ecto.UUID.t(),
|
||||
sensor_index: String.t(),
|
||||
sensor_oid: String.t(),
|
||||
sensor_descr: String.t() | nil,
|
||||
entity_type: String.t() | nil,
|
||||
state_value: integer() | nil,
|
||||
state_descr: String.t() | nil,
|
||||
status: String.t(),
|
||||
last_checked_at: DateTime.t() | nil,
|
||||
metadata: map(),
|
||||
snmp_device_id: Ecto.UUID.t(),
|
||||
snmp_device: NotLoaded.t() | Device.t(),
|
||||
inserted_at: DateTime.t(),
|
||||
updated_at: DateTime.t()
|
||||
}
|
||||
|
||||
@doc false
|
||||
def changeset(state_sensor, attrs) do
|
||||
state_sensor
|
||||
|> cast(attrs, [
|
||||
:snmp_device_id,
|
||||
:sensor_index,
|
||||
:sensor_oid,
|
||||
:sensor_descr,
|
||||
:entity_type,
|
||||
:state_value,
|
||||
:state_descr,
|
||||
:status,
|
||||
:last_checked_at,
|
||||
:metadata
|
||||
])
|
||||
|> validate_required([:snmp_device_id, :sensor_index, :sensor_oid])
|
||||
|> validate_inclusion(:status, @valid_statuses)
|
||||
|> unique_constraint([:snmp_device_id, :sensor_index])
|
||||
|> foreign_key_constraint(:snmp_device_id)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateSnmpStateSensors do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:snmp_state_sensors, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :sensor_index, :string, null: false
|
||||
add :sensor_oid, :string, null: false
|
||||
add :sensor_descr, :string
|
||||
add :entity_type, :string
|
||||
add :state_value, :integer
|
||||
add :state_descr, :string
|
||||
add :status, :string, default: "unknown"
|
||||
add :last_checked_at, :utc_datetime
|
||||
add :metadata, :map, default: %{}
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:snmp_state_sensors, [:snmp_device_id])
|
||||
create unique_index(:snmp_state_sensors, [:snmp_device_id, :sensor_index])
|
||||
create index(:snmp_state_sensors, [:status])
|
||||
create index(:snmp_state_sensors, [:entity_type])
|
||||
end
|
||||
end
|
||||
|
|
@ -377,4 +377,175 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
|
|||
assert device_info.sys_location == "DC1"
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_state_sensors/1" do
|
||||
test "discovers state sensors from ENTITY-STATE-MIB" do
|
||||
# Mock the walk for entity physical class to find modules/power supplies
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
# entPhysicalClass walk - find entities by class
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5" ->
|
||||
{:ok,
|
||||
[
|
||||
# powerSupply = 6, fan = 7, sensor = 8, module = 9
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1001", value: {:integer, 6}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1002", value: {:integer, 6}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.2001", value: {:integer, 7}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.2002", value: {:integer, 7}}
|
||||
]}
|
||||
|
||||
# entPhysicalDescr walk - descriptions
|
||||
"1.3.6.1.2.1.47.1.1.1.1.2" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1001", value: {:octet_string, "Power Supply 1"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1002", value: {:octet_string, "Power Supply 2"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.2001", value: {:octet_string, "Fan Tray 1"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.2002", value: {:octet_string, "Fan Tray 2"}}
|
||||
]}
|
||||
|
||||
# ENTITY-STATE-MIB operational status walk
|
||||
"1.3.6.1.2.1.131.1.1.1.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.1001", value: {:integer, 2}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.1002", value: {:integer, 2}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.2001", value: {:integer, 2}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.2002", value: {:integer, 3}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts)
|
||||
|
||||
assert length(state_sensors) == 4
|
||||
|
||||
psu1 = Enum.find(state_sensors, &(&1.sensor_index == "1001"))
|
||||
assert psu1.sensor_descr == "Power Supply 1"
|
||||
assert psu1.entity_type == "power_supply"
|
||||
assert psu1.state_value == 2
|
||||
assert psu1.status == "ok"
|
||||
|
||||
fan2 = Enum.find(state_sensors, &(&1.sensor_index == "2002"))
|
||||
assert fan2.sensor_descr == "Fan Tray 2"
|
||||
assert fan2.entity_type == "fan"
|
||||
assert fan2.state_value == 3
|
||||
assert fan2.status == "warning"
|
||||
end
|
||||
|
||||
test "returns empty list when ENTITY-STATE-MIB not supported" do
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts)
|
||||
assert state_sensors == []
|
||||
end
|
||||
|
||||
test "handles walk errors gracefully" do
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
|
||||
|
||||
assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts)
|
||||
assert state_sensors == []
|
||||
end
|
||||
|
||||
test "filters only power supplies and fans by default" do
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5" ->
|
||||
{:ok,
|
||||
[
|
||||
# chassis = 3, container = 5, powerSupply = 6, fan = 7
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1", value: {:integer, 3}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.100", value: {:integer, 6}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.200", value: {:integer, 7}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.300", value: {:integer, 5}}
|
||||
]}
|
||||
|
||||
"1.3.6.1.2.1.47.1.1.1.1.2" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1", value: {:octet_string, "Chassis"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.100", value: {:octet_string, "PSU"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.200", value: {:octet_string, "Fan"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.300", value: {:octet_string, "Container"}}
|
||||
]}
|
||||
|
||||
"1.3.6.1.2.1.131.1.1.1.1" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.1", value: {:integer, 2}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.100", value: {:integer, 2}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.200", value: {:integer, 2}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.300", value: {:integer, 2}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts)
|
||||
|
||||
# Should only include power supplies and fans (not chassis or container)
|
||||
assert length(state_sensors) == 2
|
||||
entity_types = Enum.map(state_sensors, & &1.entity_type)
|
||||
assert "power_supply" in entity_types
|
||||
assert "fan" in entity_types
|
||||
refute "chassis" in entity_types
|
||||
refute "container" in entity_types
|
||||
end
|
||||
|
||||
test "maps ENTITY-STATE-MIB operational status to status correctly" do
|
||||
stub(SnmpMock, :walk, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1", value: {:integer, 6}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.2", value: {:integer, 6}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.3", value: {:integer, 6}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.4", value: {:integer, 6}}
|
||||
]}
|
||||
|
||||
"1.3.6.1.2.1.47.1.1.1.1.2" ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1", value: {:octet_string, "PSU1"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.2", value: {:octet_string, "PSU2"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.3", value: {:octet_string, "PSU3"}},
|
||||
%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.4", value: {:octet_string, "PSU4"}}
|
||||
]}
|
||||
|
||||
"1.3.6.1.2.1.131.1.1.1.1" ->
|
||||
{:ok,
|
||||
[
|
||||
# 1=unknown, 2=enabled(ok), 3=disabled(warning), 4=testing(unknown)
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.1", value: {:integer, 1}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.2", value: {:integer, 2}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.3", value: {:integer, 3}},
|
||||
%{oid: "1.3.6.1.2.1.131.1.1.1.1.4", value: {:integer, 4}}
|
||||
]}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
|
||||
|
||||
assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts)
|
||||
|
||||
statuses = Map.new(state_sensors, &{&1.sensor_index, &1.status})
|
||||
assert statuses["1"] == "unknown"
|
||||
assert statuses["2"] == "ok"
|
||||
assert statuses["3"] == "warning"
|
||||
assert statuses["4"] == "unknown"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
246
test/towerops/snmp/state_sensor_test.exs
Normal file
246
test/towerops/snmp/state_sensor_test.exs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
defmodule Towerops.Snmp.StateSensorTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.StateSensor
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
|
||||
{:ok, organization} =
|
||||
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device_schema} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Test Router",
|
||||
ip_address: "192.168.1.1",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id
|
||||
})
|
||||
|
||||
snmp_device =
|
||||
%Device{}
|
||||
|> Device.changeset(%{
|
||||
device_id: device_schema.id,
|
||||
sys_name: "test-router",
|
||||
sys_descr: "Test Router"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
%{device: device_schema, snmp_device: snmp_device}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset with all required fields", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1"
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "valid changeset with all fields", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1",
|
||||
sensor_descr: "Power Supply 1",
|
||||
entity_type: "power_supply",
|
||||
state_value: 1,
|
||||
state_descr: "normal",
|
||||
status: "ok",
|
||||
last_checked_at: DateTime.utc_now(),
|
||||
metadata: %{"vendor" => "cisco"}
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :sensor_descr) == "Power Supply 1"
|
||||
assert get_field(changeset, :entity_type) == "power_supply"
|
||||
assert get_field(changeset, :state_value) == 1
|
||||
assert get_field(changeset, :state_descr) == "normal"
|
||||
assert get_field(changeset, :status) == "ok"
|
||||
end
|
||||
|
||||
test "invalid changeset without snmp_device_id" do
|
||||
attrs = %{
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1"
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).snmp_device_id
|
||||
end
|
||||
|
||||
test "invalid changeset without sensor_index", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1"
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).sensor_index
|
||||
end
|
||||
|
||||
test "invalid changeset without sensor_oid", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "psu.1"
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).sensor_oid
|
||||
end
|
||||
|
||||
test "invalid status value", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1",
|
||||
status: "invalid_status"
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "is invalid" in errors_on(changeset).status
|
||||
end
|
||||
|
||||
test "valid status values", %{snmp_device: snmp_device} do
|
||||
for status <- ~w(ok warning critical unknown) do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "psu.#{status}",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1",
|
||||
status: status
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
assert changeset.valid?, "status '#{status}' should be valid"
|
||||
end
|
||||
end
|
||||
|
||||
test "default status is unknown", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1"
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
assert changeset.valid?
|
||||
# Default should come from schema, not changeset
|
||||
end
|
||||
|
||||
test "default metadata is empty map", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1"
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
|
||||
describe "unique constraint" do
|
||||
test "prevents duplicate sensors with same device and index", %{snmp_device: snmp_device} do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1"
|
||||
}
|
||||
|
||||
# Insert first sensor
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
assert {:ok, _sensor} = Repo.insert(changeset)
|
||||
|
||||
# Attempt to insert duplicate
|
||||
changeset2 = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
|
||||
assert {:error, changeset} = Repo.insert(changeset2)
|
||||
errors = errors_on(changeset)
|
||||
# The unique constraint may report on either field
|
||||
assert "has already been taken" in Map.get(errors, :sensor_index, []) or
|
||||
"has already been taken" in Map.get(errors, :snmp_device_id, [])
|
||||
end
|
||||
|
||||
test "allows same sensor_index for different devices", %{device: device_schema} do
|
||||
# Create second device
|
||||
{:ok, device2} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Test Switch",
|
||||
ip_address: "192.168.1.2",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: device_schema.site_id
|
||||
})
|
||||
|
||||
snmp_device2 =
|
||||
%Device{}
|
||||
|> Device.changeset(%{
|
||||
device_id: device2.id,
|
||||
sys_name: "test-switch",
|
||||
sys_descr: "Test Switch"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
snmp_device1 = Repo.get_by!(Device, device_id: device_schema.id)
|
||||
|
||||
attrs1 = %{
|
||||
snmp_device_id: snmp_device1.id,
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1"
|
||||
}
|
||||
|
||||
attrs2 = %{
|
||||
snmp_device_id: snmp_device2.id,
|
||||
sensor_index: "psu.1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1"
|
||||
}
|
||||
|
||||
changeset1 = StateSensor.changeset(%StateSensor{}, attrs1)
|
||||
assert {:ok, _sensor1} = Repo.insert(changeset1)
|
||||
|
||||
changeset2 = StateSensor.changeset(%StateSensor{}, attrs2)
|
||||
assert {:ok, _sensor2} = Repo.insert(changeset2)
|
||||
end
|
||||
end
|
||||
|
||||
describe "entity_type values" do
|
||||
test "accepts common entity types", %{snmp_device: snmp_device} do
|
||||
entity_types = ~w(power_supply fan module chassis sensor port)
|
||||
|
||||
for {entity_type, i} <- Enum.with_index(entity_types) do
|
||||
attrs = %{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "entity.#{i}",
|
||||
sensor_oid: "1.3.6.1.2.1.131.1.1.1.#{i}",
|
||||
entity_type: entity_type
|
||||
}
|
||||
|
||||
changeset = StateSensor.changeset(%StateSensor{}, attrs)
|
||||
assert changeset.valid?, "entity_type '#{entity_type}' should be valid"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue