feat: add memory pool and storage discovery (Phase 1.5)

- Add snmp_memory_pools table and MemoryPool schema
- Add snmp_storage table and Storage schema
- Add discover_memory_pools/1 for HOST-RESOURCES-MIB RAM/swap
- Add discover_storage/1 for HOST-RESOURCES-MIB disk storage
- Add comprehensive tests for schema validation and discovery
- Completes Phase 1 of discovery improvements
This commit is contained in:
Graham McIntire 2026-01-21 10:57:08 -06:00
parent 45ddcdac80
commit 1fc066d95b
No known key found for this signature in database
8 changed files with 952 additions and 0 deletions

View file

@ -0,0 +1,69 @@
defmodule Towerops.Snmp.MemoryPool do
@moduledoc """
SNMP memory pool schema for tracking RAM, swap, and other memory types.
Memory pools are discovered via:
- HOST-RESOURCES-MIB hrStorageTable (RAM, virtual memory)
- UCD-SNMP-MIB memory group (Linux memory stats)
Tracks total and used memory for capacity monitoring.
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@valid_pool_types ~w(ram swap cache buffer other)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_memory_pools" do
field :pool_index, :string
field :pool_name, :string
field :pool_type, :string
field :total_bytes, :integer
field :used_bytes, :integer
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(),
pool_index: String.t(),
pool_name: String.t() | nil,
pool_type: String.t(),
total_bytes: integer() | nil,
used_bytes: integer() | nil,
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(memory_pool, attrs) do
memory_pool
|> cast(attrs, [
:snmp_device_id,
:pool_index,
:pool_name,
:pool_type,
:total_bytes,
:used_bytes,
:last_checked_at,
:metadata
])
|> validate_required([:snmp_device_id, :pool_index, :pool_type])
|> validate_inclusion(:pool_type, @valid_pool_types)
|> unique_constraint([:snmp_device_id, :pool_index])
|> foreign_key_constraint(:snmp_device_id)
end
end

View file

@ -84,6 +84,33 @@ defmodule Towerops.Snmp.Profiles.Base do
ip_ad_ent_net_mask: "1.3.6.1.2.1.4.20.1.3"
}
@host_resources_oids %{
# HOST-RESOURCES-MIB - hrStorageTable for memory and storage
hr_storage_type: "1.3.6.1.2.1.25.2.3.1.2",
hr_storage_descr: "1.3.6.1.2.1.25.2.3.1.3",
hr_storage_allocation_units: "1.3.6.1.2.1.25.2.3.1.4",
hr_storage_size: "1.3.6.1.2.1.25.2.3.1.5",
hr_storage_used: "1.3.6.1.2.1.25.2.3.1.6"
}
# HOST-RESOURCES-MIB storage type OIDs mapping
# Memory types (for discover_memory_pools)
@memory_type_oids %{
[1, 3, 6, 1, 2, 1, 25, 2, 1, 2] => "ram",
[1, 3, 6, 1, 2, 1, 25, 2, 1, 3] => "swap"
}
# Disk/storage types (for discover_storage) - excludes "other" (1) which is too generic
@storage_type_oids %{
[1, 3, 6, 1, 2, 1, 25, 2, 1, 4] => "fixed_disk",
[1, 3, 6, 1, 2, 1, 25, 2, 1, 5] => "removable_disk",
[1, 3, 6, 1, 2, 1, 25, 2, 1, 6] => "floppy_disk",
[1, 3, 6, 1, 2, 1, 25, 2, 1, 7] => "compact_disc",
[1, 3, 6, 1, 2, 1, 25, 2, 1, 8] => "ram_disk",
[1, 3, 6, 1, 2, 1, 25, 2, 1, 9] => "flash_memory",
[1, 3, 6, 1, 2, 1, 25, 2, 1, 10] => "network_disk"
}
# ENTITY-MIB physical class values to track for state sensors
# 6 = powerSupply, 7 = fan
@state_sensor_classes [6, 7]
@ -413,6 +440,154 @@ defmodule Towerops.Snmp.Profiles.Base do
{:ok, ip_addresses}
end
@doc """
Discovers memory pools from HOST-RESOURCES-MIB hrStorageTable.
Returns a list of memory pool maps (RAM, swap/virtual memory).
"""
@spec discover_memory_pools(Client.connection_opts()) :: {:ok, [map()]}
def discover_memory_pools(client_opts) do
# Walk storage type to identify memory entries
case Client.walk(client_opts, @host_resources_oids.hr_storage_type) do
{:ok, type_results} when is_map(type_results) and map_size(type_results) > 0 ->
do_discover_memory_pools(client_opts, type_results)
{:ok, _} ->
Logger.debug("No storage entries found in HOST-RESOURCES-MIB")
{:ok, []}
{:error, reason} ->
Logger.debug("HOST-RESOURCES-MIB walk failed: #{inspect(reason)}")
{:ok, []}
end
end
defp do_discover_memory_pools(client_opts, type_results) do
# Fetch all storage attributes
descr_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_descr)
alloc_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_allocation_units)
size_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_size)
used_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_used)
# Build memory pool list (only RAM and virtual memory)
memory_pools =
type_results
|> Enum.map(fn {oid, type_oid} ->
index = extract_hr_storage_index(oid)
pool_type = hr_storage_type_to_memory_type(type_oid)
if pool_type do
allocation_units = Map.get(alloc_map, index, 1)
size_units = Map.get(size_map, index, 0)
used_units = Map.get(used_map, index, 0)
%{
pool_index: to_string(index),
pool_name: Map.get(descr_map, index),
pool_type: pool_type,
total_bytes: size_units * allocation_units,
used_bytes: used_units * allocation_units,
last_checked_at: DateTime.utc_now()
}
end
end)
|> Enum.reject(&is_nil/1)
Logger.debug("Discovered #{length(memory_pools)} memory pools from HOST-RESOURCES-MIB")
{:ok, memory_pools}
end
@doc """
Discovers storage from HOST-RESOURCES-MIB hrStorageTable.
Returns a list of storage maps (fixed disks, removable disks, network disks, etc.).
"""
@spec discover_storage(Client.connection_opts()) :: {:ok, [map()]}
def discover_storage(client_opts) do
# Walk storage type to identify storage entries
case Client.walk(client_opts, @host_resources_oids.hr_storage_type) do
{:ok, type_results} when is_map(type_results) and map_size(type_results) > 0 ->
do_discover_storage(client_opts, type_results)
{:ok, _} ->
Logger.debug("No storage entries found in HOST-RESOURCES-MIB")
{:ok, []}
{:error, reason} ->
Logger.debug("HOST-RESOURCES-MIB walk failed: #{inspect(reason)}")
{:ok, []}
end
end
defp do_discover_storage(client_opts, type_results) do
# Fetch all storage attributes
descr_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_descr)
alloc_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_allocation_units)
size_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_size)
used_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_used)
# Build storage list (only disk types, not RAM/virtual memory)
storage =
type_results
|> Enum.map(fn {oid, type_oid} ->
index = extract_hr_storage_index(oid)
storage_type = hr_storage_type_to_disk_type(type_oid)
if storage_type do
allocation_units = Map.get(alloc_map, index, 1)
size_units = Map.get(size_map, index, 0)
used_units = Map.get(used_map, index, 0)
%{
storage_index: index,
description: Map.get(descr_map, index),
storage_type: storage_type,
allocation_units: allocation_units,
total_bytes: size_units * allocation_units,
used_bytes: used_units * allocation_units,
last_checked_at: DateTime.utc_now()
}
end
end)
|> Enum.reject(&is_nil/1)
Logger.debug("Discovered #{length(storage)} storage entries from HOST-RESOURCES-MIB")
{:ok, storage}
end
defp walk_hr_storage_attribute(client_opts, oid) do
case Client.walk(client_opts, oid) do
{:ok, results} when is_map(results) ->
Map.new(results, fn {result_oid, value} ->
{extract_hr_storage_index(result_oid), value}
end)
_ ->
%{}
end
end
defp extract_hr_storage_index(oid) do
oid
|> String.split(".")
|> List.last()
|> String.to_integer()
rescue
_ -> 0
end
# Map hrStorageType OID to memory pool type (RAM, swap)
defp hr_storage_type_to_memory_type(type_oid) when is_list(type_oid) do
Map.get(@memory_type_oids, type_oid)
end
defp hr_storage_type_to_memory_type(_), do: nil
# Map hrStorageType OID to storage type (disk types only)
defp hr_storage_type_to_disk_type(type_oid) when is_list(type_oid) do
Map.get(@storage_type_oids, type_oid)
end
defp hr_storage_type_to_disk_type(_), do: nil
@doc """
Discovers physical entities from ENTITY-MIB.
Returns a list of physical entity maps including chassis, modules, PSUs, fans, etc.

