towerops/lib/towerops/devices/device.ex

240 lines
7.6 KiB
Elixir

defmodule Towerops.Devices.Device do
@moduledoc """
Device schema for network devices being monitored.
Devices belong to a site and organization, and can have:
- SNMP configuration (community strings, polling settings)
- Associated SNMP device record (interfaces, sensors)
- Agent assignments for remote polling
- Monitoring checks and alerts
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentAssignment
alias Towerops.Sites.Site
alias Towerops.Snmp.Device, as: SnmpDevice
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "devices" do
field :name, :string
field :ip_address, :string
field :description, :string
field :display_order, :integer
field :status, Ecto.Enum, values: [:up, :down, :unknown], default: :unknown
field :last_checked_at, :utc_datetime
field :last_status_change_at, :utc_datetime
field :monitoring_enabled, :boolean, default: true
field :check_interval_seconds, :integer, default: 300
# SNMP fields
field :snmp_enabled, :boolean, default: true
field :snmp_version, :string, default: "2c"
field :snmp_community, :string
field :snmp_community_source, :string, default: "device"
field :snmp_port, :integer, default: 161
field :last_discovery_at, :utc_datetime
field :last_snmp_poll_at, :utc_datetime
# MikroTik API credentials (device-level overrides)
field :mikrotik_username, :string
field :mikrotik_password, Towerops.Encrypted.Binary
field :mikrotik_port, :integer
field :mikrotik_use_ssl, :boolean
field :mikrotik_enabled, :boolean
field :mikrotik_credential_source, :string, default: "site"
belongs_to :site, Site
has_one :snmp_device, SnmpDevice
has_many :agent_assignments, AgentAssignment
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
name: String.t() | nil,
ip_address: String.t(),
description: String.t() | nil,
display_order: integer() | nil,
status: :up | :down | :unknown,
last_checked_at: DateTime.t() | nil,
last_status_change_at: DateTime.t() | nil,
monitoring_enabled: boolean(),
check_interval_seconds: integer(),
snmp_enabled: boolean(),
snmp_version: String.t(),
snmp_community: String.t() | nil,
snmp_port: integer(),
last_discovery_at: DateTime.t() | nil,
last_snmp_poll_at: DateTime.t() | nil,
mikrotik_username: String.t() | nil,
mikrotik_password: String.t() | nil,
mikrotik_port: integer() | nil,
mikrotik_use_ssl: boolean() | nil,
mikrotik_enabled: boolean() | nil,
mikrotik_credential_source: String.t() | nil,
site_id: Ecto.UUID.t(),
site: NotLoaded.t() | Site.t(),
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
agent_assignments: NotLoaded.t() | [AgentAssignment.t()],
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(device, attrs) do
device
|> cast(attrs, [
:name,
:ip_address,
:description,
:display_order,
:site_id,
:monitoring_enabled,
:check_interval_seconds,
:snmp_enabled,
:snmp_version,
:snmp_community,
:snmp_community_source,
:snmp_port,
:status,
:last_checked_at,
:last_status_change_at,
:last_discovery_at,
:last_snmp_poll_at,
:mikrotik_username,
:mikrotik_password,
:mikrotik_port,
:mikrotik_use_ssl,
:mikrotik_enabled,
:mikrotik_credential_source
])
|> validate_required([:ip_address, :site_id])
|> validate_name()
|> validate_length(:name, min: 2, max: 200)
|> validate_length(:description, max: 1000)
|> validate_ip_address()
|> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600)
|> validate_snmp()
|> validate_mikrotik()
|> update_community_source()
|> update_mikrotik_credential_source()
|> foreign_key_constraint(:site_id)
end
defp validate_name(changeset) do
snmp_enabled = get_field(changeset, :snmp_enabled, false)
name = get_field(changeset, :name)
# Name is required only when SNMP is not enabled (ICMP only mode)
# When SNMP is enabled, name can be empty and will be populated from sysName during discovery
if !snmp_enabled and (is_nil(name) or name == "") do
add_error(changeset, :name, "can't be blank (required when SNMP is disabled)")
else
changeset
end
end
defp validate_ip_address(changeset) do
case get_change(changeset, :ip_address) do
nil ->
changeset
ip_string ->
case parse_ip(ip_string) do
{:ok, _} ->
changeset
{:error, _} ->
add_error(changeset, :ip_address, "must be a valid IPv4 or IPv6 address")
end
end
end
defp parse_ip(ip_string) do
ip_string
|> String.to_charlist()
|> :inet.parse_address()
end
defp validate_snmp(changeset) do
snmp_enabled = get_field(changeset, :snmp_enabled)
if snmp_enabled do
changeset
|> validate_required([:snmp_version], message: "required when SNMP is enabled")
|> validate_inclusion(:snmp_version, ["1", "2c"], message: "must be either '1' or '2c'")
|> validate_number(:snmp_port, greater_than: 0, less_than: 65_536)
else
changeset
end
end
defp update_community_source(changeset) do
# If user explicitly set a community string, mark source as "device"
# If it's blank/nil, mark source as "site" (will inherit from site or org)
case get_change(changeset, :snmp_community) do
nil ->
# No change to community, keep existing source
changeset
"" ->
# Explicitly cleared, set to inherit from site
put_change(changeset, :snmp_community_source, "site")
_value ->
# Explicitly set a value, mark as device-specific
put_change(changeset, :snmp_community_source, "device")
end
end
defp validate_mikrotik(changeset) do
mikrotik_enabled = get_field(changeset, :mikrotik_enabled)
if mikrotik_enabled do
changeset
|> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536)
|> validate_mikrotik_ssl_for_cloud()
else
changeset
end
end
defp validate_mikrotik_ssl_for_cloud(changeset) do
# If explicitly setting use_ssl to false at device level, ensure not using cloud poller
use_ssl = get_field(changeset, :mikrotik_use_ssl)
# Only validate if explicitly disabling SSL (nil means inherit)
if use_ssl == false do
# Note: We can't check effective agent here during changeset validation
# as it requires database queries. This validation happens at the context level.
# Here we just ensure the field is set if provided.
changeset
else
changeset
end
end
defp update_mikrotik_credential_source(changeset) do
# If user explicitly set a username, mark source as "device"
# If it's blank/nil, mark source as "site" (will inherit from site or org)
case get_change(changeset, :mikrotik_username) do
nil ->
# No change to username, keep existing source
changeset
"" ->
# Explicitly cleared, set to inherit from site
put_change(changeset, :mikrotik_credential_source, "site")
_value ->
# Explicitly set a value, mark as device-specific
put_change(changeset, :mikrotik_credential_source, "device")
end
end
end