towerops/lib/mix/tasks/import_profiles.ex

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