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:
parent
45ddcdac80
commit
1fc066d95b
8 changed files with 952 additions and 0 deletions
69
lib/towerops/snmp/memory_pool.ex
Normal file
69
lib/towerops/snmp/memory_pool.ex
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
77
lib/towerops/snmp/storage.ex
Normal file
77
lib/towerops/snmp/storage.ex
Normal 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
|
||||
|
|
@ -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
|
||||
29
priv/repo/migrations/20260121164729_create_snmp_storage.exs
Normal file
29
priv/repo/migrations/20260121164729_create_snmp_storage.exs
Normal 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
|
||||
156
test/towerops/snmp/memory_pool_test.exs
Normal file
156
test/towerops/snmp/memory_pool_test.exs
Normal 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
|
||||
|
|
@ -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, _ ->
|
||||
|
|
|
|||
161
test/towerops/snmp/storage_test.exs
Normal file
161
test/towerops/snmp/storage_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue