towerops/lib/mix/tasks/gen_vendor_modules.ex
2026-01-22 11:51:35 -06:00

374 lines
10 KiB
Elixir

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