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