diff --git a/lib/towerops/equipment.ex b/lib/towerops/equipment.ex index 843751f0..7f6a600b 100644 --- a/lib/towerops/equipment.ex +++ b/lib/towerops/equipment.ex @@ -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. """ diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex index 7a1eb0d2..43286ce8 100644 --- a/lib/towerops/organizations/organization.ex +++ b/lib/towerops/organizations/organization.ex @@ -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) diff --git a/lib/towerops/sites/site.ex b/lib/towerops/sites/site.ex index 3c8a2f7a..063b7d5f 100644 --- a/lib/towerops/sites/site.ex +++ b/lib/towerops/sites/site.ex @@ -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) diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 5dd302dd..6ba82830 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -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)) diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index 265b448b..207e0f9c 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -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 ] diff --git a/priv/repo/migrations/20260117174218_add_snmp_config_to_organizations_and_sites.exs b/priv/repo/migrations/20260117174218_add_snmp_config_to_organizations_and_sites.exs new file mode 100644 index 00000000..4242bdfa --- /dev/null +++ b/priv/repo/migrations/20260117174218_add_snmp_config_to_organizations_and_sites.exs @@ -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