defmodule Mix.Tasks.GenVendorModules do @shortdoc "Generate vendor module skeletons from YAML profiles" @moduledoc """ Generates vendor module skeletons from YAML profiles. ## Usage mix gen_vendor_modules [--dry-run] [--group GROUP] ## Options * `--dry-run` - Show what would be generated without creating files * `--group GROUP` - Only generate for a specific group/vendor ## Examples # Generate all missing vendor modules mix gen_vendor_modules # Preview what would be generated mix gen_vendor_modules --dry-run # Generate only for a specific vendor mix gen_vendor_modules --group cisco """ use Mix.Task @profiles_dir "priv/profiles/os_detection" @vendors_dir "lib/towerops/snmp/profiles/vendors" # Filename prefix to vendor mapping for profiles without group field @filename_vendor_map %{ "cisco" => "cisco", "juniper" => "juniper", "junos" => "juniper", "hp" => "hp", "dell" => "dell", "ibm" => "ibm", "netgear" => "netgear", "dlink" => "dlink", "d-link" => "dlink", "tp-link" => "tplink", "tplink" => "tplink", "netapp" => "netapp", "synology" => "synology", "qnap" => "qnap", "vmware" => "vmware", "palo" => "paloalto", "panos" => "paloalto", "fortinet" => "fortinet", "forti" => "fortinet", "checkpoint" => "checkpoint", "sonic" => "sonicwall", "sonicwall" => "sonicwall", "opengear" => "opengear", "raritan" => "raritan", "eaton" => "eaton", "liebert" => "liebert", "cyberpower" => "cyberpower", "tripp" => "tripplite", "axis" => "axis", "hikvision" => "hikvision", "avaya" => "avaya", "polycom" => "polycom", "yealink" => "yealink", "mitel" => "mitel", "riverbed" => "riverbed", "citrix" => "citrix", "f5" => "f5", "a10" => "a10", "infoblox" => "infoblox", "bluecoat" => "bluecoat", "barracuda" => "barracuda", "aruba" => "aruba", "ruckus" => "ruckus" } @impl Mix.Task def run(args) do {opts, _, _} = OptionParser.parse(args, switches: [dry_run: :boolean, group: :string]) dry_run = Keyword.get(opts, :dry_run, false) filter_group = Keyword.get(opts, :group) # Get existing vendors and covered profiles existing_vendors = get_existing_vendors() covered_profiles = get_covered_profiles() IO.puts("Found #{length(existing_vendors)} existing vendor modules") IO.puts("Found #{length(covered_profiles)} profiles already covered") # Parse all YAML profiles profiles = parse_yaml_profiles() IO.puts("Found #{length(profiles)} YAML profiles") # Group profiles by vendor grouped = group_profiles(profiles) IO.puts("Grouped into #{map_size(grouped)} vendors") # Filter if requested grouped = if filter_group do Map.take(grouped, [filter_group]) else grouped end # Filter out existing vendors and groups where all profiles are already covered new_vendors = grouped |> Enum.reject(fn {vendor, vendor_profiles} -> module_name = vendor_to_module_name(vendor) module_exists = module_name in existing_vendors # Check if all profiles in this group are already covered profile_names = Enum.map(vendor_profiles, & &1.os) all_covered = Enum.all?(profile_names, &(&1 in covered_profiles)) module_exists or all_covered end) |> Enum.map(fn {vendor, vendor_profiles} -> # Filter out profiles that are already covered uncovered = Enum.reject(vendor_profiles, &(&1.os in covered_profiles)) {vendor, uncovered} end) |> Enum.reject(fn {_vendor, profiles} -> Enum.empty?(profiles) end) |> Map.new() IO.puts("#{map_size(new_vendors)} new vendors to generate") if map_size(new_vendors) == 0 do IO.puts("\nNo new vendors to generate!") :ok else Enum.each(new_vendors, fn {vendor, vendor_profiles} -> generate_vendor_module(vendor, vendor_profiles, dry_run) end) print_instructions(new_vendors, dry_run) end end defp print_instructions(new_vendors, dry_run) do if !dry_run do IO.puts("\n\nGenerated #{map_size(new_vendors)} vendor modules.") IO.puts("Run `mix compile` to check for errors.") IO.puts("Run `mix test` to verify.") IO.puts("\nDon't forget to:") IO.puts(" 1. Add aliases to registry.ex") IO.puts(" 2. Add vendors to @vendors list in registry.ex") IO.puts(" 3. Write tests for each vendor") IO.puts(" 4. Add vendor-specific OIDs if available") end end defp get_existing_vendors do "#{@vendors_dir}/*.ex" |> Path.wildcard() |> Enum.map(&Path.basename(&1, ".ex")) |> Enum.reject(&(&1 in ["registry", "vendor"])) |> Enum.map(&Macro.camelize/1) end defp get_covered_profiles do # Get all profile names already handled by existing vendor modules # This requires the application to be started _ = Application.ensure_all_started(:towerops) Towerops.Snmp.Profiles.Vendors.Registry.list_profile_names() rescue _ -> [] end defp parse_yaml_profiles do "#{@profiles_dir}/*.yaml" |> Path.wildcard() |> Enum.map(&parse_yaml_file/1) |> Enum.reject(&is_nil/1) end defp parse_yaml_file(path) do case YamlElixir.read_from_file(path) do {:ok, data} when is_map(data) -> build_profile_map(data, path) _ -> nil end end defp build_profile_map(data, path) do os = Map.get(data, "os") if os do %{ os: os, group: Map.get(data, "group"), text: Map.get(data, "text"), sys_object_ids: extract_sys_object_ids(data), filename: Path.basename(path, ".yaml") } end end defp extract_sys_object_ids(data) do data |> Map.get("discovery", []) |> List.wrap() |> Enum.flat_map(&get_sys_object_id_list/1) end defp get_sys_object_id_list(%{"sysObjectID" => oids}) when is_list(oids), do: oids defp get_sys_object_id_list(_), do: [] defp group_profiles(profiles) do profiles |> Enum.group_by(&determine_vendor/1) |> Enum.reject(fn {vendor, _} -> vendor == nil end) |> Map.new() end defp determine_vendor(%{group: group}) when is_binary(group) and group != "" do String.downcase(group) end defp determine_vendor(%{filename: filename}) do infer_vendor_from_filename(filename) end defp infer_vendor_from_filename(filename) do Enum.find_value(@filename_vendor_map, fn {prefix, vendor} -> if String.starts_with?(filename, prefix) or String.contains?(filename, prefix) do vendor end end) end defp vendor_to_module_name(vendor) do name = vendor |> String.replace(~r/[^a-z0-9]+/i, "_") |> String.trim("_") |> Macro.camelize() # Prefix with "N" if starts with a number (e.g., "4rf" -> "N4rf") if String.match?(name, ~r/^[0-9]/) do "N#{name}" else name end end defp vendor_to_filename(vendor) do name = vendor |> String.replace(~r/[^a-z0-9]+/i, "_") |> String.downcase() |> String.trim("_") # Prefix with "n" if starts with a number if String.match?(name, ~r/^[0-9]/) do "n#{name}" else name end end defp generate_vendor_module(vendor, profiles, dry_run) do module_name = vendor_to_module_name(vendor) filename = vendor_to_filename(vendor) profile_names = profiles |> Enum.map(& &1.os) |> Enum.sort() |> Enum.uniq() # Get enterprise OID from first sysObjectID if available enterprise_oid = extract_enterprise_oid(profiles) content = generate_module_content(module_name, profile_names, enterprise_oid, vendor) if dry_run do IO.puts("\n--- #{filename}.ex (#{module_name}) ---") IO.puts(" Profiles: #{Enum.join(profile_names, ", ")}") IO.puts(" Enterprise OID: #{enterprise_oid || "none"}") else path = "#{@vendors_dir}/#{filename}.ex" File.write!(path, content) IO.puts("Generated: #{path}") end end defp extract_enterprise_oid(profiles) do profiles |> Enum.flat_map(& &1.sys_object_ids) |> Enum.find_value(fn oid -> # Parse enterprise OID from sysObjectID pattern like ".1.3.6.1.4.1.9." case Regex.run(~r/\.?1\.3\.6\.1\.4\.1\.(\d+)/, oid) do [_, enterprise] -> "1.3.6.1.4.1.#{enterprise}" _ -> nil end end) end defp generate_module_content(module_name, profile_names, enterprise_oid, vendor) do profiles_list = inspect(profile_names, pretty: true, limit: :infinity) enterprise_comment = if enterprise_oid do " # Enterprise OID: #{enterprise_oid}" else " # Enterprise OID: unknown - uses standard MIB OIDs" end """ defmodule Towerops.Snmp.Profiles.Vendors.#{module_name} do @moduledoc \"\"\" SNMP profile for #{String.capitalize(vendor)} devices. Auto-generated from YAML profiles. Add vendor-specific OIDs as needed. \"\"\" @behaviour Towerops.Snmp.Profiles.Vendors.Vendor alias Towerops.Snmp.Client alias Towerops.Snmp.Profiles.Vendors.Vendor #{enterprise_comment} @impl true def profile_names, do: #{profiles_list} @impl true def detect_hardware(client_opts) do case Client.get(client_opts, "1.3.6.1.2.1.1.1.0") do {:ok, descr} when is_binary(descr) -> parse_hardware_from_descr(descr) _ -> nil end end defp parse_hardware_from_descr(descr) do # TODO: Add vendor-specific hardware detection # For now, return first line or word from sysDescr descr |> String.split(~r/[\\r\\n]/) |> List.first() |> case do nil -> nil line -> String.slice(line, 0, 64) end end @impl true def wireless_oid_defs do [ # Standard HOST-RESOURCES-MIB CPU %{ oid: "1.3.6.1.2.1.25.3.3.1.2.1", sensor_descr: "CPU Load", sensor_type: "load", sensor_unit: "%", sensor_divisor: 1 } # TODO: Add vendor-specific OIDs ] end @impl true def discover_wireless_sensors(client_opts) do Vendor.fetch_sensors(wireless_oid_defs(), client_opts) end end """ end end