View file

@ -0,0 +1,77 @@
defmodule Towerops.Snmp.Storage do
@moduledoc """
SNMP storage schema for tracking disk and filesystem usage.
Storage is discovered via HOST-RESOURCES-MIB hrStorageTable and includes:
- Fixed disks (HDD, SSD)
- Removable disks (USB, external)
- Network storage (NFS, iSCSI)
- RAM disks and virtual memory
Tracks total and used space for capacity monitoring.
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@valid_storage_types ~w(other ram virtual_memory fixed_disk removable_disk floppy_disk compact_disc ram_disk flash_memory network_disk)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_storage" do
field :storage_index, :integer
field :storage_type, :string
field :description, :string
field :device_name, :string
field :total_bytes, :integer
field :used_bytes, :integer
field :allocation_units, :integer
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(),
storage_index: integer(),
storage_type: String.t(),
description: String.t() | nil,
device_name: String.t() | nil,
total_bytes: integer() | nil,
used_bytes: integer() | nil,
allocation_units: integer() | nil,
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(storage, attrs) do
storage
|> cast(attrs, [
:snmp_device_id,
:storage_index,
:storage_type,
:description,
:device_name,
:total_bytes,
:used_bytes,
:allocation_units,
:last_checked_at,
:metadata
])
|> validate_required([:snmp_device_id, :storage_index, :storage_type])
|> validate_inclusion(:storage_type, @valid_storage_types)
|> unique_constraint([:snmp_device_id, :storage_index])
|> foreign_key_constraint(:snmp_device_id)
end
end

View file

@ -0,0 +1,27 @@
defmodule Towerops.Repo.Migrations.CreateSnmpMemoryPools do
use Ecto.Migration
def change do
create table(:snmp_memory_pools, 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 :pool_index, :string, null: false
add :pool_name, :string
add :pool_type, :string, null: false
add :total_bytes, :bigint
add :used_bytes, :bigint
add :last_checked_at, :utc_datetime
add :metadata, :map, default: %{}
timestamps(type: :utc_datetime)
end
create index(:snmp_memory_pools, [:snmp_device_id])
create unique_index(:snmp_memory_pools, [:snmp_device_id, :pool_index])
create index(:snmp_memory_pools, [:pool_type])
end
end

View file

@ -0,0 +1,29 @@
defmodule Towerops.Repo.Migrations.CreateSnmpStorage do
use Ecto.Migration
def change do
create table(:snmp_storage, 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 :storage_index, :integer, null: false
add :storage_type, :string, null: false
add :description, :string
add :device_name, :string
add :total_bytes, :bigint
add :used_bytes, :bigint
add :allocation_units, :integer
add :last_checked_at, :utc_datetime
add :metadata, :map, default: %{}
timestamps(type: :utc_datetime)
end
create index(:snmp_storage, [:snmp_device_id])
create unique_index(:snmp_storage, [:snmp_device_id, :storage_index])
create index(:snmp_storage, [:storage_type])
end
end

View file

@ -0,0 +1,156 @@
defmodule Towerops.Snmp.MemoryPoolTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Snmp.Device
alias Towerops.Snmp.MemoryPool
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,
pool_index: "1",
pool_type: "ram"
}
changeset = MemoryPool.changeset(%MemoryPool{}, attrs)
assert changeset.valid?
end
test "valid changeset with all fields", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
pool_index: "1",
pool_name: "Physical Memory",
pool_type: "ram",
total_bytes: 8_589_934_592,
used_bytes: 4_294_967_296,
last_checked_at: DateTime.utc_now(),
metadata: %{"source" => "host-resources"}
}
changeset = MemoryPool.changeset(%MemoryPool{}, attrs)
assert changeset.valid?
assert get_field(changeset, :pool_name) == "Physical Memory"
assert get_field(changeset, :total_bytes) == 8_589_934_592
assert get_field(changeset, :used_bytes) == 4_294_967_296
end
test "invalid changeset without snmp_device_id" do
attrs = %{
pool_index: "1",
pool_type: "ram"
}
changeset = MemoryPool.changeset(%MemoryPool{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).snmp_device_id
end
test "invalid changeset without pool_index", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
pool_type: "ram"
}
changeset = MemoryPool.changeset(%MemoryPool{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).pool_index
end
test "invalid changeset without pool_type", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
pool_index: "1"
}
changeset = MemoryPool.changeset(%MemoryPool{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).pool_type
end
test "invalid pool_type value", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
pool_index: "1",
pool_type: "invalid"
}
changeset = MemoryPool.changeset(%MemoryPool{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).pool_type
end
test "valid pool_type values", %{snmp_device: snmp_device} do
for pool_type <- ~w(ram swap cache buffer other) do
attrs = %{
snmp_device_id: snmp_device.id,
pool_index: pool_type,
pool_type: pool_type
}
changeset = MemoryPool.changeset(%MemoryPool{}, attrs)
assert changeset.valid?, "pool_type '#{pool_type}' should be valid"
end
end
end
describe "unique constraint" do
test "prevents duplicate pool_index on same device", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
pool_index: "1",
pool_type: "ram"
}
# Insert first pool
changeset = MemoryPool.changeset(%MemoryPool{}, attrs)
assert {:ok, _pool} = Repo.insert(changeset)
# Attempt to insert duplicate
changeset2 = MemoryPool.changeset(%MemoryPool{}, attrs)
assert {:error, changeset} = Repo.insert(changeset2)
errors = errors_on(changeset)
assert "has already been taken" in Map.get(errors, :pool_index, []) or
"has already been taken" in Map.get(errors, :snmp_device_id, [])
end
end
end

View file

@ -772,6 +772,264 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
end
end
describe "discover_memory_pools/1" do
test "discovers memory pools from HOST-RESOURCES-MIB" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# hrStorageType - type references
"1.3.6.1.2.1.25.2.3.1.2" ->
{:ok,
[
# RAM: 1.3.6.1.2.1.25.2.1.2
%{oid: "1.3.6.1.2.1.25.2.3.1.2.1", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 2]}},
# Virtual Memory: 1.3.6.1.2.1.25.2.1.3
%{oid: "1.3.6.1.2.1.25.2.3.1.2.3", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 3]}},
# Fixed Disk: 1.3.6.1.2.1.25.2.1.4 (filtered out for memory)
%{oid: "1.3.6.1.2.1.25.2.3.1.2.31", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 4]}}
]}
# hrStorageDescr - descriptions
"1.3.6.1.2.1.25.2.3.1.3" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: {:octet_string, "Physical memory"}},
%{oid: "1.3.6.1.2.1.25.2.3.1.3.3", value: {:octet_string, "Virtual memory"}},
%{oid: "1.3.6.1.2.1.25.2.3.1.3.31", value: {:octet_string, "/dev/sda1"}}
]}
# hrStorageAllocationUnits
"1.3.6.1.2.1.25.2.3.1.4" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.4.1", value: {:integer, 1024}},
%{oid: "1.3.6.1.2.1.25.2.3.1.4.3", value: {:integer, 4096}},
%{oid: "1.3.6.1.2.1.25.2.3.1.4.31", value: {:integer, 4096}}
]}
# hrStorageSize - total units
"1.3.6.1.2.1.25.2.3.1.5" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: {:integer, 8_388_608}},
%{oid: "1.3.6.1.2.1.25.2.3.1.5.3", value: {:integer, 16_777_216}},
%{oid: "1.3.6.1.2.1.25.2.3.1.5.31", value: {:integer, 244_140_625}}
]}
# hrStorageUsed - used units
"1.3.6.1.2.1.25.2.3.1.6" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: {:integer, 4_194_304}},
%{oid: "1.3.6.1.2.1.25.2.3.1.6.3", value: {:integer, 8_388_608}},
%{oid: "1.3.6.1.2.1.25.2.3.1.6.31", value: {:integer, 122_070_312}}
]}
_ ->
{:ok, []}
end
end)
assert {:ok, memory_pools} = Base.discover_memory_pools(@client_opts)
# Should only return RAM and Virtual Memory, not Fixed Disk
assert length(memory_pools) == 2
ram = Enum.find(memory_pools, &(&1.pool_index == "1"))
assert ram.pool_name == "Physical memory"
assert ram.pool_type == "ram"
assert ram.total_bytes == 8_388_608 * 1024
assert ram.used_bytes == 4_194_304 * 1024
virtual = Enum.find(memory_pools, &(&1.pool_index == "3"))
assert virtual.pool_name == "Virtual memory"
assert virtual.pool_type == "swap"
assert virtual.total_bytes == 16_777_216 * 4096
assert virtual.used_bytes == 8_388_608 * 4096
end
test "returns empty list when HOST-RESOURCES-MIB not supported" do
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert {:ok, memory_pools} = Base.discover_memory_pools(@client_opts)
assert memory_pools == []
end
test "handles walk errors gracefully" do
stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
assert {:ok, memory_pools} = Base.discover_memory_pools(@client_opts)
assert memory_pools == []
end
end
describe "discover_storage/1" do
test "discovers storage from HOST-RESOURCES-MIB" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# hrStorageType - type references
"1.3.6.1.2.1.25.2.3.1.2" ->
{:ok,
[
# RAM (filtered out for storage)
%{oid: "1.3.6.1.2.1.25.2.3.1.2.1", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 2]}},
# Fixed Disk
%{oid: "1.3.6.1.2.1.25.2.3.1.2.31", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 4]}},
# Removable Disk
%{oid: "1.3.6.1.2.1.25.2.3.1.2.32", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 5]}},
# Network Disk
%{oid: "1.3.6.1.2.1.25.2.3.1.2.33", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 10]}}
]}
# hrStorageDescr
"1.3.6.1.2.1.25.2.3.1.3" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: {:octet_string, "Physical memory"}},
%{oid: "1.3.6.1.2.1.25.2.3.1.3.31", value: {:octet_string, "/"}},
%{oid: "1.3.6.1.2.1.25.2.3.1.3.32", value: {:octet_string, "/mnt/usb"}},
%{oid: "1.3.6.1.2.1.25.2.3.1.3.33", value: {:octet_string, "/mnt/nfs"}}
]}
# hrStorageAllocationUnits
"1.3.6.1.2.1.25.2.3.1.4" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.4.1", value: {:integer, 1024}},
%{oid: "1.3.6.1.2.1.25.2.3.1.4.31", value: {:integer, 4096}},
%{oid: "1.3.6.1.2.1.25.2.3.1.4.32", value: {:integer, 4096}},
%{oid: "1.3.6.1.2.1.25.2.3.1.4.33", value: {:integer, 4096}}
]}
# hrStorageSize
"1.3.6.1.2.1.25.2.3.1.5" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: {:integer, 8_388_608}},
%{oid: "1.3.6.1.2.1.25.2.3.1.5.31", value: {:integer, 244_140_625}},
%{oid: "1.3.6.1.2.1.25.2.3.1.5.32", value: {:integer, 7_812_500}},
%{oid: "1.3.6.1.2.1.25.2.3.1.5.33", value: {:integer, 976_562_500}}
]}
# hrStorageUsed
"1.3.6.1.2.1.25.2.3.1.6" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: {:integer, 4_194_304}},
%{oid: "1.3.6.1.2.1.25.2.3.1.6.31", value: {:integer, 122_070_312}},
%{oid: "1.3.6.1.2.1.25.2.3.1.6.32", value: {:integer, 3_906_250}},
%{oid: "1.3.6.1.2.1.25.2.3.1.6.33", value: {:integer, 488_281_250}}
]}
_ ->
{:ok, []}
end
end)
assert {:ok, storage} = Base.discover_storage(@client_opts)
# Should only return Fixed Disk, Removable Disk, Network Disk (not RAM)
assert length(storage) == 3
root = Enum.find(storage, &(&1.storage_index == 31))
assert root.description == "/"
assert root.storage_type == "fixed_disk"
assert root.allocation_units == 4096
assert root.total_bytes == 244_140_625 * 4096
assert root.used_bytes == 122_070_312 * 4096
usb = Enum.find(storage, &(&1.storage_index == 32))
assert usb.description == "/mnt/usb"
assert usb.storage_type == "removable_disk"
nfs = Enum.find(storage, &(&1.storage_index == 33))
assert nfs.description == "/mnt/nfs"
assert nfs.storage_type == "network_disk"
end
test "returns empty list when HOST-RESOURCES-MIB not supported" do
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert {:ok, storage} = Base.discover_storage(@client_opts)
assert storage == []
end
test "handles walk errors gracefully" do
stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
assert {:ok, storage} = Base.discover_storage(@client_opts)
assert storage == []
end
test "maps all storage type OIDs correctly" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.25.2.3.1.2" ->
{:ok,
[
# hrStorageType values 1-10 (other, ram, virtual_memory, fixed_disk, etc.)
%{oid: "1.3.6.1.2.1.25.2.3.1.2.1", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 1]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.2", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 2]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.3", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 3]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.4", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 4]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.5", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 5]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.6", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 6]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.7", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 7]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.8", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 8]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.9", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 9]}},
%{oid: "1.3.6.1.2.1.25.2.3.1.2.10", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 10]}}
]}
"1.3.6.1.2.1.25.2.3.1.3" ->
{:ok,
Enum.map(1..10, fn i ->
%{oid: "1.3.6.1.2.1.25.2.3.1.3.#{i}", value: {:octet_string, "Storage #{i}"}}
end)}
"1.3.6.1.2.1.25.2.3.1.4" ->
{:ok,
Enum.map(1..10, fn i ->
%{oid: "1.3.6.1.2.1.25.2.3.1.4.#{i}", value: {:integer, 4096}}
end)}
"1.3.6.1.2.1.25.2.3.1.5" ->
{:ok,
Enum.map(1..10, fn i ->
%{oid: "1.3.6.1.2.1.25.2.3.1.5.#{i}", value: {:integer, 1000}}
end)}
"1.3.6.1.2.1.25.2.3.1.6" ->
{:ok,
Enum.map(1..10, fn i ->
%{oid: "1.3.6.1.2.1.25.2.3.1.6.#{i}", value: {:integer, 500}}
end)}
_ ->
{:ok, []}
end
end)
assert {:ok, storage} = Base.discover_storage(@client_opts)
# Only disk-type storage (indices 4-10, not RAM/virtual memory)
assert length(storage) == 7
expected_types = [
{4, "fixed_disk"},
{5, "removable_disk"},
{6, "floppy_disk"},
{7, "compact_disc"},
{8, "ram_disk"},
{9, "flash_memory"},
{10, "network_disk"}
]
for {idx, expected_type} <- expected_types do
entry = Enum.find(storage, &(&1.storage_index == idx))
assert entry.storage_type == expected_type, "Index #{idx} should have type #{expected_type}"
end
end
end
describe "discover_physical_entities/1" do
test "discovers physical entities from ENTITY-MIB" do
stub(SnmpMock, :walk, fn _, oid, _ ->

View file

@ -0,0 +1,161 @@
defmodule Towerops.Snmp.StorageTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Snmp.Device
alias Towerops.Snmp.Storage
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,
storage_index: 1,
storage_type: "fixed_disk"
}
changeset = Storage.changeset(%Storage{}, attrs)
assert changeset.valid?
end
test "valid changeset with all fields", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1",
device_name: "/",
total_bytes: 500_000_000_000,
used_bytes: 250_000_000_000,
allocation_units: 4096,
last_checked_at: DateTime.utc_now(),
metadata: %{"source" => "host-resources"}
}
changeset = Storage.changeset(%Storage{}, attrs)
assert changeset.valid?
assert get_field(changeset, :description) == "/dev/sda1"
assert get_field(changeset, :total_bytes) == 500_000_000_000
assert get_field(changeset, :used_bytes) == 250_000_000_000
end
test "invalid changeset without snmp_device_id" do
attrs = %{
storage_index: 1,
storage_type: "disk"
}
changeset = Storage.changeset(%Storage{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).snmp_device_id
end
test "invalid changeset without storage_index", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
storage_type: "disk"
}
changeset = Storage.changeset(%Storage{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).storage_index
end
test "invalid changeset without storage_type", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
storage_index: 1
}
changeset = Storage.changeset(%Storage{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).storage_type
end
test "invalid storage_type value", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "invalid"
}
changeset = Storage.changeset(%Storage{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).storage_type
end
test "valid storage_type values", %{snmp_device: snmp_device} do
for {storage_type, idx} <-
Enum.with_index(
~w(other ram virtual_memory fixed_disk removable_disk floppy_disk compact_disc ram_disk flash_memory network_disk)
) do
attrs = %{
snmp_device_id: snmp_device.id,
storage_index: idx + 1,
storage_type: storage_type
}
changeset = Storage.changeset(%Storage{}, attrs)
assert changeset.valid?, "storage_type '#{storage_type}' should be valid"
end
end
end
describe "unique constraint" do
test "prevents duplicate storage_index on same device", %{snmp_device: snmp_device} do
attrs = %{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk"
}
# Insert first storage
changeset = Storage.changeset(%Storage{}, attrs)
assert {:ok, _storage} = Repo.insert(changeset)
# Attempt to insert duplicate
changeset2 = Storage.changeset(%Storage{}, attrs)
assert {:error, changeset} = Repo.insert(changeset2)
errors = errors_on(changeset)
assert "has already been taken" in Map.get(errors, :storage_index, []) or
"has already been taken" in Map.get(errors, :snmp_device_id, [])
end
end
end