defmodule SnmpKit.SnmpLib.Config do @moduledoc """ Configuration management system for production SNMP deployments. This module provides a flexible, environment-aware configuration system designed for real-world SNMP management applications. Based on patterns proven in large-scale deployments like the DDumb project managing 1000+ devices. ## Features - **Environment-Aware**: Automatic detection of dev/test/prod environments - **Layered Configuration**: Application, environment, and runtime overrides - **Dynamic Updates**: Hot-reload configuration without service restart - **Validation**: Schema validation for all configuration values - **Secrets Management**: Secure handling of sensitive configuration data - **Multi-Tenant Support**: Per-deployment configuration isolation ## Configuration Sources (in order of precedence) 1. Runtime environment variables 2. Configuration files (config/*.exs) 3. Application defaults 4. Module defaults ## Usage Patterns # Get configuration for a specific component pool_config = SnmpKit.SnmpLib.Config.get(:pool, :default_settings) # Get configuration with fallback timeout = SnmpKit.SnmpLib.Config.get(:snmp, :timeout, 5000) # Update configuration at runtime SnmpKit.SnmpLib.Config.put(:pool, :max_size, 50) # Load configuration from file SnmpKit.SnmpLib.Config.load_from_file("/etc/snmp_lib/production.exs") # Validate current configuration {:ok, _} = SnmpKit.SnmpLib.Config.validate() ## Environment Detection The configuration system automatically detects the current environment: - `:dev` - Development with verbose logging and relaxed timeouts - `:test` - Testing with simulated backends and fast timeouts - `:prod` - Production with optimized settings and monitoring - `:staging` - Pre-production environment for integration testing ## Configuration Schema All configuration follows a validated schema to prevent runtime errors: %{ snmp: %{ default_version: :v2c, default_timeout: 5_000, default_retries: 3, default_community: "public" }, pool: %{ default_size: 10, max_overflow: 5, strategy: :fifo, health_check_interval: 30_000 }, monitoring: %{ metrics_enabled: true, prometheus_port: 9090, dashboard_enabled: true, alert_thresholds: %{...} } } """ use GenServer require Logger @config_table :snmp_lib_config @schema_table :snmp_lib_schema @watchers_table :snmp_lib_watchers @default_config %{ snmp: %{ default_version: :v2c, default_timeout: 5_000, default_retries: 3, default_community: "public", default_port: 161, max_message_size: 65_507, socket_options: [], mib_paths: [] }, pool: %{ default_size: 10, max_overflow: 5, strategy: :fifo, health_check_interval: 30_000, checkout_timeout: 5_000, max_idle_time: 300_000 }, monitoring: %{ metrics_enabled: true, prometheus_enabled: false, prometheus_port: 9090, dashboard_enabled: false, log_level: :info, alert_thresholds: %{ error_rate: 0.05, response_time_p95: 2_000, connection_pool_utilization: 0.8 } }, error_handling: %{ max_retries: 3, retry_strategy: :exponential, circuit_breaker_enabled: true, circuit_breaker_threshold: 10, circuit_breaker_timeout: 60_000 }, cache: %{ enabled: true, # 5 minutes default_ttl: 300_000, max_size: 10_000, cleanup_interval: 60_000 } } @type config_key :: atom() | [atom()] @type config_value :: any() @type config_section :: atom() @type environment :: :dev | :test | :prod | :staging ## Public API @doc """ Starts the configuration manager with initial configuration. ## Options - `config_file`: Path to configuration file to load on startup - `environment`: Override automatic environment detection - `validate_on_start`: Validate configuration on startup (default: true) ## Examples {:ok, _pid} = SnmpKit.SnmpLib.Config.start_link() {:ok, _pid} = SnmpKit.SnmpLib.Config.start_link(config_file: "/etc/snmp_lib/prod.exs") """ @spec start_link(keyword()) :: {:ok, pid()} | {:error, any()} def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Gets a configuration value by key path with optional default. ## Parameters - `section`: Configuration section (:snmp, :pool, :monitoring, etc.) - `key`: Specific configuration key or nested key path - `default`: Value to return if key is not found ## Examples # Get a simple value timeout = SnmpKit.SnmpLib.Config.get(:snmp, :default_timeout) # Get nested value threshold = SnmpKit.SnmpLib.Config.get(:monitoring, [:alert_thresholds, :error_rate]) # Get with default community = SnmpKit.SnmpLib.Config.get(:snmp, :community, "public") """ @spec get(config_section(), config_key(), config_value()) :: config_value() def get(section, key, default \\ nil) do case :ets.lookup(@config_table, section) do [{^section, config}] -> get_nested_value(config, key, default) [] -> get_default_value(section, key, default) end end @doc """ Sets a configuration value at runtime. Changes are applied immediately and optionally persisted. ## Examples :ok = SnmpKit.SnmpLib.Config.put(:pool, :default_size, 20) :ok = SnmpKit.SnmpLib.Config.put(:monitoring, [:alert_thresholds, :error_rate], 0.10) """ @spec put(config_section(), config_key(), config_value()) :: :ok | {:error, any()} def put(section, key, value) do GenServer.call(__MODULE__, {:put, section, key, value}) end @doc """ Loads configuration from a file and merges with current config. ## Examples :ok = SnmpKit.SnmpLib.Config.load_from_file("/etc/snmp_lib/production.exs") """ @spec load_from_file(binary()) :: :ok | {:error, any()} def load_from_file(file_path) do GenServer.call(__MODULE__, {:load_file, file_path}) end @doc """ Gets the current environment. ## Examples env = SnmpKit.SnmpLib.Config.environment() # :prod """ @spec environment() :: environment() def environment do GenServer.call(__MODULE__, :get_environment) end @doc """ Validates the current configuration against the schema. ## Returns - `{:ok, config}`: Configuration is valid - `{:error, errors}`: List of validation errors ## Examples case SnmpKit.SnmpLib.Config.validate() do {:ok, _config} -> Logger.info("Configuration is valid") {:error, validation_errors} -> Logger.error("Configuration errors: " <> inspect(validation_errors)) end """ @spec validate() :: {:ok, map()} | {:error, [any()]} def validate do GenServer.call(__MODULE__, :validate) end @doc """ Registers a callback function to be called when configuration changes. ## Examples SnmpKit.SnmpLib.Config.watch(:pool, fn old_config, new_config -> Logger.info("Pool configuration changed") SnmpKit.SnmpLib.Pool.reload_config(new_config) end) """ @spec watch(config_section(), function()) :: :ok def watch(section, callback) when is_function(callback, 2) do GenServer.call(__MODULE__, {:watch, section, callback}) end @doc """ Gets all configuration as a nested map. ## Examples config = SnmpKit.SnmpLib.Config.all() IO.inspect(config.snmp.default_timeout) """ @spec all() :: map() def all do GenServer.call(__MODULE__, :get_all) end @doc """ Reloads configuration from environment and files. ## Examples :ok = SnmpKit.SnmpLib.Config.reload() """ @spec reload() :: :ok | {:error, any()} def reload do GenServer.call(__MODULE__, :reload) end @doc """ Merges user-provided options with default SNMP configuration values. This function provides the default SNMP configuration options that are commonly used across SNMP operations, then merges them with user-provided options. User options take precedence over defaults. ## Default Values - `community`: "public" - `timeout`: 5000 (milliseconds) - `retries`: 3 - `port`: 161 - `version`: :v2c - `mib_paths`: [] ## Parameters - `opts`: Keyword list of user-provided options that override defaults ## Returns Keyword list with defaults merged with user options, where user options take precedence. ## Examples iex> SnmpKit.SnmpLib.Config.merge_opts([]) [community: "public", timeout: 5000, retries: 3, port: 161, version: :v2c, mib_paths: []] iex> result = SnmpKit.SnmpLib.Config.merge_opts([timeout: 10000]) iex> result[:community] "public" iex> result[:timeout] 10000 iex> result[:retries] 3 iex> result = SnmpKit.SnmpLib.Config.merge_opts([community: "private", port: 162]) iex> result[:community] "private" iex> result[:port] 162 iex> result[:timeout] 5000 """ @spec merge_opts(keyword()) :: keyword() def merge_opts(opts) when is_list(opts) do # Get default SNMP values from the configuration system, with static fallbacks defaults = [ community: safe_get(:snmp, :default_community, "public"), timeout: safe_get(:snmp, :default_timeout, 5000), retries: safe_get(:snmp, :default_retries, 3), port: safe_get(:snmp, :default_port, 161), version: safe_get(:snmp, :default_version, :v2c), mib_paths: safe_get(:snmp, :mib_paths, []) ] # Merge defaults with user options, user options take precedence Keyword.merge(defaults, opts) end ## GenServer Implementation @impl GenServer def init(opts) do # Create ETS tables for fast access _ = :ets.new(@config_table, [:named_table, :set, :public, read_concurrency: true]) _ = :ets.new(@schema_table, [:named_table, :set, :public, read_concurrency: true]) _ = :ets.new(@watchers_table, [:named_table, :bag, :public]) # Detect environment environment = detect_environment(opts) # Load initial configuration initial_config = load_initial_config(environment, opts) # Store configuration in ETS store_config(initial_config) # Validate if requested if Keyword.get(opts, :validate_on_start, true) do case validate_config(initial_config) do {:ok, _} -> Logger.info("SnmpKit.SnmpLib.Config started with valid configuration") {:error, errors} -> Logger.warning("SnmpKit.SnmpLib.Config started with validation errors: #{inspect(errors)}") end end state = %{ environment: environment, config_file: Keyword.get(opts, :config_file), watchers: %{} } {:ok, state} end @impl GenServer def handle_call({:put, section, key, value}, _from, state) do case update_config_value(section, key, value) do :ok -> notify_watchers(section) {:reply, :ok, state} {:error, reason} -> {:reply, {:error, reason}, state} end end @impl GenServer def handle_call({:load_file, file_path}, _from, state) do case load_config_file(file_path) do {:ok, file_config} -> merge_config(file_config) {:reply, :ok, state} {:error, reason} -> {:reply, {:error, reason}, state} end end @impl GenServer def handle_call(:get_environment, _from, state) do {:reply, state.environment, state} end @impl GenServer def handle_call(:validate, _from, state) do current_config = get_all_config() result = validate_config(current_config) {:reply, result, state} end @impl GenServer def handle_call({:watch, section, callback}, _from, state) do :ets.insert(@watchers_table, {section, callback}) {:reply, :ok, state} end @impl GenServer def handle_call(:get_all, _from, state) do config = get_all_config() {:reply, config, state} end @impl GenServer def handle_call(:reload, _from, state) do case reload_configuration(state) do {:ok, new_state} -> {:reply, :ok, new_state} {:error, error} -> {:reply, {:error, error}, state} end end ## Private Implementation # Capture Mix.env() at compile time since Mix is not available in releases @compile_env if Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod # Environment detection with multiple fallbacks defp detect_environment(opts) do cond do env = Keyword.get(opts, :environment) -> env env = System.get_env("MIX_ENV") -> String.to_atom(env) env = System.get_env("SNMP_LIB_ENV") -> String.to_atom(env) env = Application.get_env(:snmp_lib, :environment) -> env true -> @compile_env end end # Load configuration from multiple sources defp load_initial_config(environment, opts) do base_config = get_environment_defaults(environment) # Load from application config app_config = :snmp_lib |> Application.get_all_env() |> Map.new() config_with_app = deep_merge(base_config, app_config) # Load from file if specified config_with_file = case Keyword.get(opts, :config_file) do nil -> config_with_app file_path -> case load_config_file(file_path) do {:ok, file_config} -> deep_merge(config_with_app, file_config) {:error, _} -> config_with_app end end # Load from environment variables config_with_env = load_from_environment(config_with_file) config_with_env end # Get environment-specific defaults defp get_environment_defaults(:dev) do @default_config |> put_in([:monitoring, :log_level], :debug) |> put_in([:pool, :health_check_interval], 10_000) end defp get_environment_defaults(:test) do @default_config |> put_in([:snmp, :default_timeout], 1_000) |> put_in([:pool, :default_size], 2) |> put_in([:pool, :health_check_interval], 5_000) |> put_in([:monitoring, :metrics_enabled], false) |> put_in([:cache, :enabled], false) end defp get_environment_defaults(:staging) do @default_config |> put_in([:monitoring, :dashboard_enabled], true) |> put_in([:monitoring, :prometheus_enabled], true) end defp get_environment_defaults(:prod) do @default_config |> put_in([:pool, :default_size], 25) |> put_in([:pool, :max_overflow], 15) |> put_in([:monitoring, :metrics_enabled], true) |> put_in([:monitoring, :dashboard_enabled], true) |> put_in([:monitoring, :prometheus_enabled], true) |> put_in([:monitoring, :log_level], :warning) end defp get_environment_defaults(_), do: @default_config # Load configuration from environment variables defp load_from_environment(config) do # This would parse environment variables like: # SNMP_LIB_POOL_DEFAULT_SIZE=20 # SNMP_LIB_SNMP_DEFAULT_TIMEOUT=10000 # etc. env_config = %{} # For now, just return the config as-is # In a full implementation, this would parse all SNMP_LIB_* environment variables deep_merge(config, env_config) end # Load configuration from file defp load_config_file(file_path) do if File.exists?(file_path) do try do {config, _} = Code.eval_file(file_path) {:ok, config} rescue error -> Logger.error("Failed to load config file #{file_path}: #{inspect(error)}") {:error, error} end else {:error, :file_not_found} end end # Store configuration in ETS tables defp store_config(config) do Enum.each(config, fn {section, section_config} -> :ets.insert(@config_table, {section, section_config}) end) end # Get nested configuration value defp get_nested_value(config, key, default) when is_atom(key) do Map.get(config, key, default) end defp get_nested_value(config, keys, default) when is_list(keys) do get_in(config, keys) || default end # Get default value from static configuration defp get_default_value(section, key, default) do case Map.get(@default_config, section) do nil -> default section_config -> get_nested_value(section_config, key, default) end end # Safe get that works even when GenServer is not running (for doctests) defp safe_get(section, key, default) do get(section, key, default) rescue ArgumentError -> # ETS table doesn't exist, fall back to static config get_default_value(section, key, default) end # Update configuration value in ETS defp update_config_value(section, key, value) do case :ets.lookup(@config_table, section) do [{^section, config}] -> case put_nested_value_safe(config, key, value) do {:ok, updated_config} -> :ets.insert(@config_table, {section, updated_config}) :ok {:error, reason} -> {:error, reason} end [] -> # Create new section case put_nested_value_safe(%{}, key, value) do {:ok, new_config} -> :ets.insert(@config_table, {section, new_config}) :ok {:error, reason} -> {:error, reason} end end rescue ArgumentError -> {:error, :invalid_config_path} error -> {:error, {:unexpected_error, error}} end # Set nested configuration value defp put_nested_value_safe(config, key, value) when is_atom(key) do {:ok, Map.put(config, key, value)} end defp put_nested_value_safe(config, keys, value) when is_list(keys) do {:ok, put_in(config, keys, value)} rescue error -> {:error, {:unexpected_error, error}} end # Get all configuration as map defp get_all_config do @config_table |> :ets.tab2list() |> Map.new() end # Merge configuration maps deeply defp deep_merge(left, right) when is_map(left) and is_map(right) do Map.merge(left, right, fn _key, left_val, right_val -> deep_merge(left_val, right_val) end) end defp deep_merge(_left, right), do: right # Merge new configuration with existing defp merge_config(new_config) do existing_config = get_all_config() merged_config = deep_merge(existing_config, new_config) store_config(merged_config) end # Validate configuration against schema defp validate_config(config) do # Simple validation - in production this would use a proper schema library errors = [] errors = validate_section(config, :snmp, errors) errors = validate_section(config, :pool, errors) errors = validate_section(config, :monitoring, errors) case errors do [] -> {:ok, config} _ -> {:error, errors} end end defp validate_section(config, section, errors) do case Map.get(config, section) do nil -> [{:missing_section, section} | errors] section_config when is_map(section_config) -> errors _ -> [{:invalid_section_type, section} | errors] end end # Notify watchers of configuration changes defp notify_watchers(section) do case :ets.lookup(@watchers_table, section) do [] -> :ok watchers -> old_config = :ets.lookup(@config_table, section) new_config = :ets.lookup(@config_table, section) Enum.each(watchers, fn {^section, callback} -> try do callback.(old_config, new_config) rescue error -> Logger.warning("Configuration watcher failed: #{inspect(error)}") end end) end end defp reload_configuration(state) do # Reload from environment and files new_config = load_initial_config(state.environment, config_file: state.config_file) store_config(new_config) # Notify all watchers Enum.each([:snmp, :pool, :monitoring, :error_handling, :cache], fn section -> notify_watchers(section) end) {:ok, state} rescue error -> {:error, error} end end