towerops/lib/snmpkit/snmp_mgr/bulk.ex
Graham McIntire d1403c8069 Fix failing tests and clean up code
- 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
2026-06-16 14:54:34 -05:00

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