towerops/lib/snmpkit/snmp_lib/config.ex
Graham McIntire 719ac1eb0f
Fix Mix.env() runtime crash in releases
Capture Mix.env() at compile time via module attribute since Mix is
not available at runtime in production releases.
2026-02-15 17:16:48 -06:00

708 lines
20 KiB
Elixir

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