towerops/lib/snmpkit/snmp_lib/config.ex
Graham McIntire de075ea8d9
Store billing subscriber/MRR data on integrations for dashboard
Each billing sync (Gaiia, Splynx, VISP, Sonar) already computes
subscriber counts and MRR but only stored them in sync messages.
Now persists them on the integrations table so the dashboard can
aggregate across all billing providers instead of only querying
Gaiia network sites (which were never populated).

Also fixes SnmpKit Config environment detection to use Mix.env()
as fallback when MIX_ENV env var is not set.
2026-02-15 16:51:33 -06:00

706 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
# 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
Code.ensure_loaded?(Mix) -> Mix.env()
true -> :dev
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