Add hierarchical SNMP configuration with org/site/equipment fallback

Implements a hierarchical SNMP community configuration system where settings
can be defined at the organization level (global default), overridden at the
site level, or overridden at the equipment level.

Changes:
- Migration: Add snmp_version and snmp_community to organizations and sites
- Organization schema: Add SNMP fields with default version "2c"
- Site schema: Add SNMP fields that override organization defaults
- Equipment context: Add get_snmp_config/1 with hierarchical fallback logic
- Discovery: Use Equipment.get_snmp_config instead of equipment.snmp_community
- PollerWorker: Use Equipment.get_snmp_config instead of equipment.snmp_community

The fallback order is:
1. Equipment-level (highest priority)
2. Site-level
3. Organization-level (default)

All 785 tests passing.
This commit is contained in:
Graham McIntire 2026-01-17 11:47:17 -06:00
parent de002f9903
commit ba1e8933e4
No known key found for this signature in database
6 changed files with 132 additions and 15 deletions

View file

@ -137,6 +137,76 @@ defmodule Towerops.Equipment do
Repo.one!(from(e in EquipmentSchema, where: e.id == ^equipment_id, where: e.site_id == ^site_id, preload: [:site]))
end
@doc """
Gets SNMP configuration for equipment with hierarchical fallback.
Falls back in this order:
1. Equipment-level configuration
2. Site-level configuration
3. Organization-level configuration
Returns a map with:
- version: SNMP version ("1", "2c", or "3")
- community: SNMP community string
- source: Where the config came from (:equipment, :site, or :organization)
"""
def get_snmp_config(equipment_id) when is_binary(equipment_id) do
equipment =
EquipmentSchema
|> Repo.get!(equipment_id)
|> Repo.preload(site: :organization)
get_snmp_config(equipment)
end
def get_snmp_config(%EquipmentSchema{} = equipment) do
# Ensure associations are loaded
equipment =
if Ecto.assoc_loaded?(equipment.site) do
if Ecto.assoc_loaded?(equipment.site.organization) do
equipment
else
Repo.preload(equipment, site: :organization)
end
else
Repo.preload(equipment, site: :organization)
end
cond do
# Equipment-level override
equipment.snmp_community != nil ->
%{
version: equipment.snmp_version || "2c",
community: equipment.snmp_community,
source: :equipment
}
# Site-level override
equipment.site.snmp_community != nil ->
%{
version: equipment.site.snmp_version || "2c",
community: equipment.site.snmp_community,
source: :site
}
# Organization-level default
equipment.site.organization.snmp_community != nil ->
%{
version: equipment.site.organization.snmp_version || "2c",
community: equipment.site.organization.snmp_community,
source: :organization
}
# No SNMP config set at any level
true ->
%{
version: "2c",
community: nil,
source: :default
}
end
end
@doc """
Creates equipment.
"""

View file

@ -15,6 +15,10 @@ defmodule Towerops.Organizations.Organization do
field :name, :string
field :slug, :string
# SNMP configuration (global default for all sites/equipment)
field :snmp_version, :string, default: "2c"
field :snmp_community, :string
belongs_to :default_agent_token, AgentToken
has_many :memberships, Membership
@ -28,6 +32,8 @@ defmodule Towerops.Organizations.Organization do
id: Ecto.UUID.t(),
name: String.t(),
slug: String.t(),
snmp_version: String.t() | nil,
snmp_community: String.t() | nil,
default_agent_token_id: Ecto.UUID.t() | nil,
default_agent_token: NotLoaded.t() | AgentToken.t() | nil,
memberships: NotLoaded.t() | [Membership.t()],
@ -40,9 +46,10 @@ defmodule Towerops.Organizations.Organization do
@doc false
def changeset(organization, attrs) do
organization
|> cast(attrs, [:name, :default_agent_token_id])
|> cast(attrs, [:name, :default_agent_token_id, :snmp_version, :snmp_community])
|> validate_required([:name])
|> validate_length(:name, min: 2, max: 100)
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|> generate_slug()
|> validate_required([:slug])
|> unique_constraint(:slug)

View file

