355 lines
9.9 KiB
Elixir
355 lines
9.9 KiB
Elixir
defmodule Mix.Tasks.ImportProfiles do
|
|
@shortdoc "Import device profiles from LibreNMS YAML definitions"
|
|
|
|
@moduledoc """
|
|
Imports device profiles from LibreNMS YAML definition files.
|
|
|
|
## Usage
|
|
|
|
# Import all profile definitions
|
|
mix import_profiles --source-path ~/librenms/includes/definitions
|
|
|
|
# Import specific profiles
|
|
mix import_profiles --source-path ~/librenms/includes/definitions --profiles mikrotik,cisco
|
|
|
|
## Options
|
|
|
|
* `--source-path` - Path to LibreNMS includes/definitions directory (required)
|
|
* `--profiles` - Comma-separated list of profiles to import (default: all)
|
|
|
|
## Notes
|
|
|
|
* Reads YAML files from the definitions directory
|
|
* Creates database profiles with MIB symbolic names
|
|
* Updates existing profiles if they already exist
|
|
* Requires the application to be started to access the database
|
|
"""
|
|
|
|
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] || raise "Missing --source-path argument"
|
|
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 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
|
|
Logger.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()
|
|
|
|
Logger.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
|
|
Logger.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 ->
|
|
Logger.info("Importing profile: #{profile_name}")
|
|
|
|
case import_profile(source_path, profile_name) do
|
|
{:ok, _profile} ->
|
|
Logger.info("✓ #{profile_name}: created/updated successfully")
|
|
{:ok, profile_name}
|
|
|
|
{:error, reason} ->
|
|
Logger.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))
|
|
|
|
Logger.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),
|
|
{:ok, discovery_data} <- read_yaml_file(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(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, fn item ->
|
|
case item do
|
|
%{"sysDescr" => patterns} when is_list(patterns) -> patterns
|
|
_ -> []
|
|
end
|
|
end)
|
|
|
|
case sys_descr_patterns do
|
|
[first | _] -> first
|
|
_ -> nil
|
|
end
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
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, fn item ->
|
|
case item do
|
|
%{"sysObjectID" => patterns} when is_list(patterns) -> patterns
|
|
_ -> []
|
|
end
|
|
end)
|
|
|
|
case oid_patterns do
|
|
[first | _] -> first
|
|
_ -> nil
|
|
end
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
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
|
|
device_oids = []
|
|
|
|
# Get the modules.os section
|
|
os_module = get_in(yaml_data, ["modules", "os"]) || %{}
|
|
|
|
# Serial number
|
|
device_oids =
|
|
case Map.get(os_module, "serial") do
|
|
nil -> device_oids
|
|
mib_name -> [{"serial_number", mib_name} | device_oids]
|
|
end
|
|
|
|
# Firmware version
|
|
device_oids =
|
|
case Map.get(os_module, "version") do
|
|
nil -> device_oids
|
|
mib_name -> [{"firmware_version", mib_name} | device_oids]
|
|
end
|
|
|
|
# Hardware/model
|
|
device_oids =
|
|
case Map.get(os_module, "hardware") do
|
|
nil -> device_oids
|
|
mib_name -> [{"hardware", mib_name} | device_oids]
|
|
end
|
|
|
|
device_oids
|
|
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
|
|
# Get the MIB name - prefer 'oid' field, fall back to 'value' or 'num_oid'
|
|
mib_name = Map.get(sensor_def, "oid") || Map.get(sensor_def, "value")
|
|
num_oid = Map.get(sensor_def, "num_oid")
|
|
|
|
# Skip table-based sensors (those with {{ $index }} templates)
|
|
if is_binary(num_oid) and String.contains?(num_oid, "{{ $index }}") do
|
|
nil
|
|
else
|
|
# Only import scalar sensors (ending with .0)
|
|
if is_binary(mib_name) and String.ends_with?(mib_name, ".0") do
|
|
# Extract description and clean up templates
|
|
descr =
|
|
case Map.get(sensor_def, "descr") do
|
|
nil ->
|
|
sensor_type
|
|
|
|
descr_template when is_binary(descr_template) ->
|
|
# Remove template syntax like {{ MIB::name }}
|
|
descr_template
|
|
|> String.replace(~r/\{\{ [^}]+ \}\}/, "")
|
|
|> String.trim()
|
|
|> case do
|
|
"" -> sensor_type
|
|
cleaned -> cleaned
|
|
end
|
|
|
|
_ ->
|
|
sensor_type
|
|
end
|
|
|
|
%{
|
|
sensor_type: sensor_type,
|
|
mib_name: mib_name,
|
|
sensor_descr: descr,
|
|
sensor_unit: Map.get(sensor_def, "unit"),
|
|
sensor_divisor: Map.get(sensor_def, "divisor", 1)
|
|
}
|
|
|
|
# Skip non-scalar sensors for now
|
|
end
|
|
end
|
|
end
|
|
end
|