2022-01-24 16:53:04 +01:00
|
|
|
defmodule HAHandler.HAProxy do
|
2022-01-25 09:45:11 +01:00
|
|
|
@moduledoc """
|
|
|
|
Interface to HAProxy's runtime API:
|
|
|
|
https://www.haproxy.com/documentation/hapee/latest/api/runtime-api/
|
|
|
|
"""
|
|
|
|
|
|
|
|
# TODO: can we keep the socket open by keeping the port state in a GenServer
|
|
|
|
# and abusing the prompt mode of HAProxy's socket somehow? The later is
|
|
|
|
# somewhat clunky, but I'm not sure if there is a way around it since HAProxy
|
|
|
|
# seems to return EOF once a command is executed (unless prompt mode is
|
|
|
|
# enabled).
|
|
|
|
|
|
|
|
import HAHandler, only: [haproxy_socket: 0]
|
|
|
|
|
2022-01-24 16:53:04 +01:00
|
|
|
alias :procket, as: Socket
|
|
|
|
|
2022-01-25 09:45:11 +01:00
|
|
|
# How long do we wait for an answer - in milliseconds.
|
|
|
|
@socket_read_timeout 5_000
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Sends a command to HAProxy's socket - please refer to HAProxy's runtime API
|
|
|
|
documentation for available commands (see moduledoc).
|
|
|
|
|
|
|
|
This method will either return an answer with `{:ok, answer}` or error with
|
|
|
|
`{:error, err}`.
|
|
|
|
"""
|
|
|
|
def execute(command) when is_binary(command) do
|
|
|
|
case open_socket(haproxy_socket()) do
|
2022-02-23 17:44:56 +01:00
|
|
|
{:ok, fd, port} ->
|
|
|
|
send(port, {self(), {:command, command <> "\n"}})
|
|
|
|
data = read_from_socket(port)
|
|
|
|
# We need to be careful and always close file descriptors, as they can
|
|
|
|
# run out - which will block all operations.
|
|
|
|
close_socket(fd)
|
|
|
|
data
|
2022-02-23 17:53:00 +01:00
|
|
|
|
2022-01-25 09:45:11 +01:00
|
|
|
{:error, err} ->
|
|
|
|
{:error, err}
|
|
|
|
end
|
|
|
|
end
|
2022-01-24 16:53:04 +01:00
|
|
|
|
2022-02-18 17:52:38 +01:00
|
|
|
defp extract_stats(data) do
|
2022-02-23 17:53:00 +01:00
|
|
|
extracted =
|
|
|
|
for entry <- data do
|
|
|
|
for mapping <- entry do
|
|
|
|
case mapping do
|
|
|
|
%{
|
|
|
|
"id" => id,
|
|
|
|
"proxyId" => proxy_id,
|
|
|
|
"objType" => type,
|
|
|
|
"field" => %{"name" => name},
|
|
|
|
"value" => %{"value" => value}
|
|
|
|
} ->
|
|
|
|
%{:id => id, :proxy_id => proxy_id, :type => type, :field => name, :value => value}
|
|
|
|
|
|
|
|
_ ->
|
|
|
|
nil
|
|
|
|
end
|
2022-02-18 17:52:38 +01:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
extracted
|
|
|
|
|> List.flatten()
|
|
|
|
|> Enum.group_by(
|
2022-02-21 10:42:03 +01:00
|
|
|
fn mapping -> {mapping.type, mapping.id, mapping.proxy_id} end,
|
2022-02-18 17:52:38 +01:00
|
|
|
fn mapping -> %{mapping.field => mapping.value} end
|
|
|
|
)
|
2022-02-23 17:53:00 +01:00
|
|
|
|> Enum.map(fn {{type, id, proxy_id}, grouped_mappings} ->
|
|
|
|
grouped_mappings
|
|
|
|
|> Enum.reduce(fn l, r -> Map.merge(l, r) end)
|
|
|
|
|> Map.put("type", type)
|
|
|
|
|> Map.put("id", id)
|
|
|
|
|> Map.put("proxy_id", proxy_id)
|
|
|
|
end)
|
|
|
|
|> Enum.group_by(fn entry -> Map.get(entry, "type") end)
|
2022-02-18 17:52:38 +01:00
|
|
|
end
|
|
|
|
|
2022-01-25 09:45:11 +01:00
|
|
|
@doc """
|
|
|
|
Executes and parse the output of the `show stats` HAProxy command, returning
|
|
|
|
a list of Maps.
|
|
|
|
"""
|
2022-02-21 09:31:24 +01:00
|
|
|
def get_stats(opts \\ [])
|
2022-02-23 17:53:00 +01:00
|
|
|
|
|
|
|
def get_stats(hide_error: true) do
|
2022-02-21 09:31:24 +01:00
|
|
|
case get_stats() do
|
|
|
|
{:error, _err} ->
|
|
|
|
%{
|
|
|
|
"Frontend" => %{},
|
|
|
|
"Backend" => %{},
|
|
|
|
"Server" => %{}
|
|
|
|
}
|
2022-02-23 17:53:00 +01:00
|
|
|
|
|
|
|
stats ->
|
|
|
|
stats
|
2022-02-21 09:31:24 +01:00
|
|
|
end
|
|
|
|
end
|
2022-02-23 17:53:00 +01:00
|
|
|
|
2022-02-21 09:31:24 +01:00
|
|
|
def get_stats(_opts) do
|
2022-01-25 09:45:11 +01:00
|
|
|
case execute("show stat json") do
|
|
|
|
{:ok, raw} ->
|
|
|
|
case Poison.decode(raw) do
|
|
|
|
{:ok, data} ->
|
2022-02-18 17:52:38 +01:00
|
|
|
extract_stats(data)
|
2022-01-24 16:53:04 +01:00
|
|
|
|
2022-01-25 09:45:11 +01:00
|
|
|
{:error, err} ->
|
|
|
|
{:error, err}
|
|
|
|
end
|
|
|
|
|
|
|
|
{:error, err} ->
|
|
|
|
{:error, err}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2022-02-21 11:44:59 +01:00
|
|
|
@doc """
|
|
|
|
Set a server's properties, as per [1].
|
|
|
|
|
|
|
|
[1] https://www.haproxy.com/documentation/hapee/latest/api/runtime-api/set-server/
|
|
|
|
"""
|
|
|
|
def set_server(backend, server, key, value) do
|
|
|
|
case execute("set server #{backend}/#{server} #{key} #{value}") do
|
|
|
|
{:ok, ""} ->
|
|
|
|
:ok
|
2022-02-23 17:53:00 +01:00
|
|
|
|
2022-02-21 11:44:59 +01:00
|
|
|
{:ok, err} ->
|
|
|
|
{:error, err}
|
2022-02-23 17:53:00 +01:00
|
|
|
|
2022-02-21 11:44:59 +01:00
|
|
|
{:error, err} ->
|
|
|
|
{:error, err}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2022-01-25 09:45:11 +01:00
|
|
|
# Opens an UNIX socket - there is no built-in support in Elixir/Erlang so we
|
|
|
|
# use low-level C bindings provided by :procket
|
|
|
|
# (https://github.com/msantos/procket).
|
|
|
|
#
|
|
|
|
# Heavily inspired by the `NVim.Link` module:
|
|
|
|
# https://github.com/kbrw/neovim-elixir/blob/master/lib/link.ex
|
|
|
|
defp open_socket(path) do
|
|
|
|
family = Socket.family(:unix)
|
|
|
|
type = :stream
|
|
|
|
# 0 means use the default protocol in the family.
|
|
|
|
protocol = 0
|
|
|
|
|
|
|
|
pad = 8 * (Socket.unix_path_max() - byte_size(path))
|
|
|
|
sockaddr = Socket.sockaddr_common(family, byte_size(path)) <> path <> <<0::size(pad)>>
|
|
|
|
|
|
|
|
{:ok, socket} = Socket.socket(family, type, protocol)
|
2022-01-24 16:53:04 +01:00
|
|
|
|
|
|
|
case Socket.connect(socket, sockaddr) do
|
|
|
|
:ok ->
|
2022-01-25 09:45:11 +01:00
|
|
|
# We connect our UNIX socket to an erlang Port:
|
|
|
|
#
|
|
|
|
# > Ports provide a mechanism to start operating system processes
|
|
|
|
# > external to the Erlang VM and communicate with them via message
|
|
|
|
# > passing.
|
|
|
|
# See https://hexdocs.pm/elixir/Port.html for details.
|
|
|
|
#
|
|
|
|
# See https://www.erlang.org/doc/man/erlang.html#open_port-2 for more
|
|
|
|
# port options.
|
|
|
|
#
|
|
|
|
# * :binary: all I/O from the port is binary data objects as opposed to
|
|
|
|
# lists of bytes.
|
|
|
|
# * Messages are delivered on a per line basis. Each line (delimited by
|
|
|
|
# the OS-dependent newline sequence) is delivered in a single message.
|
|
|
|
# The message data format is {Flag, Line}, where Flag is eol or noeol,
|
|
|
|
# and Line is the data delivered (without the newline sequence).
|
|
|
|
{fdin, fdout} = {socket, socket}
|
|
|
|
port = Port.open({:fd, fdin, fdout}, [{:line, 10_000}, :binary])
|
2022-01-24 16:53:04 +01:00
|
|
|
|
2022-02-23 17:44:56 +01:00
|
|
|
{:ok, socket, port}
|
2022-01-24 16:53:04 +01:00
|
|
|
|
|
|
|
{:error, err} ->
|
|
|
|
{:error, err}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2022-02-23 17:44:56 +01:00
|
|
|
# Close UNIX fd opened with `open_socket/1`.
|
|
|
|
defp close_socket(fd), do: Socket.close(fd)
|
|
|
|
|
2022-01-25 09:45:11 +01:00
|
|
|
# Messages may be split due to the `{:line, L}` option specific in
|
|
|
|
# `open_socket/1`.
|
2022-01-24 16:53:04 +01:00
|
|
|
defp read_from_socket(socket, acc \\ "") do
|
|
|
|
receive do
|
|
|
|
{_port, {:data, {:noeol, data}}} ->
|
|
|
|
read_from_socket(socket, acc <> data)
|
|
|
|
|
2022-01-25 09:45:11 +01:00
|
|
|
{_port, {:data, {:eol, data}}} ->
|
|
|
|
{:ok, acc <> data}
|
2022-01-24 16:53:04 +01:00
|
|
|
|
2022-01-25 09:45:11 +01:00
|
|
|
msg ->
|
|
|
|
{:error, {:unexpected_message, msg}}
|
|
|
|
after
|
|
|
|
@socket_read_timeout ->
|
|
|
|
{:error, :timeout}
|
2022-01-24 16:53:04 +01:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|