@ -17,6 +17,10 @@ defmodule Towerops.Sites.Site do
field :description, :string
field :location, :string
# SNMP configuration (overrides organization default)
field :snmp_version, :string
field :snmp_community, :string
belongs_to :organization, Organization
belongs_to :agent_token, AgentToken
belongs_to :parent_site, Site
@ -31,6 +35,8 @@ defmodule Towerops.Sites.Site do
name: String.t(),
description: String.t() | nil,
location: String.t() | nil,
snmp_version: String.t() | nil,
snmp_community: String.t() | nil,
organization_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(),
agent_token_id: Ecto.UUID.t() | nil,
@ -46,11 +52,21 @@ defmodule Towerops.Sites.Site do
@doc false
def changeset(site, attrs) do
site
|> cast(attrs, [:name, :description, :location, :organization_id, :agent_token_id, :parent_site_id])
|> cast(attrs, [
:name,
:description,
:location,
:organization_id,
:agent_token_id,
:parent_site_id,
:snmp_version,
:snmp_community
])
|> validate_required([:name, :organization_id])
|> validate_length(:name, min: 2, max: 200)
|> validate_length(:description, max: 1000)
|> validate_length(:location, max: 200)
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:agent_token_id)
|> foreign_key_constraint(:parent_site_id)

View file

@ -14,7 +14,8 @@ defmodule Towerops.Snmp.Discovery do
import Ecto.Query
alias Towerops.Equipment.Equipment
alias Towerops.Equipment
alias Towerops.Equipment.Equipment, as: EquipmentSchema
alias Towerops.Repo
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
@ -93,8 +94,8 @@ defmodule Towerops.Snmp.Discovery do
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
@spec discover_equipment(EquipmentSchema.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_equipment(%EquipmentSchema{} = equipment) do
if equipment.snmp_enabled do
Logger.info("Starting SNMP discovery for equipment: #{equipment.name} (#{equipment.ip_address})")
@ -138,7 +139,7 @@ defmodule Towerops.Snmp.Discovery do
@spec discover_all(String.t()) :: {:ok, discovery_summary()}
def discover_all(org_id) do
equipment_list =
Equipment
EquipmentSchema
|> join(:inner, [e], s in assoc(e, :site))
|> where([e, s], s.organization_id == ^org_id and e.snmp_enabled == true)
|> Repo.all()
@ -170,12 +171,15 @@ defmodule Towerops.Snmp.Discovery do
# Private functions
@spec build_client_opts(Equipment.t()) :: Client.connection_opts()
@spec build_client_opts(EquipmentSchema.t()) :: Client.connection_opts()
defp build_client_opts(equipment) do
# Get SNMP config with hierarchical fallback (equipment -> site -> organization)
snmp_config = Equipment.get_snmp_config(equipment)
[
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
community: snmp_config.community,
version: snmp_config.version,
port: equipment.snmp_port || 161,
timeout: 5000
]
@ -268,7 +272,7 @@ defmodule Towerops.Snmp.Discovery do
end
@spec save_discovery_results(
Equipment.t(),
EquipmentSchema.t(),
device_info(),
[interface_data()],
[sensor_data()]
@ -288,7 +292,7 @@ defmodule Towerops.Snmp.Discovery do
end)
end
@spec upsert_device(Equipment.t(), device_info()) :: Device.t()
@spec upsert_device(EquipmentSchema.t(), device_info()) :: Device.t()
defp upsert_device(equipment, device_info) do
case Repo.get_by(Device, equipment_id: equipment.id) do
nil ->
@ -328,8 +332,8 @@ defmodule Towerops.Snmp.Discovery do
end)
end
@spec update_equipment_discovery_time(Equipment.t()) ::
{:ok, Equipment.t()} | {:error, Ecto.Changeset.t()}
@spec update_equipment_discovery_time(EquipmentSchema.t()) ::
{:ok, EquipmentSchema.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))

View file

@ -636,10 +636,13 @@ defmodule Towerops.Snmp.PollerWorker do
end
defp build_client_opts(equipment) do
# Get SNMP config with hierarchical fallback (equipment -> site -> organization)
snmp_config = Equipment.get_snmp_config(equipment)
[
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
community: snmp_config.community,
version: snmp_config.version,
port: equipment.snmp_port || 161,
timeout: 5000
]

View file

@ -0,0 +1,17 @@
defmodule Towerops.Repo.Migrations.AddSnmpConfigToOrganizationsAndSites do
use Ecto.Migration
def change do
# Add SNMP configuration to organizations (global default)
alter table(:organizations) do
add :snmp_version, :string, default: "2c"
add :snmp_community, :string
end
# Add SNMP configuration to sites (override org default)
alter table(:sites) do
add :snmp_version, :string
add :snmp_community, :string
end
end
end