diff --git a/lib/mix/tasks/import_profiles.ex b/lib/mix/tasks/import_profiles.ex new file mode 100644 index 00000000..d4b2c909 --- /dev/null +++ b/lib/mix/tasks/import_profiles.ex @@ -0,0 +1,234 @@ +defmodule Mix.Tasks.ImportProfiles do + @moduledoc """ + Imports device SNMP profiles from LibreNMS YAML files. + + ## Usage + + # Import a single profile + mix import_profiles --profile epmp --detection ~/dev/librenms/resources/definitions/os_detection/epmp.yaml --discovery ~/dev/librenms/resources/definitions/os_discovery/epmp.yaml + + # Import specific profiles + mix import_profiles --profiles epmp,airos-af,unifi + + # Import all profiles from LibreNMS directory + mix import_profiles --all --librenms-path ~/dev/librenms + + ## Options + + --profile NAME - Import a single profile by name + --profiles LIST - Import multiple profiles (comma-separated) + --all - Import all profiles from LibreNMS + --librenms-path PATH - Path to LibreNMS installation + --detection PATH - Path to detection YAML file + --discovery PATH - Path to discovery YAML file (optional) + --force - Delete existing profile before importing + + """ + use Mix.Task + + alias Towerops.DeviceProfiles + alias Towerops.DeviceProfiles.Importer + alias Towerops.Repo + + @requirements ["app.start"] + + @impl Mix.Task + def run(args) do + {opts, _, _} = + OptionParser.parse(args, + strict: [ + profile: :string, + profiles: :string, + all: :boolean, + librenms_path: :string, + detection: :string, + discovery: :string, + force: :boolean + ] + ) + + cond do + opts[:all] -> + import_all_profiles(opts) + + opts[:profiles] -> + import_multiple_profiles(opts) + + opts[:profile] || opts[:detection] -> + import_single_profile(opts) + + true -> + print_usage() + end + end + + defp import_single_profile(opts) do + detection_file = opts[:detection] + discovery_file = opts[:discovery] + force = opts[:force] || false + + if detection_file && File.exists?(detection_file) do + os_name = Path.basename(detection_file, ".yaml") + + if force do + delete_existing_profile(os_name) + end + + Mix.shell().info("Importing profile: #{os_name}") + + case Importer.import_profile(detection_file, discovery_file) do + {:ok, profile} -> + Mix.shell().info("Successfully imported profile: #{profile.os}") + print_profile_stats(profile) + + {:error, reason} -> + Mix.shell().error("Failed to import profile: #{inspect(reason)}") + end + else + Mix.shell().error("Detection file not found: #{detection_file}") + end + end + + defp import_multiple_profiles(opts) do + profiles = String.split(opts[:profiles], ",", trim: true) + librenms_path = opts[:librenms_path] || guess_librenms_path() + force = opts[:force] || false + + if librenms_path do + detection_dir = Path.join(librenms_path, "resources/definitions/os_detection") + discovery_dir = Path.join(librenms_path, "resources/definitions/os_discovery") + + Enum.each(profiles, fn profile_name -> + import_profile_by_name(profile_name, detection_dir, discovery_dir, force) + end) + else + Mix.shell().error("LibreNMS path not found. Use --librenms-path option.") + end + end + + defp import_all_profiles(opts) do + librenms_path = opts[:librenms_path] || guess_librenms_path() + force = opts[:force] || false + + if librenms_path do + detection_dir = Path.join(librenms_path, "resources/definitions/os_detection") + discovery_dir = Path.join(librenms_path, "resources/definitions/os_discovery") + + if force do + Mix.shell().info("Deleting all existing profiles...") + delete_all_profiles() + end + + Mix.shell().info("Importing all profiles from: #{librenms_path}") + Mix.shell().info("This may take several minutes...") + + case Importer.import_all_from_directory(detection_dir, discovery_dir) do + {:ok, stats} -> + Mix.shell().info("\nImport complete!") + Mix.shell().info("Successfully imported: #{stats.success} profiles") + Mix.shell().info("Failed to import: #{stats.failed} profiles") + + {:error, reason} -> + Mix.shell().error("Failed to import profiles: #{inspect(reason)}") + end + else + Mix.shell().error("LibreNMS path not found. Use --librenms-path option.") + end + end + + defp import_profile_by_name(profile_name, detection_dir, discovery_dir, force) do + detection_file = Path.join(detection_dir, "#{profile_name}.yaml") + discovery_file = Path.join(discovery_dir, "#{profile_name}.yaml") + + discovery_file = if File.exists?(discovery_file), do: discovery_file + + if File.exists?(detection_file) do + if force do + delete_existing_profile(profile_name) + end + + Mix.shell().info("Importing profile: #{profile_name}") + + case Importer.import_profile(detection_file, discovery_file) do + {:ok, profile} -> + Mix.shell().info("✓ Successfully imported: #{profile.os}") + + {:error, reason} -> + Mix.shell().error("✗ Failed to import #{profile_name}: #{inspect(reason)}") + end + else + Mix.shell().error("Detection file not found for: #{profile_name}") + end + end + + defp delete_existing_profile(os_name) do + if profile = DeviceProfiles.get_profile(os_name) do + Mix.shell().info("Deleting existing profile: #{os_name}") + DeviceProfiles.delete_profile(profile) + end + end + + defp delete_all_profiles do + profiles = DeviceProfiles.list_profiles() + + Enum.each(profiles, fn profile -> + DeviceProfiles.delete_profile(profile) + end) + + Mix.shell().info("Deleted #{length(profiles)} existing profiles") + end + + defp guess_librenms_path do + home = System.get_env("HOME") + possible_paths = ["~/dev/librenms", "#{home}/dev/librenms", "/opt/librenms"] + + possible_paths + |> Enum.find(fn path -> + expanded = Path.expand(path) + File.dir?(expanded) + end) + |> case do + nil -> nil + path -> Path.expand(path) + end + end + + defp print_profile_stats(profile) do + profile = Repo.preload(profile, [:detection_rules, :sensor_definitions, :processor_definitions]) + + Mix.shell().info("") + Mix.shell().info(" OS: #{profile.os}") + Mix.shell().info(" Text: #{profile.text}") + Mix.shell().info(" Type: #{profile.type}") + Mix.shell().info(" Detection rules: #{length(profile.detection_rules)}") + Mix.shell().info(" Sensor definitions: #{length(profile.sensor_definitions)}") + Mix.shell().info(" Processor definitions: #{length(profile.processor_definitions)}") + Mix.shell().info("") + end + + defp print_usage do + Mix.shell().info(""" + Usage: + mix import_profiles [options] + + Options: + --profile NAME Import a single profile by name + --profiles LIST Import multiple profiles (comma-separated) + --all Import all profiles from LibreNMS + --librenms-path PATH Path to LibreNMS installation + --detection PATH Path to detection YAML file + --discovery PATH Path to discovery YAML file (optional) + --force Delete existing profile before importing + + Examples: + # Import specific profiles + mix import_profiles --profiles epmp,airos-af,unifi --librenms-path ~/dev/librenms + + # Import a single profile with explicit paths + mix import_profiles --detection ~/dev/librenms/resources/definitions/os_detection/epmp.yaml --discovery ~/dev/librenms/resources/definitions/os_discovery/epmp.yaml + + # Import all profiles (takes several minutes) + mix import_profiles --all --librenms-path ~/dev/librenms + """) + end +end diff --git a/lib/towerops/application.ex b/lib/towerops/application.ex index da69b987..c9536ec6 100644 --- a/lib/towerops/application.ex +++ b/lib/towerops/application.ex @@ -26,7 +26,7 @@ defmodule Towerops.Application do ToweropsWeb.Telemetry, Towerops.Repo, {DNSCluster, query: Application.get_env(:towerops, :dns_cluster_query) || :ignore}, - {Phoenix.PubSub, name: Towerops.PubSub, adapter: Phoenix.PubSub.Redis, redis_config: redis_pubsub_config()}, + pubsub_spec(), # Start event logger (subscribes to PubSub) Towerops.Devices.EventLogger, # Start monitoring supervisor @@ -78,6 +78,27 @@ defmodule Towerops.Application do ] end + # Returns PubSub spec - uses Redis in production/clustered environments, + # falls back to default PG2 adapter for Mix tasks and development + defp pubsub_spec do + redis_config = Application.get_env(:towerops, :redis, []) + node_name = node() + + # Use Redis adapter only when: + # 1. Redis config exists with host + # 2. Node has a name (not nonode@nohost) + use_redis? = + Keyword.has_key?(redis_config, :host) && + node_name != :nonode@nohost + + if use_redis? do + {Phoenix.PubSub, name: Towerops.PubSub, adapter: Phoenix.PubSub.Redis, redis_config: redis_pubsub_config()} + else + # Default PG2 adapter for Mix tasks and development + {Phoenix.PubSub, name: Towerops.PubSub} + end + end + defp redis_pubsub_config do redis_config = Application.get_env(:towerops, :redis, []) diff --git a/lib/towerops/device_profiles.ex b/lib/towerops/device_profiles.ex new file mode 100644 index 00000000..ebce23a6 --- /dev/null +++ b/lib/towerops/device_profiles.ex @@ -0,0 +1,214 @@ +defmodule Towerops.DeviceProfiles do + @moduledoc """ + Context for managing device SNMP profiles. + + Provides functions for querying profiles, matching devices to profiles based on + detection rules, and retrieving sensor/processor/mempool definitions. + """ + + import Ecto.Query + + alias Towerops.DeviceProfiles.DetectionRule + alias Towerops.DeviceProfiles.DeviceProfile + alias Towerops.DeviceProfiles.MemPoolDefinition + alias Towerops.DeviceProfiles.OSDefinition + alias Towerops.DeviceProfiles.ProcessorDefinition + alias Towerops.DeviceProfiles.SensorDefinition + alias Towerops.DeviceProfiles.SensorState + alias Towerops.Repo + + @doc """ + Lists all enabled device profiles. + """ + def list_profiles do + Repo.all(from p in DeviceProfile, where: p.enabled == true, order_by: [asc: p.priority]) + end + + @doc """ + Gets a single device profile by OS name. + """ + def get_profile(os) when is_binary(os) do + Repo.get_by(DeviceProfile, os: os) + end + + @doc """ + Gets a profile with all associations preloaded. + """ + def get_profile_with_associations(os) when is_binary(os) do + Repo.one( + from p in DeviceProfile, + where: p.os == ^os, + preload: [ + :detection_rules, + :os_definitions, + :processor_definitions, + :mempool_definitions, + sensor_definitions: [:states] + ] + ) + end + + @doc """ + Matches a device to a profile based on system information. + + Takes a map with :sys_descr and :sys_object_id keys and returns the + matching profile or nil. + """ + def match_profile(system_info) do + profiles = list_profiles_with_detection_rules() + + Enum.find(profiles, fn profile -> + match_detection_rules?(profile.detection_rules, system_info) + end) + end + + @doc """ + Lists all sensor definitions for a given profile and sensor class. + """ + def list_sensor_definitions(profile_id, sensor_class) do + Repo.all( + from s in SensorDefinition, + where: s.device_profile_id == ^profile_id and s.sensor_class == ^sensor_class, + preload: [:states] + ) + end + + @doc """ + Creates a new device profile. + """ + def create_profile(attrs \\ %{}) do + %DeviceProfile{} + |> DeviceProfile.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a device profile. + """ + def update_profile(%DeviceProfile{} = profile, attrs) do + profile + |> DeviceProfile.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a device profile and all associated definitions. + """ + def delete_profile(%DeviceProfile{} = profile) do + Repo.delete(profile) + end + + @doc """ + Creates a detection rule for a profile. + """ + def create_detection_rule(attrs \\ %{}) do + %DetectionRule{} + |> DetectionRule.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates a sensor definition for a profile. + """ + def create_sensor_definition(attrs \\ %{}) do + %SensorDefinition{} + |> SensorDefinition.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates a sensor state for a sensor definition. + """ + def create_sensor_state(attrs \\ %{}) do + %SensorState{} + |> SensorState.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates a processor definition for a profile. + """ + def create_processor_definition(attrs \\ %{}) do + %ProcessorDefinition{} + |> ProcessorDefinition.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates a memory pool definition for a profile. + """ + def create_mempool_definition(attrs \\ %{}) do + %MemPoolDefinition{} + |> MemPoolDefinition.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates an OS definition for a profile. + """ + def create_os_definition(attrs \\ %{}) do + %OSDefinition{} + |> OSDefinition.changeset(attrs) + |> Repo.insert() + end + + # Private functions + + defp list_profiles_with_detection_rules do + Repo.all( + from p in DeviceProfile, + where: p.enabled == true, + order_by: [asc: p.priority], + preload: [:detection_rules] + ) + end + + defp match_detection_rules?(rules, system_info) do + Enum.any?(rules, &match_rule?(&1, system_info)) + end + + defp match_rule?(%DetectionRule{rule_type: "sysObjectID"} = rule, system_info) do + sys_object_id = Map.get(system_info, :sys_object_id, "") + + cond do + rule.pattern && rule.pattern != "" -> + String.contains?(sys_object_id, rule.pattern) + + rule.value && rule.value != "" -> + sys_object_id == rule.value + + true -> + false + end + end + + defp match_rule?(%DetectionRule{rule_type: "sysDescr"} = rule, system_info) do + sys_descr = Map.get(system_info, :sys_descr, "") + + cond do + rule.pattern && rule.pattern != "" -> + # Handle regex patterns + case Regex.compile(rule.pattern) do + {:ok, regex} -> Regex.match?(regex, sys_descr) + {:error, _} -> String.contains?(sys_descr, rule.pattern) + end + + rule.value && rule.value != "" -> + String.contains?(sys_descr, rule.value) + + true -> + false + end + end + + defp match_rule?(%DetectionRule{rule_type: "sysDescr_regex"} = rule, system_info) do + sys_descr = Map.get(system_info, :sys_descr, "") + + case Regex.compile(rule.pattern || "") do + {:ok, regex} -> Regex.match?(regex, sys_descr) + {:error, _} -> false + end + end + + defp match_rule?(_rule, _system_info), do: false +end diff --git a/lib/towerops/device_profiles/detection_rule.ex b/lib/towerops/device_profiles/detection_rule.ex new file mode 100644 index 00000000..1ae994d6 --- /dev/null +++ b/lib/towerops/device_profiles/detection_rule.ex @@ -0,0 +1,37 @@ +defmodule Towerops.DeviceProfiles.DetectionRule do + @moduledoc """ + Schema for device detection rules. + + Defines rules for identifying which device profile to use based on + sysObjectID, sysDescr patterns, and SNMP queries. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.DeviceProfiles.DeviceProfile + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "profile_detection_rules" do + field :rule_type, :string + field :oid, :string + field :operator, :string + field :value, :string + field :pattern, :string + field :priority, :integer, default: 0 + + belongs_to :device_profile, DeviceProfile + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(rule, attrs) do + rule + |> cast(attrs, [:rule_type, :oid, :operator, :value, :pattern, :priority, :device_profile_id]) + |> validate_required([:rule_type, :device_profile_id]) + |> foreign_key_constraint(:device_profile_id) + end +end diff --git a/lib/towerops/device_profiles/device_profile.ex b/lib/towerops/device_profiles/device_profile.ex new file mode 100644 index 00000000..d96c0275 --- /dev/null +++ b/lib/towerops/device_profiles/device_profile.ex @@ -0,0 +1,60 @@ +defmodule Towerops.DeviceProfiles.DeviceProfile do + @moduledoc """ + Schema for device SNMP profiles. + + Stores high-level device type information and coordinates detection rules, + sensor definitions, and other device-specific SNMP configurations. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.DeviceProfiles.DetectionRule + alias Towerops.DeviceProfiles.MemPoolDefinition + alias Towerops.DeviceProfiles.OSDefinition + alias Towerops.DeviceProfiles.ProcessorDefinition + alias Towerops.DeviceProfiles.SensorDefinition + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "device_profiles" do + field :os, :string + field :text, :string + field :type, :string + field :icon, :string + field :group, :string + field :mib, :string + field :snmp_bulk, :boolean, default: true + field :mib_dir, :string + field :enabled, :boolean, default: true + field :priority, :integer, default: 100 + + has_many :detection_rules, DetectionRule, foreign_key: :device_profile_id + has_many :sensor_definitions, SensorDefinition, foreign_key: :device_profile_id + has_many :processor_definitions, ProcessorDefinition, foreign_key: :device_profile_id + has_many :mempool_definitions, MemPoolDefinition, foreign_key: :device_profile_id + has_many :os_definitions, OSDefinition, foreign_key: :device_profile_id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(profile, attrs) do + profile + |> cast(attrs, [ + :os, + :text, + :type, + :icon, + :group, + :mib, + :snmp_bulk, + :mib_dir, + :enabled, + :priority + ]) + |> validate_required([:os, :text]) + |> unique_constraint(:os) + end +end diff --git a/lib/towerops/device_profiles/importer.ex b/lib/towerops/device_profiles/importer.ex new file mode 100644 index 00000000..e1c0e4b8 --- /dev/null +++ b/lib/towerops/device_profiles/importer.ex @@ -0,0 +1,359 @@ +defmodule Towerops.DeviceProfiles.Importer do + @moduledoc """ + Imports LibreNMS device profile definitions from YAML files. + + Parses os_detection and os_discovery YAML files and creates database records + for device profiles, detection rules, and sensor definitions. + """ + + alias Towerops.DeviceProfiles + alias Towerops.Repo + + require Logger + + @doc """ + Imports a device profile from LibreNMS YAML files. + + ## Parameters + - detection_file: Path to os_detection YAML file + - discovery_file: Path to os_discovery YAML file (optional) + + ## Returns + - {:ok, profile} on success + - {:error, reason} on failure + """ + def import_profile(detection_file, discovery_file \\ nil) do + Repo.transaction(fn -> + with {:ok, detection_data} <- parse_yaml_file(detection_file), + {:ok, profile} <- create_profile_from_detection(detection_data), + {:ok, profile} <- maybe_import_discovery(profile, discovery_file) do + profile + else + {:error, reason} -> + Logger.error("Failed to import profile: #{inspect(reason)}") + Repo.rollback(reason) + end + end) + end + + @doc """ + Imports all profiles from a directory. + """ + def import_all_from_directory(detection_dir, discovery_dir) do + detection_files = Path.wildcard(Path.join(detection_dir, "*.yaml")) + + results = + Enum.map(detection_files, fn detection_file -> + os_name = Path.basename(detection_file, ".yaml") + discovery_file = Path.join(discovery_dir, "#{os_name}.yaml") + + discovery_file = + if File.exists?(discovery_file), do: discovery_file + + case import_profile(detection_file, discovery_file) do + {:ok, profile} -> + Logger.info("Imported profile: #{profile.os}") + {:ok, profile} + + {:error, reason} -> + Logger.warning("Failed to import #{os_name}: #{inspect(reason)}") + {:error, os_name, reason} + end + end) + + {successes, failures} = Enum.split_with(results, &match?({:ok, _}, &1)) + + Logger.info("Imported #{length(successes)} profiles successfully") + + if length(failures) > 0 do + Logger.warning("Failed to import #{length(failures)} profiles") + end + + {:ok, %{success: length(successes), failed: length(failures)}} + end + + # Private functions + + defp parse_yaml_file(file_path) do + case YamlElixir.read_from_file(file_path) do + {:ok, data} -> {:ok, data} + {:error, reason} -> {:error, "YAML parse error: #{inspect(reason)}"} + end + end + + defp create_profile_from_detection(data) do + attrs = %{ + os: data["os"], + text: data["text"] || data["os"], + type: data["type"], + icon: data["icon"], + group: data["group"], + snmp_bulk: Map.get(data, "snmp_bulk", true), + mib_dir: data["mib_dir"], + enabled: true, + priority: data["priority"] || 100 + } + + case DeviceProfiles.create_profile(attrs) do + {:ok, profile} -> + # Create detection rules + case create_detection_rules(profile.id, data["discovery"] || []) do + :ok -> {:ok, profile} + {:error, _} = error -> error + end + + {:error, _} = error -> + error + end + end + + defp create_detection_rules(profile_id, rules) when is_list(rules) do + Enum.reduce_while(rules, :ok, fn rule, _acc -> + case parse_detection_rule(profile_id, rule) do + {:ok, _} -> {:cont, :ok} + {:error, _} = error -> {:halt, error} + end + end) + end + + defp parse_detection_rule(profile_id, rule) do + # Handle sysObjectID rules + if Map.has_key?(rule, "sysObjectID") do + create_sys_object_id_rules(profile_id, rule["sysObjectID"]) + end + + # Handle sysDescr rules + if Map.has_key?(rule, "sysDescr") do + DeviceProfiles.create_detection_rule(%{ + device_profile_id: profile_id, + rule_type: "sysDescr", + value: rule["sysDescr"] + }) + end + + # Handle sysDescr_regex rules + if Map.has_key?(rule, "sysDescr_regex") do + create_sys_descr_regex_rules(profile_id, rule["sysDescr_regex"]) + end + + {:ok, :processed} + end + + defp create_sys_object_id_rules(profile_id, oids) when is_list(oids) do + Enum.each(oids, fn oid -> + DeviceProfiles.create_detection_rule(%{ + device_profile_id: profile_id, + rule_type: "sysObjectID", + pattern: oid + }) + end) + + {:ok, :created} + end + + defp create_sys_object_id_rules(profile_id, oid) when is_binary(oid) do + DeviceProfiles.create_detection_rule(%{ + device_profile_id: profile_id, + rule_type: "sysObjectID", + pattern: oid + }) + end + + defp create_sys_descr_regex_rules(profile_id, patterns) when is_list(patterns) do + Enum.each(patterns, fn pattern -> + # Remove leading/trailing slashes from regex pattern + clean_pattern = String.trim(pattern, "/") + + DeviceProfiles.create_detection_rule(%{ + device_profile_id: profile_id, + rule_type: "sysDescr_regex", + pattern: clean_pattern + }) + end) + + {:ok, :created} + end + + defp create_sys_descr_regex_rules(profile_id, pattern) when is_binary(pattern) do + clean_pattern = String.trim(pattern, "/") + + DeviceProfiles.create_detection_rule(%{ + device_profile_id: profile_id, + rule_type: "sysDescr_regex", + pattern: clean_pattern + }) + end + + defp maybe_import_discovery(profile, nil), do: {:ok, profile} + + defp maybe_import_discovery(profile, discovery_file) do + if File.exists?(discovery_file) do + with {:ok, discovery_data} <- parse_yaml_file(discovery_file) do + import_discovery_definitions(profile, discovery_data) + end + else + {:ok, profile} + end + end + + defp import_discovery_definitions(profile, data) do + # Import MIB if specified + if data["mib"] do + DeviceProfiles.update_profile(profile, %{mib: data["mib"]}) + end + + modules = data["modules"] || %{} + + # Import OS definitions + if modules["os"] do + import_os_definitions(profile.id, modules["os"]) + end + + # Import processor definitions + if modules["processors"] && modules["processors"]["data"] do + import_processor_definitions(profile.id, modules["processors"]["data"]) + end + + # Import memory pool definitions + if modules["mempools"] && modules["mempools"]["data"] do + import_mempool_definitions(profile.id, modules["mempools"]["data"]) + end + + # Import sensor definitions + if modules["sensors"] do + import_sensor_definitions(profile.id, modules["sensors"]) + end + + {:ok, profile} + end + + defp import_os_definitions(profile_id, os_data) do + Enum.each(os_data, fn {field, value} -> + attrs = %{ + device_profile_id: profile_id, + field: to_string(field), + oid: if(is_binary(value), do: value) + } + + DeviceProfiles.create_os_definition(attrs) + end) + end + + defp import_processor_definitions(profile_id, processors) do + Enum.each(processors, fn proc -> + attrs = %{ + device_profile_id: profile_id, + oid: proc["oid"], + num_oid: proc["num_oid"], + index: to_string(proc["index"] || ""), + descr: proc["descr"], + precision: proc["precision"] || 1, + type: proc["type"] + } + + DeviceProfiles.create_processor_definition(attrs) + end) + end + + defp import_mempool_definitions(profile_id, mempools) do + Enum.each(mempools, fn mempool -> + attrs = %{ + device_profile_id: profile_id, + index: to_string(mempool["index"] || ""), + descr: mempool["descr"], + total_oid: mempool["total"], + used_oid: mempool["used"], + free_oid: mempool["free"], + percent_used_oid: mempool["percent_used"], + precision: mempool["precision"] || 1, + type: mempool["type"], + class: mempool["class"] + } + + DeviceProfiles.create_mempool_definition(attrs) + end) + end + + defp import_sensor_definitions(profile_id, sensors_data) do + Enum.each(sensors_data, fn {sensor_class, sensor_config} -> + if sensor_config["data"] do + import_sensors_of_class(profile_id, to_string(sensor_class), sensor_config["data"]) + end + end) + end + + defp import_sensors_of_class(profile_id, sensor_class, sensors) do + Enum.each(sensors, fn sensor -> + attrs = %{ + device_profile_id: profile_id, + sensor_class: sensor_class, + oid: sensor["oid"], + num_oid: sensor["num_oid"], + index: to_string(sensor["index"] || ""), + descr: sensor["descr"], + divisor: calculate_divisor(sensor["precision"]), + precision: sensor["precision"], + sensor_type: sensor["type"], + low_limit: parse_float(sensor["low_limit"]), + low_warn_limit: parse_float(sensor["low_warn_limit"]), + warn_limit: parse_float(sensor["warn_limit"]), + high_limit: parse_float(sensor["high_limit"]), + skip_value_lt: parse_float(sensor["skip_value_lt"]), + skip_value_gt: parse_float(sensor["skip_value_gt"]), + user_func: sensor["user_func"], + entPhysicalIndex: sensor["entPhysicalIndex"], + group: sensor["group"], + options: extract_sensor_options(sensor) + } + + case DeviceProfiles.create_sensor_definition(attrs) do + {:ok, sensor_def} -> + # Import sensor states if present + if sensor["states"] do + import_sensor_states(sensor_def.id, sensor["states"]) + end + + {:error, reason} -> + Logger.warning("Failed to import sensor: #{inspect(reason)}") + end + end) + end + + defp import_sensor_states(sensor_definition_id, states) when is_list(states) do + Enum.each(states, fn state -> + attrs = %{ + sensor_definition_id: sensor_definition_id, + value: state["value"], + descr: state["descr"], + generic: state["generic"], + graph: state["graph"] + } + + DeviceProfiles.create_sensor_state(attrs) + end) + end + + defp calculate_divisor(nil), do: 1 + + defp calculate_divisor(precision) when is_integer(precision) and precision > 0 do + 10 |> :math.pow(precision) |> trunc() + end + + defp calculate_divisor(_), do: 1 + + defp parse_float(nil), do: nil + defp parse_float(value) when is_float(value), do: value + defp parse_float(value) when is_integer(value), do: value * 1.0 + defp parse_float(value) when is_binary(value), do: String.to_float(value) + defp parse_float(_), do: nil + + defp extract_sensor_options(sensor) do + %{ + snmp_flags: sensor["snmp_flags"], + state_name: sensor["state_name"], + skip_values: sensor["skip_values"] + } + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end +end diff --git a/lib/towerops/device_profiles/mempool_definition.ex b/lib/towerops/device_profiles/mempool_definition.ex new file mode 100644 index 00000000..f8a4da3c --- /dev/null +++ b/lib/towerops/device_profiles/mempool_definition.ex @@ -0,0 +1,52 @@ +defmodule Towerops.DeviceProfiles.MemPoolDefinition do + @moduledoc """ + Schema for memory pool definitions. + + Defines OID mappings for discovering and monitoring memory usage. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.DeviceProfiles.DeviceProfile + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "profile_mempool_definitions" do + field :index, :string + field :descr, :string + field :total_oid, :string + field :used_oid, :string + field :free_oid, :string + field :percent_used_oid, :string + field :precision, :integer, default: 1 + field :type, :string + field :class, :string + field :warn_percent, :integer + + belongs_to :device_profile, DeviceProfile + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(definition, attrs) do + definition + |> cast(attrs, [ + :index, + :descr, + :total_oid, + :used_oid, + :free_oid, + :percent_used_oid, + :precision, + :type, + :class, + :warn_percent, + :device_profile_id + ]) + |> validate_required([:device_profile_id]) + |> foreign_key_constraint(:device_profile_id) + end +end diff --git a/lib/towerops/device_profiles/os_definition.ex b/lib/towerops/device_profiles/os_definition.ex new file mode 100644 index 00000000..d22a6005 --- /dev/null +++ b/lib/towerops/device_profiles/os_definition.ex @@ -0,0 +1,37 @@ +defmodule Towerops.DeviceProfiles.OSDefinition do + @moduledoc """ + Schema for OS-specific metadata definitions. + + Defines how to extract device version, serial number, hardware model, + and other OS-specific information. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.DeviceProfiles.DeviceProfile + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "profile_os_definitions" do + field :field, :string + field :oid, :string + field :regex, :string + field :template, :string + field :value, :string + + belongs_to :device_profile, DeviceProfile + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(definition, attrs) do + definition + |> cast(attrs, [:field, :oid, :regex, :template, :value, :device_profile_id]) + |> validate_required([:field, :device_profile_id]) + |> foreign_key_constraint(:device_profile_id) + |> unique_constraint([:device_profile_id, :field]) + end +end diff --git a/lib/towerops/device_profiles/processor_definition.ex b/lib/towerops/device_profiles/processor_definition.ex new file mode 100644 index 00000000..56bdda74 --- /dev/null +++ b/lib/towerops/device_profiles/processor_definition.ex @@ -0,0 +1,48 @@ +defmodule Towerops.DeviceProfiles.ProcessorDefinition do + @moduledoc """ + Schema for CPU/processor definitions. + + Defines OID mappings for discovering and monitoring CPU usage. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.DeviceProfiles.DeviceProfile + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "profile_processor_definitions" do + field :oid, :string + field :num_oid, :string + field :index, :string + field :descr, :string + field :precision, :integer, default: 1 + field :type, :string + field :warn_percent, :integer + field :entPhysicalIndex, :string + + belongs_to :device_profile, DeviceProfile + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(definition, attrs) do + definition + |> cast(attrs, [ + :oid, + :num_oid, + :index, + :descr, + :precision, + :type, + :warn_percent, + :entPhysicalIndex, + :device_profile_id + ]) + |> validate_required([:oid, :num_oid, :device_profile_id]) + |> foreign_key_constraint(:device_profile_id) + end +end diff --git a/lib/towerops/device_profiles/sensor_definition.ex b/lib/towerops/device_profiles/sensor_definition.ex new file mode 100644 index 00000000..c5f937ee --- /dev/null +++ b/lib/towerops/device_profiles/sensor_definition.ex @@ -0,0 +1,75 @@ +defmodule Towerops.DeviceProfiles.SensorDefinition do + @moduledoc """ + Schema for sensor definitions. + + Defines OID mappings and configurations for discovering and polling + device sensors (temperature, voltage, power, etc.). + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.DeviceProfiles.DeviceProfile + alias Towerops.DeviceProfiles.SensorState + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "profile_sensor_definitions" do + field :sensor_class, :string + field :oid, :string + field :num_oid, :string + field :index, :string + field :descr, :string + field :divisor, :integer, default: 1 + field :multiplier, :integer, default: 1 + field :precision, :integer + field :sensor_type, :string + field :unit, :string + field :low_limit, :float + field :low_warn_limit, :float + field :warn_limit, :float + field :high_limit, :float + field :skip_value_lt, :float + field :skip_value_gt, :float + field :user_func, :string + field :entPhysicalIndex, :string + field :group, :string + field :options, :map + + belongs_to :device_profile, DeviceProfile + has_many :states, SensorState, foreign_key: :sensor_definition_id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(definition, attrs) do + definition + |> cast(attrs, [ + :sensor_class, + :oid, + :num_oid, + :index, + :descr, + :divisor, + :multiplier, + :precision, + :sensor_type, + :unit, + :low_limit, + :low_warn_limit, + :warn_limit, + :high_limit, + :skip_value_lt, + :skip_value_gt, + :user_func, + :entPhysicalIndex, + :group, + :options, + :device_profile_id + ]) + |> validate_required([:sensor_class, :device_profile_id]) + |> foreign_key_constraint(:device_profile_id) + end +end diff --git a/lib/towerops/device_profiles/sensor_state.ex b/lib/towerops/device_profiles/sensor_state.ex new file mode 100644 index 00000000..a5f9d168 --- /dev/null +++ b/lib/towerops/device_profiles/sensor_state.ex @@ -0,0 +1,35 @@ +defmodule Towerops.DeviceProfiles.SensorState do + @moduledoc """ + Schema for sensor state definitions. + + Defines enumerated states for state-based sensors (e.g., GPS sync status, + DFS status, power supply status). + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.DeviceProfiles.SensorDefinition + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "profile_sensor_states" do + field :value, :integer + field :descr, :string + field :generic, :integer + field :graph, :integer + + belongs_to :sensor_definition, SensorDefinition + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(state, attrs) do + state + |> cast(attrs, [:value, :descr, :generic, :graph, :sensor_definition_id]) + |> validate_required([:value, :descr, :generic, :graph, :sensor_definition_id]) + |> foreign_key_constraint(:sensor_definition_id) + end +end diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index adcbd7c9..51e5d62a 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -14,6 +14,7 @@ defmodule Towerops.Snmp.Discovery do import Ecto.Query + alias Towerops.DeviceProfiles alias Towerops.Devices alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Repo @@ -23,6 +24,7 @@ defmodule Towerops.Snmp.Discovery do alias Towerops.Snmp.NeighborDiscovery alias Towerops.Snmp.Profiles.Base alias Towerops.Snmp.Profiles.Cisco + alias Towerops.Snmp.Profiles.Dynamic alias Towerops.Snmp.Profiles.Mikrotik alias Towerops.Snmp.Profiles.NetSnmp alias Towerops.Snmp.Profiles.Ubiquiti @@ -74,7 +76,7 @@ defmodule Towerops.Snmp.Discovery do optional(atom()) => term() } - @type profile :: module() + @type profile :: module() | {:dynamic, DeviceProfiles.DeviceProfile.t()} @type discovery_summary :: %{ success: non_neg_integer(), @@ -195,6 +197,9 @@ defmodule Towerops.Snmp.Discovery do @doc """ Selects the appropriate SNMP profile based on system information. + First tries to match against database-stored profiles from LibreNMS. + If no database match is found, falls back to hard-coded profile modules. + ## Examples iex> select_profile(%{sys_descr: "Cisco IOS"}) @@ -205,6 +210,19 @@ defmodule Towerops.Snmp.Discovery do """ @spec select_profile(system_info()) :: profile() def select_profile(system_info) do + # First try to match against database profiles + case DeviceProfiles.match_profile(system_info) do + %DeviceProfiles.DeviceProfile{} = profile -> + Logger.debug("Selected database profile: #{profile.os}") + {:dynamic, profile} + + nil -> + # Fall back to hard-coded profile selection + select_hardcoded_profile(system_info) + end + end + + defp select_hardcoded_profile(system_info) do sys_descr = Map.get(system_info, :sys_descr, "") sys_object_id = Map.get(system_info, :sys_object_id, "") @@ -235,8 +253,18 @@ defmodule Towerops.Snmp.Discovery do @spec build_device_info(Client.connection_opts(), system_info(), profile()) :: {:ok, device_info()} - defp build_device_info(_client_opts, system_info, profile) do - # Let the profile identify the device (adds manufacturer, model, firmware_version) + defp build_device_info(_client_opts, system_info, {:dynamic, profile}) do + # Use dynamic profile to identify device + identified_info = Dynamic.identify_device(profile, system_info) + {:ok, identified_info} + rescue + _error -> + Logger.error("Failed to identify device with dynamic profile") + {:ok, system_info} + end + + defp build_device_info(_client_opts, system_info, profile) when is_atom(profile) do + # Let the hard-coded profile identify the device identified_info = profile.identify_device(system_info) {:ok, identified_info} rescue @@ -247,7 +275,19 @@ defmodule Towerops.Snmp.Discovery do @spec discover_interfaces(Client.connection_opts(), profile()) :: {:ok, [interface_data()]} - defp discover_interfaces(client_opts, profile) do + defp discover_interfaces(client_opts, {:dynamic, profile}) do + case Dynamic.discover_interfaces(profile, client_opts) do + {:ok, interfaces} -> + Logger.debug("Discovered #{length(interfaces)} interfaces") + {:ok, interfaces} + + {:error, _} -> + Logger.warning("Interface discovery failed, continuing without interfaces") + {:ok, []} + end + end + + defp discover_interfaces(client_opts, profile) when is_atom(profile) do case profile.discover_interfaces(client_opts) do {:ok, interfaces} -> Logger.debug("Discovered #{length(interfaces)} interfaces") @@ -260,7 +300,19 @@ defmodule Towerops.Snmp.Discovery do end @spec discover_sensors(Client.connection_opts(), profile()) :: {:ok, [sensor_data()]} - defp discover_sensors(client_opts, profile) do + defp discover_sensors(client_opts, {:dynamic, profile}) do + case Dynamic.discover_sensors(profile, client_opts) do + {:ok, sensors} -> + Logger.debug("Discovered #{length(sensors)} sensors") + {:ok, sensors} + + {:error, _} -> + Logger.warning("Sensor discovery failed, continuing without sensors") + {:ok, []} + end + end + + defp discover_sensors(client_opts, profile) when is_atom(profile) do case profile.discover_sensors(client_opts) do {:ok, sensors} -> Logger.debug("Discovered #{length(sensors)} sensors") diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex new file mode 100644 index 00000000..9998f3a6 --- /dev/null +++ b/lib/towerops/snmp/profiles/dynamic.ex @@ -0,0 +1,171 @@ +defmodule Towerops.Snmp.Profiles.Dynamic do + @moduledoc """ + Dynamic SNMP profile that interprets database-stored device profiles. + + This profile uses the device_profiles table to dynamically discover + sensors and processors based on OID definitions from LibreNMS. + """ + + alias Towerops.DeviceProfiles + alias Towerops.DeviceProfiles.DeviceProfile + alias Towerops.Snmp.Client + alias Towerops.Snmp.Discovery + alias Towerops.Snmp.Profiles.Base + + require Logger + + @doc """ + Identifies device using the database profile information. + Returns manufacturer, model, and firmware info. + """ + @spec identify_device(DeviceProfile.t(), Discovery.system_info()) :: Discovery.device_info() + def identify_device(profile, system_info) do + # Use profile metadata to build device info + %{ + manufacturer: extract_manufacturer(profile), + model: profile.text || profile.os, + firmware_version: Map.get(system_info, :sys_descr), + serial_number: nil + } + end + + @doc """ + Discovers network interfaces using the base profile. + Dynamic profiles inherit standard IF-MIB interface discovery. + """ + @spec discover_interfaces(DeviceProfile.t(), Client.connection_opts()) :: + {:ok, [Discovery.interface_data()]} | {:error, term()} + def discover_interfaces(_profile, client_opts) do + # Use base profile for standard interface discovery + Base.discover_interfaces(client_opts) + end + + @doc """ + Discovers sensors dynamically using database profile definitions. + Walks SNMP OIDs defined in the profile's sensor_definitions. + """ + @spec discover_sensors(DeviceProfile.t(), Client.connection_opts()) :: + {:ok, [Discovery.sensor_data()]} | {:error, term()} + def discover_sensors(profile, client_opts) do + # Load profile with sensor definitions + profile = DeviceProfiles.get_profile_with_associations(profile.os) + + if profile && length(profile.sensor_definitions) > 0 do + Logger.debug("Discovering sensors for #{profile.os} using #{length(profile.sensor_definitions)} definitions") + + sensors = + Enum.flat_map(profile.sensor_definitions, fn sensor_def -> + discover_sensor_from_definition(client_opts, sensor_def) + end) + + {:ok, sensors} + else + Logger.debug("No sensor definitions found for profile #{profile.os}") + {:ok, []} + end + end + + # Private functions + + defp extract_manufacturer(%DeviceProfile{group: group}) when is_binary(group) do + # Capitalize manufacturer from group (e.g., "cambium" -> "Cambium") + String.capitalize(group) + end + + defp extract_manufacturer(%DeviceProfile{text: text}) when is_binary(text) do + # Extract first word from text (e.g., "Ubiquiti AirFiber" -> "Ubiquiti") + text + |> String.split(" ") + |> List.first() + end + + defp extract_manufacturer(_), do: "Unknown" + + defp discover_sensor_from_definition(client_opts, sensor_def) do + # Try numeric OID first, fall back to named OID + oid = sensor_def.num_oid || sensor_def.oid + + if oid do + case Client.walk(client_opts, oid) do + {:ok, results} when is_list(results) and results != [] -> + # Convert walk results to sensor data + Enum.map(results, fn result -> + build_sensor_data(sensor_def, result) + end) + + {:ok, _empty} -> + Logger.debug("No data found for sensor OID: #{oid}") + [] + + {:error, reason} -> + Logger.warning("Failed to walk sensor OID #{oid}: #{inspect(reason)}") + [] + end + else + Logger.warning("Sensor definition missing OID: #{inspect(sensor_def)}") + [] + end + end + + defp build_sensor_data(sensor_def, %{oid: oid, value: value}) do + # Build sensor index from OID (extract last component) + sensor_index = extract_index_from_oid(oid, sensor_def.index) + + # Apply divisor to value if it's numeric + last_value = + cond do + is_number(value) && sensor_def.divisor && sensor_def.divisor > 1 -> + value / sensor_def.divisor + + is_number(value) -> + value * 1.0 + + true -> + nil + end + + %{ + sensor_type: sensor_def.sensor_class, + sensor_index: sensor_index, + sensor_oid: oid, + sensor_descr: sensor_def.descr || "#{sensor_def.sensor_class} #{sensor_index}", + sensor_unit: determine_unit(sensor_def.sensor_class), + sensor_divisor: sensor_def.divisor || 1, + last_value: last_value, + status: "ok" + } + end + + defp extract_index_from_oid(oid, configured_index) when is_binary(configured_index) do + # If index is pre-configured, use it + if configured_index == "" do + # Extract last component of OID as index + oid + |> String.split(".") + |> List.last() + |> to_string() + else + configured_index + end + end + + defp extract_index_from_oid(oid, _) do + # Extract last component of OID as index + oid + |> String.split(".") + |> List.last() + |> to_string() + end + + defp determine_unit("temperature"), do: "°C" + defp determine_unit("voltage"), do: "V" + defp determine_unit("current"), do: "A" + defp determine_unit("power"), do: "W" + defp determine_unit("fanspeed"), do: "RPM" + defp determine_unit("humidity"), do: "%" + defp determine_unit("dbm"), do: "dBm" + defp determine_unit("snr"), do: "dB" + defp determine_unit("percent"), do: "%" + defp determine_unit("frequency"), do: "Hz" + defp determine_unit(_), do: "" +end diff --git a/priv/repo/migrations/20260118000250_create_device_profiles.exs b/priv/repo/migrations/20260118000250_create_device_profiles.exs new file mode 100644 index 00000000..f84c647a --- /dev/null +++ b/priv/repo/migrations/20260118000250_create_device_profiles.exs @@ -0,0 +1,162 @@ +defmodule Towerops.Repo.Migrations.CreateDeviceProfiles do + use Ecto.Migration + + def change do + # Main device profile table - stores high-level device type information + create table(:device_profiles, primary_key: false) do + add :id, :binary_id, primary_key: true + add :os, :string, null: false + add :text, :string, null: false + add :type, :string + add :icon, :string + add :group, :string + add :mib, :string + add :snmp_bulk, :boolean, default: true + add :mib_dir, :string + add :enabled, :boolean, default: true + add :priority, :integer, default: 100 + + timestamps(type: :utc_datetime) + end + + create unique_index(:device_profiles, [:os]) + create index(:device_profiles, [:enabled]) + create index(:device_profiles, [:type]) + create index(:device_profiles, [:priority]) + + # Detection rules - how to identify which profile to use + create table(:profile_detection_rules, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :device_profile_id, + references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false + + add :rule_type, :string, null: false + add :oid, :text + add :operator, :string + add :value, :text + add :pattern, :text + add :priority, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + create index(:profile_detection_rules, [:device_profile_id]) + create index(:profile_detection_rules, [:rule_type]) + + # Sensor definitions - OID mappings for different sensor types + create table(:profile_sensor_definitions, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :device_profile_id, + references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false + + add :sensor_class, :string, null: false + add :oid, :string + add :num_oid, :string + add :index, :string + add :descr, :string + add :divisor, :integer, default: 1 + add :multiplier, :integer, default: 1 + add :precision, :integer + add :sensor_type, :string + add :unit, :string + add :low_limit, :float + add :low_warn_limit, :float + add :warn_limit, :float + add :high_limit, :float + add :skip_value_lt, :float + add :skip_value_gt, :float + add :user_func, :string + add :entPhysicalIndex, :string + add :group, :string + add :options, :map + + timestamps(type: :utc_datetime) + end + + create index(:profile_sensor_definitions, [:device_profile_id]) + create index(:profile_sensor_definitions, [:sensor_class]) + + # State definitions - for enumerated state sensors + create table(:profile_sensor_states, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :sensor_definition_id, + references(:profile_sensor_definitions, type: :binary_id, on_delete: :delete_all), + null: false + + add :value, :integer, null: false + add :descr, :string, null: false + add :generic, :integer, null: false + add :graph, :integer, null: false + + timestamps(type: :utc_datetime) + end + + create index(:profile_sensor_states, [:sensor_definition_id]) + + # Processor (CPU) definitions + create table(:profile_processor_definitions, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :device_profile_id, + references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false + + add :oid, :string, null: false + add :num_oid, :string, null: false + add :index, :string + add :descr, :string + add :precision, :integer, default: 1 + add :type, :string + add :warn_percent, :integer + add :entPhysicalIndex, :string + + timestamps(type: :utc_datetime) + end + + create index(:profile_processor_definitions, [:device_profile_id]) + + # Memory pool definitions + create table(:profile_mempool_definitions, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :device_profile_id, + references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false + + add :index, :string + add :descr, :string + add :total_oid, :string + add :used_oid, :string + add :free_oid, :string + add :percent_used_oid, :string + add :precision, :integer, default: 1 + add :type, :string + add :class, :string + add :warn_percent, :integer + + timestamps(type: :utc_datetime) + end + + create index(:profile_mempool_definitions, [:device_profile_id]) + + # OS-specific metadata (version, serial, hardware info) + create table(:profile_os_definitions, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :device_profile_id, + references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false + + add :field, :string, null: false + add :oid, :string + add :regex, :string + add :template, :text + add :value, :string + + timestamps(type: :utc_datetime) + end + + create index(:profile_os_definitions, [:device_profile_id]) + create unique_index(:profile_os_definitions, [:device_profile_id, :field]) + end +end