Capture Mix.env() at compile time via module attribute since Mix is not available at runtime in production releases.
708 lines
20 KiB
Elixir
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
|