- Fix doctests for Accounts, Agents.Stats, Snmp to match actual behavior - Fix dynamic_extra_test vendor post-processing tests to seed sensor data - Fix activity_controller_test to seed devices for feed data - Fix session_manager_test to create browser session for test - Fix topology_test link creation for connection test - Fix device_monitor/driver_worker tests for unique job constraints - Fix accounts_test expired_tokens assertion (magic link token is expired) - Fix happy_path_test and show_events_test to seed monitor data - Fix admin user_live_test user.name -> user.email (no name field) - Fix schema_test to seed activity data - Fix mobile_qr_live_test to match actual template text - Fix SnmpKit.MIB doctests and tests for enriched return values - Fix onboarding_live, mobile_controller, mib_test weak assertions - Remove dead code and fix credo warnings
345 lines
10 KiB
Elixir
345 lines
10 KiB
Elixir
defmodule SnmpKit.SnmpMgr.Bulk do
|
|
@moduledoc """
|
|
Advanced SNMP bulk operations using SNMPv2c GETBULK.
|
|
|
|
This module provides efficient bulk operations that are significantly faster
|
|
than iterative GETNEXT requests for retrieving large amounts of data.
|
|
"""
|
|
|
|
alias SnmpKit.SnmpMgr.Config
|
|
alias SnmpKit.SnmpMgr.Core
|
|
alias SnmpKit.SnmpMgr.Format
|
|
|
|
require Logger
|
|
|
|
@default_max_repetitions 30
|
|
@default_non_repeaters 0
|
|
|
|
@doc """
|
|
Performs a single GETBULK request.
|
|
|
|
Returns enriched varbind maps (same standardized shape as other SnmpMgr APIs).
|
|
|
|
## Parameters
|
|
- `target` - The target device
|
|
- `oids` - Single OID or list of OIDs to retrieve
|
|
- `opts` - Options including :max_repetitions, :non_repeaters
|
|
|
|
"""
|
|
def get_bulk(target, oids, opts \\ []) do
|
|
# Check if user explicitly specified a version other than v2c
|
|
case Keyword.get(opts, :version) do
|
|
:v1 ->
|
|
{:error, {:unsupported_operation, :get_bulk_requires_v2c}}
|
|
|
|
:v3 ->
|
|
{:error, {:unsupported_operation, :get_bulk_requires_v2c}}
|
|
|
|
_ ->
|
|
oids_list = if is_list(oids), do: oids, else: [oids]
|
|
|
|
with {:ok, resolved_oids} <- resolve_oids(oids_list) do
|
|
perform_bulk_request(target, resolved_oids, opts)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp perform_bulk_request(target, resolved_oids, opts) do
|
|
# For multiple OIDs, use non_repeaters to get single values for some
|
|
non_repeaters = Keyword.get(opts, :non_repeaters, @default_non_repeaters)
|
|
max_repetitions = Keyword.get(opts, :max_repetitions, @default_max_repetitions)
|
|
|
|
bulk_opts =
|
|
opts
|
|
|> Keyword.put(:non_repeaters, non_repeaters)
|
|
|> Keyword.put(:max_repetitions, max_repetitions)
|
|
|> Keyword.put(:version, :v2c)
|
|
|
|
# Use the first OID as the starting point for GETBULK
|
|
starting_oid = hd(resolved_oids)
|
|
|
|
case Core.send_get_bulk_request(target, starting_oid, bulk_opts) do
|
|
{:ok, results} ->
|
|
merged_opts = Config.merge_opts(opts)
|
|
{:ok, Format.enrich_varbinds(results, merged_opts)}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Optimized table retrieval using GETBULK.
|
|
|
|
Returns enriched varbind maps for each entry, e.g.:
|
|
{:ok, [%{oid: "1.3.6.1...", oid_list: [...], type: :octet_string, value: "eth0", name: ..., formatted: ...}, ...]}
|
|
|
|
Uses GETBULK to efficiently retrieve an entire SNMP table,
|
|
automatically handling pagination when tables are larger than max_repetitions.
|
|
|
|
## Parameters
|
|
- `target` - The target device
|
|
- `table_oid` - The table OID to retrieve
|
|
- `opts` - Options including :max_repetitions, :max_entries
|
|
|
|
|
|
{:ok, [
|
|
{"1.3.6.1.2.1.2.2.1.2.1", "eth0"},
|
|
{"1.3.6.1.2.1.2.2.1.3.1", 6},
|
|
{"1.3.6.1.2.1.2.2.1.2.2", "eth1"},
|
|
{"1.3.6.1.2.1.2.2.1.3.2", 6}
|
|
]}
|
|
"""
|
|
def get_table_bulk(target, table_oid, opts \\ []) do
|
|
max_entries = Keyword.get(opts, :max_entries, 1000)
|
|
|
|
case resolve_oid(table_oid) do
|
|
{:ok, start_oid} ->
|
|
case bulk_walk_table(target, start_oid, start_oid, [], max_entries, opts) do
|
|
{:ok, results} ->
|
|
merged_opts = Config.merge_opts(opts)
|
|
{:ok, Format.enrich_varbinds(results, merged_opts)}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Bulk walk operation using GETBULK instead of iterative GETNEXT.
|
|
|
|
Returns enriched varbind maps for each entry (same shape as other APIs).
|
|
|
|
Significantly more efficient than traditional walks for large subtrees.
|
|
|
|
## Parameters
|
|
- `target` - The target device
|
|
- `root_oid` - Starting OID for the walk
|
|
- `opts` - Options including :max_repetitions, :max_entries
|
|
|
|
|
|
{:ok, [
|
|
{"1.3.6.1.2.1.1.1.0", "System Description"},
|
|
{"1.3.6.1.2.1.1.2.0", "1.3.6.1.4.1.9"},
|
|
{"1.3.6.1.2.1.1.3.0", 12345}
|
|
]}
|
|
"""
|
|
def walk_bulk(target, root_oid, opts \\ []) do
|
|
max_entries = Keyword.get(opts, :max_entries, 1000)
|
|
|
|
case resolve_oid(root_oid) do
|
|
{:ok, start_oid} ->
|
|
case bulk_walk_subtree(target, start_oid, start_oid, [], max_entries, opts) do
|
|
{:ok, results} ->
|
|
merged_opts = Config.merge_opts(opts)
|
|
{:ok, Format.enrich_varbinds(results, merged_opts)}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Performs multiple concurrent GETBULK operations.
|
|
|
|
## Parameters
|
|
- `targets_and_oids` - List of {target, oid} tuples
|
|
- `opts` - Options for all requests
|
|
|
|
|
|
[
|
|
{:ok, [{"1.3.6.1.2.1.1.1.0", "Device 1"}]},
|
|
{:ok, [{"1.3.6.1.2.1.1.3.0", 123456}]},
|
|
{:error, :timeout}
|
|
]
|
|
"""
|
|
def get_bulk_multi(targets_and_oids, opts \\ []) do
|
|
timeout = Keyword.get(opts, :timeout, 10_000)
|
|
|
|
tasks =
|
|
Enum.map(targets_and_oids, fn {target, oid} ->
|
|
Task.async(fn ->
|
|
get_bulk(target, oid, opts)
|
|
end)
|
|
end)
|
|
|
|
tasks
|
|
|> Task.yield_many(timeout)
|
|
|> Enum.map(fn {_task, result} ->
|
|
case result do
|
|
{:ok, value} -> value
|
|
nil -> {:error, :timeout}
|
|
{:exit, reason} -> {:error, {:task_failed, reason}}
|
|
end
|
|
end)
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp bulk_walk_table(target, current_oid, root_oid, acc, remaining, opts) when remaining > 0 do
|
|
max_repetitions =
|
|
min(remaining, Keyword.get(opts, :max_repetitions, @default_max_repetitions))
|
|
|
|
bulk_opts =
|
|
opts
|
|
|> Keyword.put(:max_repetitions, max_repetitions)
|
|
|> Keyword.put(:version, :v2c)
|
|
|
|
case Core.send_get_bulk_request(target, current_oid, bulk_opts) do
|
|
{:ok, results} ->
|
|
# Filter results that are still within the table scope
|
|
{in_scope, next_oid} = filter_table_results(results, root_oid)
|
|
|
|
if Enum.empty?(in_scope) or next_oid == nil do
|
|
{:ok, Enum.reverse(acc)}
|
|
else
|
|
new_acc = Enum.reverse(in_scope, acc)
|
|
bulk_walk_table(target, next_oid, root_oid, new_acc, remaining - length(in_scope), opts)
|
|
end
|
|
|
|
{:error, _} = error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defp bulk_walk_table(_target, _current_oid, _root_oid, acc, 0, _opts) do
|
|
{:ok, Enum.reverse(acc)}
|
|
end
|
|
|
|
defp bulk_walk_subtree(target, current_oid, root_oid, acc, remaining, opts) when remaining > 0 do
|
|
max_repetitions =
|
|
min(remaining, Keyword.get(opts, :max_repetitions, @default_max_repetitions))
|
|
|
|
bulk_opts =
|
|
opts
|
|
|> Keyword.put(:max_repetitions, max_repetitions)
|
|
|> Keyword.put(:version, :v2c)
|
|
|
|
# Debug logging for walk_multi issue investigation
|
|
Logger.debug(
|
|
"bulk_walk_subtree: current_oid=#{inspect(current_oid)}, remaining=#{remaining}, max_rep=#{max_repetitions}"
|
|
)
|
|
|
|
case Core.send_get_bulk_request(target, current_oid, bulk_opts) do
|
|
{:ok, results} ->
|
|
Logger.debug("bulk_walk_subtree: got #{length(results)} raw results")
|
|
|
|
# Filter results that are still within the subtree scope
|
|
{in_scope, next_oid} = filter_subtree_results(results, root_oid)
|
|
|
|
Logger.debug("bulk_walk_subtree: #{length(in_scope)} in_scope, next_oid=#{inspect(next_oid)}")
|
|
|
|
Logger.debug("bulk_walk_subtree: in_scope OIDs: #{inspect(Enum.map(in_scope, fn {oid, _, _} -> oid end))}")
|
|
|
|
if Enum.empty?(in_scope) or next_oid == nil do
|
|
Logger.debug(
|
|
"bulk_walk_subtree: stopping - empty_in_scope=#{Enum.empty?(in_scope)}, next_oid_nil=#{next_oid == nil}"
|
|
)
|
|
|
|
{:ok, Enum.reverse(acc)}
|
|
else
|
|
new_acc = Enum.reverse(in_scope, acc)
|
|
|
|
Logger.debug("bulk_walk_subtree: continuing with #{length(new_acc)} total results so far")
|
|
|
|
bulk_walk_subtree(
|
|
target,
|
|
next_oid,
|
|
root_oid,
|
|
new_acc,
|
|
remaining - length(in_scope),
|
|
opts
|
|
)
|
|
end
|
|
|
|
{:error, _} = error ->
|
|
Logger.debug("bulk_walk_subtree: error - #{inspect(error)}")
|
|
error
|
|
end
|
|
end
|
|
|
|
defp bulk_walk_subtree(_target, _current_oid, _root_oid, acc, 0, _opts) do
|
|
{:ok, Enum.reverse(acc)}
|
|
end
|
|
|
|
defp filter_table_results(results, root_oid) do
|
|
in_scope_results =
|
|
Enum.filter(results, fn
|
|
# Only accept 3-tuple format with proper type information
|
|
{oid_list, _type, _value} ->
|
|
starts_with = List.starts_with?(oid_list, root_oid)
|
|
|
|
Logger.debug("filter: checking #{inspect(oid_list)} starts_with #{inspect(root_oid)} = #{starts_with}")
|
|
|
|
starts_with
|
|
|
|
# Reject 2-tuple format - type information must be preserved
|
|
{_oid_list, _value} ->
|
|
Logger.debug("filter: rejecting 2-tuple format")
|
|
false
|
|
end)
|
|
|
|
# Keep OIDs as lists internally - conversion to strings happens at final output
|
|
|
|
# Fix: next_oid should be derived from last in_scope result, not last overall result
|
|
# and we need to increment it properly for the next GETBULK request
|
|
next_oid =
|
|
case List.last(in_scope_results) do
|
|
{oid_list, _type, _value} ->
|
|
# For bulk operations, the next OID should be the last successful OID
|
|
# The SNMP agent will return the next available OIDs from this point
|
|
Logger.debug("filter: next_oid from last in_scope: #{inspect(oid_list)}")
|
|
oid_list
|
|
|
|
_ ->
|
|
# If no in_scope results, try to get next_oid from last overall result
|
|
case List.last(results) do
|
|
{oid_list, _type, _value} ->
|
|
Logger.debug("filter: next_oid from last overall: #{inspect(oid_list)}")
|
|
oid_list
|
|
|
|
{_oid_list, _value} ->
|
|
nil
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
Logger.debug("filter: returning #{length(in_scope_results)} in_scope, next_oid=#{inspect(next_oid)}")
|
|
|
|
{in_scope_results, next_oid}
|
|
end
|
|
|
|
defp filter_subtree_results(results, root_oid) do
|
|
filter_table_results(results, root_oid)
|
|
end
|
|
|
|
defp resolve_oids(oids) do
|
|
resolved =
|
|
oids
|
|
|> Enum.map(&Core.parse_oid/1)
|
|
|> Enum.reduce_while({:ok, []}, fn
|
|
{:ok, oid}, {:ok, acc} -> {:cont, {:ok, [oid | acc]}}
|
|
error, _acc -> {:halt, error}
|
|
end)
|
|
|
|
case resolved do
|
|
{:ok, oid_list} -> {:ok, Enum.reverse(oid_list)}
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
defp resolve_oid(oid), do: Core.parse_oid(oid)
|
|
|
|
# Type information must never be inferred - it must be preserved from SNMP responses
|
|
# Removing type inference functions to prevent loss of critical type information
|
|
end
|