defmodule Lamina do @moduledoc """ Lamina allows you to define a run-time configuration pipeline that can merge configuration from several sources. This allows the system to be reactive to changes in its environment. ## Example The following example defines a configuration for an imaginary HTTP server application which takes it's configuration from a combination of default values, the OTP application environment and system environment variables: ```elixir defmodule MyHttpServer.Config do use Lamina provider(Lamina.Provider.Default, listen_port: 4000, listen_address: "0.0.0.0") provider(Lamina.Provider.ApplicationEnv, otp_app: :my_http_server, key: MyHttpServer.Endpoint) provider(Lamina.Provider.Env, prefix: "HTTP") config :listen_port do cast(&Lamina.Cast.to_integer/1) validate(fn port when is_integer(port) and (port in [80, 443] or port >= 1000) -> true _ -> false end) end config :listen_address do validate(fn address when is_binary(address) -> address |> String.to_charlist() |> :inet.parse_address() |> case do {:ok, _} -> true _ -> false end _ -> false end) end end ``` Provider order is preserved, such that providers added later (via the `provider/1` or `provider/2` macro) have more priority than their predecessors. This has the effect that when more than one provider can provide a value for a given configuration item, the most preferred value will be returned. Each configuration item is defined using the `config/1` or `config/2` macro. If the configuration item does not need casting to another type, nor validation then just defining it with `config/1` is sufficient. In some cases it is necessary to provide additional casting or validating functions. They can be provided by passing a block containing the `cast/1` or `validate/1` macros. Make sure that you add your configuration module to your application's supervisor tree **before** any processes that rely on it's information. Lamina will fail to start or shutdown on any errors it encounters. ## Lifetimes All configuration items in Lamina are explicitly marked with a lifetime, which must be specified by the configuration provider when returning values. The semantics are as follows: - `:volatile` - a configuration that could potentially be different every time it is read. Volatile configuration items are returned by the `ApplicationEnv` and `Env` providers. - `:static` - a configuration value that is not going to change until the provider changes it. Static configuration items are returned by the `Default` provider, but could also be used for a configuration provider that notifies the system of configuration changes in some way. - `{non_neg_integer(), System.time_unit()}` - a value that has a specific expiry time. This may be used for a configuration source that has explicit leases on values (ala [Vault](https://www.vaultproject.io/) or a value for which querying is expensive, and providing an expiry would effectively cache it. ## Querying When asked to retrieve a configuration value, Lamina queries it's ETS table using the following query plan; values for which there is no expiry, or which have not yet expired, ordered by provider weight, descending. It only ever returns a single row. If the returned row is marked as `:volatile` then the configuration provider is immediately queried for a new value, meaning that these requests will pay the cost of a `GenServer.call/3` to ensure freshness. If this is an issue then you should consider changing the provider lifetime to use an expiry. The `ApplicationEnv` and `Env` providers have a configuration option to do this. If you are the developer of a volatile provider, it is strongly suggested that you provide for this use case. ## Server configuration The following options can be passed to the `use Lamina` macro, although it's probably advisable to leave them as their defaults. - `gc_timeout: pos_integer()` - how long the server should be idle before removing expired configuration from the ETS table in milliseconds. Defaults to 3000. - `ttl_refresh_fraction: float` - when presented with a configuration value which has an expiry, the server queues a refresh at some point prior to the value expiring, in order to avoid having missing configuration. Setting this to a value between `0` and `1` specifies the proportion of the expiry time to wait before attempting to refresh the value. Defaults to `0.95`. ## Configuration subscriptions Lamina defines a `subscribe/1` and `unsubscribe/1` function on each configuration module, which uses a `Registry` to handle pub-sub for configuration changes. This allows your processes to subscribe to configuration changes and update or restart any services they provide. ### Example For example, a simple HTTP server which changes it's listen port in response to a configuration change: ```elixir defmodule MyHttpServer.Cowboy do use GenServer alias Plug.Cowboy alias MyHttpServer.{Config, Plug} def init(_) do with {:ok, port} <- Config.listen_port(), {:ok, srv} <- Cowboy.http(Plug, [], port: port), :ok <- Config.subscribe(:listen_port) do {:ok, %{srv: srv, port: port}} end end def handle_info({:config_change, Config, :listen_port, _old_port, new_port}, %{ port: current_port }) when new_port != current_port do with :ok <- Cowboy.shutdown(Plug.HTTP), {:ok, srv} <- Cowboy.http(Plug, [], port: new_port) do {:noreply, %{port: new_port, srv: srv}} else {:error, reason} -> {:stop, reason, nil} end end def handle_info({:config_change, _, _, _}, state), do: {:noreply, state} end ``` """ @spec __using__(keyword) :: Macro.t() defmacro __using__(opts) do quote do Module.register_attribute(__MODULE__, :configs, accumulate: true) Module.register_attribute(__MODULE__, :providers, accumulate: true) import Lamina.DSL @before_compile Lamina.DSL @behaviour Lamina @doc false @spec start_link(keyword) :: GenServer.on_start_link() def start_link(keyword) do GenServer.start_link(Lamina.Server, [__MODULE__ | unquote(opts)]) end @doc false @spec child_spec(keyword) :: Supervisor.child_spec() def child_spec(_opts) do %{ id: {Lamina.Server, __MODULE__}, start: {__MODULE__, :start_link, [unquote(opts)]}, restart: :permanent, type: :worker } end @doc false @spec config_change(atom, any, any) :: :ok def config_change(config_key, old_value, new_value), do: :ok @doc ~s""" Subscribe the calling process to notifications about configuration changes. Messages in the form of `{:config_change, #{inspect(__MODULE__)}, config_key, old_value, new_value}` will be sent to the receiving process when a configuration change is detected. """ @spec subscribe(atom) :: :ok def subscribe(config_key) do # credo:disable-for-next-line Credo.Check.Design.AliasUsage Lamina.Registry.PubSubRegistry.subscribe(__MODULE__, config_key) end @doc """ Unsubscribe the calling process from configuration change notifications. """ @spec unsubscribe(atom) :: :ok def unsubscribe(config_key) do # credo:disable-for-next-line Credo.Check.Design.AliasUsage Lamina.Registry.PubSubRegistry.unsubscribe(__MODULE__, config_key) end defoverridable config_change: 3, start_link: 1, child_spec: 1 end end @doc false @callback __lamina__(atom) :: [] @doc """ Called when the `Lamina.Server` detects a configuration change. This is a simple mechanism to allow the server to notify your system that a configuration value has changed. See also `subscribe/1`. """ @callback config_change(config_key, old_value, new_value) :: :ok when config_key: atom, old_value: any, new_value: any end