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 @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