This commit is contained in:
2026-07-14 22:50:46 +04:00
parent 27143319e3
commit bd19e0682b
3116 changed files with 467189 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

Binary file not shown.

View File

@@ -0,0 +1,16 @@
# Changelog
## 0.2.0 (2025-03-04)
* Support multiple DNS queries
## 0.1.3 (2024-02-02)
* Support OTP 24
## 0.1.2 (2024-01-08)
* Use `:inet_res.getbyname/2` to resolve the given hostname to support search list for host-name lookup, such as in k8s and similar setups
## 0.1.1 (2023-09-27)
* Fix bug where an empty clauses would raise an argument error
## 0.1.0 (2023-07-11)
* Initial release

View File

@@ -0,0 +1,22 @@
# MIT License
Copyright (c) 2023 Chris McCord
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,45 @@
# DNSCluster
Simple DNS clustering for distributed Elixir nodes.
## Installation
The package can be installed by adding `dns_cluster` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:dns_cluster, "~> 0.1.1"}
]
end
```
Next, you can configure and start the cluster by adding it to your supervision
tree in your `application.ex`:
```elixir
children = [
{Phoenix.PubSub, ...},
{DNSCluster, query: Application.get_env(:my_app, :dns_cluster_query) || :ignore},
MyAppWeb.Endpoint
]
```
If you are deploying with Elixir releases, the release must be set to support longnames and
the node must be named. These can be set in your `rel/env.sh.eex` file:
```sh
#!/bin/sh
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE="myapp@fully-qualified-host-or-ip"
```
By default, nodes from the same release will have the same cookie. If you want different
applications or releases to connect to each other, then you must set the `RELEASE_COOKIE`,
either in your deployment platform or inside `rel/env.sh.eex`:
```sh
#!/bin/sh
...
export RELEASE_COOKIE="my-app-cookie"
```

View File

@@ -0,0 +1,13 @@
{<<"links">>,
[{<<"GitHub">>,<<"https://github.com/phoenixframework/dns_cluster">>}]}.
{<<"name">>,<<"dns_cluster">>}.
{<<"version">>,<<"0.2.0">>}.
{<<"description">>,<<"Simple DNS clustering for distributed Elixir nodes">>}.
{<<"elixir">>,<<"~> 1.11">>}.
{<<"files">>,
[<<"lib">>,<<"lib/dns_cluster.ex">>,<<"CHANGELOG.md">>,<<"LICENSE.md">>,
<<"mix.exs">>,<<"README.md">>,<<".formatter.exs">>]}.
{<<"app">>,<<"dns_cluster">>}.
{<<"licenses">>,[<<"MIT">>]}.
{<<"requirements">>,[]}.
{<<"build_tools">>,[<<"mix">>]}.

View File

@@ -0,0 +1,243 @@
defmodule DNSCluster do
@moduledoc """
Simple DNS based cluster discovery.
A DNS query is made every `:interval` milliseconds to discover new ips.
## Default node discovery
Nodes will only be joined if their node basename matches the basename of the current node.
For example, if `node()` is `myapp-123@fdaa:1:36c9:a7b:198:c4b1:73c6:1`, it will try to connect
to every IP from the DNS query with `Node.connect/1`. But this will only work if the remote node
has the same basename, like `myapp-123@fdaa:1:36c9:a7b:198:c4b1:73c6:2`. If the remote node's
basename is different, the nodes will not connect.
## Specifying remote basenames
If you want to connect to nodes with different basenames, use a tuple with the basename and query.
For example, to connect to a node named `remote`, use `{"remote", "remote-app.internal"}`.
## Multiple queries
Sometimes you might want to cluster apps with different domain names. Just pass a list of queries
for this. For instance: `["app-one.internal", "app-two.internal", {"other-basename", "other.internal"}]`.
Remember, all nodes need to share the same secret cookie to connect successfully.
## Examples
To start in your supervision tree, add the child:
children = [
...,
{DNSCluster, query: "myapp.internal"}
]
See the `start_link/1` docs for all available options.
If you require more advanced clustering options and strategies, see the
[libcluster](https://hexdocs.pm/libcluster) library.
"""
use GenServer
require Logger
defmodule Resolver do
@moduledoc false
require Record
Record.defrecord(:hostent, Record.extract(:hostent, from_lib: "kernel/include/inet.hrl"))
def basename(node_name) when is_atom(node_name) do
[basename, _] = String.split(to_string(node_name), "@")
basename
end
def connect_node(node_name) when is_atom(node_name), do: Node.connect(node_name)
def list_nodes, do: Node.list(:visible)
def lookup(query, type) when is_binary(query) and type in [:a, :aaaa] do
case :inet_res.getbyname(~c"#{query}", type) do
{:ok, hostent(h_addr_list: addr_list)} -> addr_list
{:error, _} -> []
end
end
end
@doc ~S"""
Starts DNS based cluster discovery.
## Options
* `:name` - the name of the cluster. Defaults to `DNSCluster`.
* `:query` - the required DNS query for node discovery, for example:
`"myapp.internal"` or `["foo.internal", "bar.internal"]`. If the basename
differs between nodes, a tuple of `{basename, query}` can be provided as well.
The value `:ignore` can be used to ignore starting the DNSCluster.
* `:interval` - the millisec interval between DNS queries. Defaults to `5000`.
* `:connect_timeout` - the millisec timeout to allow discovered nodes to connect.
Defaults to `10_000`.
## Examples
iex> DNSCluster.start_link(query: "myapp.internal")
{:ok, pid}
iex> DNSCluster.start_link(query: :ignore)
:ignore
"""
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@impl true
def init(opts) do
case Keyword.fetch(opts, :query) do
{:ok, :ignore} ->
:ignore
{:ok, query} ->
if valid_query?(query) do
warn_on_invalid_dist()
resolver = Keyword.get(opts, :resolver, Resolver)
state = %{
interval: Keyword.get(opts, :interval, 5_000),
basename: resolver.basename(node()),
query: List.wrap(query),
log: Keyword.get(opts, :log, false),
poll_timer: nil,
connect_timeout: Keyword.get(opts, :connect_timeout, 10_000),
resolver: resolver
}
{:ok, state, {:continue, :discover_ips}}
else
raise ArgumentError,
"expected :query to be a string, {basename, query}, or list, got: #{inspect(query)}"
end
:error ->
raise ArgumentError, "missing required :query option in #{inspect(opts)}"
end
end
@impl true
def handle_continue(:discover_ips, state) do
{:noreply, do_discovery(state)}
end
@impl true
def handle_info(:discover_ips, state) do
{:noreply, do_discovery(state)}
end
defp do_discovery(state) do
state
|> connect_new_nodes()
|> schedule_next_poll()
end
defp connect_new_nodes(%{resolver: resolver, connect_timeout: timeout} = state) do
node_names = for name <- resolver.list_nodes(), into: MapSet.new(), do: to_string(name)
ips = discover_ips(state)
_results =
ips
|> Enum.map(fn {basename, ip} -> "#{basename}@#{ip}" end)
|> Enum.filter(fn node_name -> !Enum.member?(node_names, node_name) end)
|> Task.async_stream(
fn new_name ->
if resolver.connect_node(:"#{new_name}") do
log(state, "#{node()} connected to #{new_name}")
end
end,
max_concurrency: max(1, length(ips)),
timeout: timeout
)
|> Enum.to_list()
state
end
defp log(state, msg) do
if level = state.log, do: Logger.log(level, msg)
end
defp schedule_next_poll(state) do
%{state | poll_timer: Process.send_after(self(), :discover_ips, state.interval)}
end
defp discover_ips(%{resolver: resolver, query: queries} = state) do
[:a, :aaaa]
|> Enum.flat_map(fn type ->
Enum.flat_map(queries, fn query ->
{basename, query} =
case query do
{basename, query} ->
# use the user-specified basename
{basename, query}
query when is_binary(query) ->
# no basename specified, use host basename
{state.basename, query}
end
for addr <- resolver.lookup(query, type) do
{basename, addr}
end
end)
end)
|> Enum.uniq()
|> Enum.map(fn {basename, addr} -> {basename, to_string(:inet.ntoa(addr))} end)
end
defp valid_query?(list) do
list
|> List.wrap()
|> Enum.all?(fn
string when is_binary(string) -> true
{basename, query} when is_binary(basename) and is_binary(query) -> true
_ -> false
end)
end
defp warn_on_invalid_dist do
release? = is_binary(System.get_env("RELEASE_NAME"))
net_state = if function_exported?(:net_kernel, :get_state, 0), do: :net_kernel.get_state()
cond do
!net_state ->
:ok
net_state.started == :no and release? ->
Logger.warning("""
node not running in distributed mode. Ensure the following exports are set in your rel/env.sh.eex file:
#!/bin/sh
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE="myapp@fully-qualified-host-or-ip"
""")
net_state.started == :no or
(!release? and net_state.started != :no and net_state[:name_domain] != :longnames) ->
Logger.warning("""
node not running in distributed mode. When running outside of a release, you must start net_kernel manually with
longnames.
https://www.erlang.org/doc/man/net_kernel.html#start-2
""")
net_state[:name_domain] != :longnames and release? ->
Logger.warning("""
node not running with longnames which are required for DNS discovery.
Ensure the following exports are set in your rel/env.sh.eex file:
#!/bin/sh
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE="myapp@fully-qualified-host-or-ip"
""")
true ->
:ok
end
end
end

View File

@@ -0,0 +1,39 @@
defmodule DNSCluster.MixProject do
use Mix.Project
@version "0.2.0"
@scm_url "https://github.com/phoenixframework/dns_cluster"
def project do
[
app: :dns_cluster,
package: package(),
version: @version,
elixir: "~> 1.11",
start_permanent: Mix.env() == :prod,
deps: deps(),
source_url: @scm_url,
homepage_url: @scm_url,
description: "Simple DNS clustering for distributed Elixir nodes"
]
end
defp package do
[
maintainers: ["Chris McCord"],
licenses: ["MIT"],
links: %{"GitHub" => @scm_url},
files: ~w(lib CHANGELOG.md LICENSE.md mix.exs README.md .formatter.exs)
]
end
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[{:ex_doc, ">= 0.0.0", only: :docs}]
end
end