367 lines
10 KiB
Elixir
367 lines
10 KiB
Elixir
defmodule Mix.Tasks.ImportProfiles do
|
|
@shortdoc "Import device profiles from LibreNMS YAML definitions"
|
|
|
|
@moduledoc """
|
|
Imports device profiles from YAML definition files.
|
|
|
|
## Usage
|
|
|
|
# Import all profiles from priv/profiles (default)
|
|
mix import_profiles
|
|
|
|
# Import from a custom path
|
|
mix import_profiles --source-path ~/librenms/resources/definitions
|
|
|
|
# Import specific profiles
|
|
mix import_profiles --profiles mikrotik,cisco,epmp
|
|
|
|
## Options
|
|
|
|
* `--source-path` - Path to definitions directory (default: priv/profiles)
|
|
* `--profiles` - Comma-separated list of profiles to import (default: all)
|
|
|
|
## Notes
|
|
|
|
* Reads YAML files from os_detection/ and os_discovery/ subdirectories
|
|
* Creates database profiles with MIB symbolic names
|
|
* Updates existing profiles if they already exist
|
|
* Run ./scripts/sync-librenms.sh to sync profiles from LibreNMS
|
|
"""
|
|
|
|
use Mix.Task
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Profiles
|
|
alias Towerops.Profiles.DeviceOid
|
|
alias Towerops.Profiles.SensorOid
|
|
alias Towerops.Repo
|
|
|
|
require Logger
|
|
|
|
@requirements ["app.start"]
|
|
|
|
def run(args) do
|
|
{opts, _} =
|
|
OptionParser.parse!(args,
|
|
strict: [
|
|
source_path: :string,
|
|
profiles: :string
|
|
]
|
|
)
|
|
|
|
source_path = opts[:source_path] || default_source_path()
|
|
profiles_filter = parse_profiles(opts[:profiles])
|
|
|
|
if !File.exists?(source_path) do
|
|
raise "Source directory not found: #{source_path}"
|
|
end
|
|
|
|
case profiles_filter do
|
|
:all ->
|
|
import_all_profiles(source_path)
|
|
|
|
profile_list ->
|
|
import_specific_profiles(source_path, profile_list)
|
|
end
|
|
end
|
|
|
|
defp default_source_path do
|
|
Path.join(:code.priv_dir(:towerops), "profiles")
|
|
end
|
|
|
|
defp parse_profiles(nil), do: :all
|
|
defp parse_profiles(""), do: :all
|
|
|
|
defp parse_profiles(profiles_string) do
|
|
profiles_string
|
|
|> String.split(",")
|
|
|> Enum.map(&String.trim/1)
|
|
|> Enum.reject(&(&1 == ""))
|
|
end
|
|
|
|
defp import_all_profiles(source_path) do
|
|
Mix.shell().info("Discovering YAML files in #{source_path}...")
|
|
|
|
detection_path = Path.join(source_path, "os_detection")
|
|
|
|
yaml_files =
|
|
detection_path
|
|
|> Path.join("*.yaml")
|
|
|> Path.wildcard()
|
|
|> Enum.sort()
|
|
|
|
Mix.shell().info("Found #{length(yaml_files)} YAML files")
|
|
|
|
profile_names = Enum.map(yaml_files, &Path.basename(&1, ".yaml"))
|
|
import_profiles_by_name(source_path, profile_names)
|
|
end
|
|
|
|
defp import_specific_profiles(source_path, profiles) do
|
|
Mix.shell().info("Importing #{length(profiles)} specified profiles")
|
|
import_profiles_by_name(source_path, profiles)
|
|
end
|
|
|
|
defp import_profiles_by_name(source_path, profile_names) do
|
|
results =
|
|
Enum.map(profile_names, fn profile_name ->
|
|
Mix.shell().info("Importing profile: #{profile_name}")
|
|
|
|
case import_profile(source_path, profile_name) do
|
|
{:ok, _profile} ->
|
|
Mix.shell().info("✓ #{profile_name}: created/updated successfully")
|
|
{:ok, profile_name}
|
|
|
|
{:error, reason} ->
|
|
Mix.shell().error("✗ #{profile_name}: #{inspect(reason)}")
|
|
{:error, profile_name, reason}
|
|
end
|
|
end)
|
|
|
|
successes = Enum.count(results, &match?({:ok, _}, &1))
|
|
failures = Enum.count(results, &match?({:error, _, _}, &1))
|
|
|
|
Mix.shell().info("Import complete: #{successes} succeeded, #{failures} failed")
|
|
end
|
|
|
|
defp import_profile(source_path, profile_name) do
|
|
detection_file = Path.join([source_path, "os_detection", "#{profile_name}.yaml"])
|
|
discovery_file = Path.join([source_path, "os_discovery", "#{profile_name}.yaml"])
|
|
|
|
with {:ok, detection_data} <- read_yaml_file(detection_file),
|
|
# Discovery file is optional - use empty map if not found
|
|
discovery_data = read_yaml_file_optional(discovery_file),
|
|
{:ok, _profile} <- create_or_update_profile(detection_data, discovery_data, profile_name),
|
|
# Reload profile to get correct binary ID
|
|
profile when not is_nil(profile) <- Profiles.get_profile(profile_name),
|
|
:ok <- import_device_oids(profile, discovery_data),
|
|
:ok <- import_sensor_oids(profile, discovery_data) do
|
|
{:ok, profile}
|
|
end
|
|
end
|
|
|
|
defp read_yaml_file_optional(file_path) do
|
|
case read_yaml_file(file_path) do
|
|
{:ok, data} -> data
|
|
{:error, _} -> %{}
|
|
end
|
|
end
|
|
|
|
defp read_yaml_file(file_path) do
|
|
with {:ok, yaml_content} <- File.read(file_path),
|
|
{:ok, yaml_data} <- YamlElixir.read_from_string(yaml_content) do
|
|
# YamlElixir can return either a map or a list with one map
|
|
yaml_data =
|
|
case yaml_data do
|
|
[data] -> data
|
|
data when is_map(data) -> data
|
|
_ -> yaml_data
|
|
end
|
|
|
|
{:ok, yaml_data}
|
|
else
|
|
{:error, :enoent} ->
|
|
{:error, "File not found: #{file_path}"}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
defp create_or_update_profile(detection_data, _discovery_data, profile_name) do
|
|
# Extract detection patterns from os_detection file
|
|
detection_pattern = extract_detection_pattern(detection_data)
|
|
detection_oid = extract_detection_oid(detection_data)
|
|
vendor = Map.get(detection_data, "text") || String.capitalize(profile_name)
|
|
|
|
attrs = %{
|
|
name: profile_name,
|
|
vendor: vendor,
|
|
detection_pattern: detection_pattern,
|
|
detection_oid: detection_oid,
|
|
priority: Map.get(detection_data, "priority", 100),
|
|
enabled: true
|
|
}
|
|
|
|
# Check if profile exists
|
|
case Profiles.get_profile(profile_name) do
|
|
nil ->
|
|
Profiles.create_profile(attrs)
|
|
|
|
existing_profile ->
|
|
Profiles.update_profile(existing_profile, attrs)
|
|
end
|
|
end
|
|
|
|
defp extract_detection_pattern(yaml_data) do
|
|
# Look for sysDescr patterns in discovery section
|
|
case get_in(yaml_data, ["discovery"]) do
|
|
discovery when is_list(discovery) ->
|
|
sys_descr_patterns = Enum.flat_map(discovery, &extract_sys_descr_patterns/1)
|
|
|
|
List.first(sys_descr_patterns)
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp extract_sys_descr_patterns(%{"sysDescr" => patterns}) when is_list(patterns), do: patterns
|
|
defp extract_sys_descr_patterns(_), do: []
|
|
|
|
defp extract_detection_oid(yaml_data) do
|
|
# Look for sysObjectID patterns in discovery section
|
|
case get_in(yaml_data, ["discovery"]) do
|
|
discovery when is_list(discovery) ->
|
|
oid_patterns = Enum.flat_map(discovery, &extract_sys_object_id_patterns/1)
|
|
|
|
List.first(oid_patterns)
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp extract_sys_object_id_patterns(%{"sysObjectID" => patterns}) when is_list(patterns), do: patterns
|
|
|
|
defp extract_sys_object_id_patterns(_), do: []
|
|
|
|
defp import_device_oids(profile, yaml_data) do
|
|
# Look for device info in various places
|
|
device_oids = extract_device_oids(yaml_data)
|
|
|
|
# Delete existing device OIDs
|
|
Repo.delete_all(
|
|
from o in DeviceOid,
|
|
where: o.profile_id == ^profile.id
|
|
)
|
|
|
|
# Insert new device OIDs
|
|
Enum.each(device_oids, fn {field, mib_name} ->
|
|
Profiles.add_device_oid(profile.id, field, mib_name)
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp extract_device_oids(yaml_data) do
|
|
# Extract from modules.os section in os_discovery YAML
|
|
# Get the modules.os section
|
|
os_module = get_in(yaml_data, ["modules", "os"]) || %{}
|
|
|
|
# Map of YAML field names to our field names
|
|
field_mappings = [
|
|
{"serial", "serial_number"},
|
|
{"version", "firmware_version"},
|
|
{"hardware", "hardware"},
|
|
{"lat", "latitude"},
|
|
{"long", "longitude"},
|
|
{"features", "features"},
|
|
{"sysName", "sys_name_oid"}
|
|
]
|
|
|
|
Enum.flat_map(field_mappings, fn {yaml_field, db_field} ->
|
|
case Map.get(os_module, yaml_field) do
|
|
nil -> []
|
|
# Handle list of OIDs (fallback order)
|
|
mib_names when is_list(mib_names) -> [{db_field, List.first(mib_names)}]
|
|
mib_name when is_binary(mib_name) -> [{db_field, mib_name}]
|
|
_ -> []
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp import_sensor_oids(profile, yaml_data) do
|
|
# Look for sensor definitions
|
|
sensors = extract_sensor_oids(yaml_data)
|
|
|
|
# Delete existing sensor OIDs
|
|
Repo.delete_all(
|
|
from o in SensorOid,
|
|
where: o.profile_id == ^profile.id
|
|
)
|
|
|
|
# Insert new sensor OIDs
|
|
Enum.each(sensors, fn sensor_attrs ->
|
|
Profiles.add_sensor_oid(profile.id, sensor_attrs)
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp extract_sensor_oids(yaml_data) do
|
|
# Extract from modules.sensors section in os_discovery YAML
|
|
sensors_module = get_in(yaml_data, ["modules", "sensors"]) || %{}
|
|
|
|
# Sensor types to extract
|
|
sensor_types = ["temperature", "voltage", "current", "power", "fanspeed"]
|
|
|
|
Enum.flat_map(sensor_types, fn sensor_type ->
|
|
case get_in(sensors_module, [sensor_type, "data"]) do
|
|
sensor_data when is_list(sensor_data) ->
|
|
sensor_data
|
|
|> Enum.map(&parse_sensor(&1, sensor_type))
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
_ ->
|
|
[]
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp parse_sensor(sensor_def, sensor_type) do
|
|
mib_name = Map.get(sensor_def, "oid") || Map.get(sensor_def, "value")
|
|
num_oid = Map.get(sensor_def, "num_oid")
|
|
|
|
cond do
|
|
table_sensor?(num_oid) -> nil
|
|
scalar_sensor?(mib_name) -> build_sensor_map(sensor_def, sensor_type, mib_name)
|
|
true -> nil
|
|
end
|
|
end
|
|
|
|
defp table_sensor?(num_oid) when is_binary(num_oid) do
|
|
String.contains?(num_oid, "{{ $index }}")
|
|
end
|
|
|
|
defp table_sensor?(_), do: false
|
|
|
|
defp scalar_sensor?(mib_name) when is_binary(mib_name) do
|
|
String.ends_with?(mib_name, ".0")
|
|
end
|
|
|
|
defp scalar_sensor?(_), do: false
|
|
|
|
defp build_sensor_map(sensor_def, sensor_type, mib_name) do
|
|
%{
|
|
sensor_type: sensor_type,
|
|
mib_name: mib_name,
|
|
sensor_descr: extract_sensor_description(sensor_def, sensor_type),
|
|
sensor_unit: Map.get(sensor_def, "unit"),
|
|
sensor_divisor: Map.get(sensor_def, "divisor", 1)
|
|
}
|
|
end
|
|
|
|
defp extract_sensor_description(sensor_def, default_type) do
|
|
case Map.get(sensor_def, "descr") do
|
|
nil ->
|
|
default_type
|
|
|
|
descr when is_binary(descr) ->
|
|
clean_description_template(descr, default_type)
|
|
|
|
_ ->
|
|
default_type
|
|
end
|
|
end
|
|
|
|
defp clean_description_template(descr, default_type) do
|
|
descr
|
|
|> String.replace(~r/\{\{ [^}]+ \}\}/, "")
|
|
|> String.trim()
|
|
|> case do
|
|
"" -> default_type
|
|
cleaned -> cleaned
|
|
end
|
|
end
|
|
end
|