From 214233c388cc581a74bc99eeb94c5ff69a8a8273 Mon Sep 17 00:00:00 2001 From: Guilherme Balena Versiani Date: Wed, 22 Jun 2022 15:14:10 +0000 Subject: [PATCH] Initial commit. --- .formatter.exs | 4 + .gitignore | 38 ++++++ README.md | 21 +++ lib/etcd_ex.ex | 270 ++++++++++++++++++++++++++++++++++++++ lib/etcd_ex/connection.ex | 88 +++++++++++++ mix.exs | 27 ++++ mix.lock | 5 + test/test_helper.exs | 1 + 8 files changed, 454 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lib/etcd_ex.ex create mode 100644 lib/etcd_ex/connection.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd2e7b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +etcdex-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +### Vim +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Temporary files +.netrwhist +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..95ec3e2 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# EtcdEx + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `etcdex` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:etcdex, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/lib/etcd_ex.ex b/lib/etcd_ex.ex new file mode 100644 index 0000000..1c3495a --- /dev/null +++ b/lib/etcd_ex.ex @@ -0,0 +1,270 @@ +defmodule EtcdEx do + @moduledoc """ + This module provides interface to Etcd. + """ + + @type conn :: EtcdEx.Connection.t() + @type key :: String.t() + @type value :: String.t() + @type range_end :: String.t() + @type lease :: integer + @type limit :: non_neg_integer + @type revision :: pos_integer + @type sort :: {sort_target, sort_order} + @type sort_target :: :KEY | :VERSION | :VALUE | :CREATE | :MOD + @type sort_order :: :NONE | :ASCEND | :DESCEND + + @type get_opts :: [get_opt] + @type get_opt :: + {:range_end, range_end} + | {:prefix, boolean} + | {:from_key, boolean} + | {:limit, limit} + | {:revision, revision} + | {:sort, sort} + | {:serializable, boolean} + | {:keys_only, boolean} + | {:count_only, boolean} + | {:min_mod_revision, revision} + | {:max_mod_revision, revision} + | {:min_create_revision, revision} + | {:max_create_revision, revision} + | {:timeout, timeout} + + @type put_opts :: [put_opt] + @type put_opt :: + {:lease, lease} + | {:prev_kv, boolean} + | {:ignore_value, boolean} + | {:ignore_lease, boolean} + | {:timeout, timeout} + + @type delete_opts :: [delete_opt] + @type delete_opt :: + {:range_end, range_end} + | {:prefix, boolean} + | {:from_key, boolean} + | {:prev_kv, boolean} + | {:timeout, timeout} + + @doc """ + Gets one or range of key-value pairs from Etcd. + """ + def get(conn, key, opts \\ []) when is_atom(conn) and is_binary(key) and is_list(opts) do + request = + conn + |> :eetcd_kv.new() + |> :eetcd_kv.with_key(key) + |> build_get_opts(opts) + + EtcdEx.Connection.get(conn, request) + end + + defp build_get_opts(req, []), do: req + + defp build_get_opts(req, [{:range_end, range_end} | opts]) do + req + |> :eetcd_kv.with_range_end(range_end) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:prefix, true} | opts]) do + req + |> :eetcd_kv.with_prefix() + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:prefix, _} | opts]), do: build_get_opts(req, opts) + + defp build_get_opts(req, [{:from_key, true} | opts]) do + req + |> :eetcd_kv.with_from_key() + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:from_key, _} | opts]), do: build_get_opts(req, opts) + + defp build_get_opts(req, [{:limit, limit} | opts]) do + req + |> :eetcd_kv.with_limit(limit) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:lease, lease} | opts]) do + req + |> :eetcd_kv.with_lease(lease) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:revision, revision} | opts]) do + req + |> :eetcd_kv.with_rev(revision) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:sort, {sort_target, sort_order}} | opts]) do + req + |> :eetcd_kv.with_sort(sort_target, sort_order) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:serializable, true} | opts]) do + req + |> :eetcd_kv.with_serializable() + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:serializable, _} | opts]), do: build_get_opts(req, opts) + + defp build_get_opts(req, [{:keys_only, true} | opts]) do + req + |> :eetcd_kv.with_keys_only() + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:keys_only, _} | opts]), do: build_get_opts(req, opts) + + defp build_get_opts(req, [{:count_only, true} | opts]) do + req + |> :eetcd_kv.with_count_only() + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:count_only, _} | opts]), do: build_get_opts(req, opts) + + defp build_get_opts(req, [{:min_mod_revision, revision} | opts]) do + req + |> :eetcd_kv.with_min_mod_rev(revision) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:max_mod_revision, revision} | opts]) do + req + |> :eetcd_kv.with_max_mod_rev(revision) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:min_create_revision, revision} | opts]) do + req + |> :eetcd_kv.with_min_create_rev(revision) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:max_create_revision, revision} | opts]) do + req + |> :eetcd_kv.with_max_create_rev(revision) + |> build_get_opts(opts) + end + + defp build_get_opts(req, [{:timeout, timeout} | opts]) do + req + |> :eetcd_kv.with_timeout(timeout) + |> build_get_opts(opts) + end + + @doc """ + Puts a key-value pair to Etcd. + """ + @spec put(conn, key, value, put_opts) :: {:ok, any} | {:error, any} + def put(conn, key, value, opts \\ []) + when is_atom(conn) and is_binary(key) and is_binary(value) and is_list(opts) do + request = + conn + |> :eetcd_kv.new() + |> :eetcd_kv.with_key(key) + |> :eetcd_kv.with_value(value) + |> build_put_opts(opts) + + EtcdEx.Connection.put(conn, request) + end + + defp build_put_opts(req, []), do: req + + defp build_put_opts(req, [{:lease, lease} | opts]) do + req + |> :eetcd_kv.with_lease(lease) + |> build_put_opts(opts) + end + + defp build_put_opts(req, [{:prev_kv, true} | opts]) do + req + |> :eetcd_kv.with_prev_kv() + |> build_put_opts(opts) + end + + defp build_put_opts(req, [{:prev_kv, _} | opts]), do: build_put_opts(req, opts) + + defp build_put_opts(req, [{:ignore_value, true} | opts]) do + req + |> :eetcd_kv.with_ignore_value() + |> build_put_opts(opts) + end + + defp build_put_opts(req, [{:ignore_value, _} | opts]), do: build_put_opts(req, opts) + + defp build_put_opts(req, [{:ignore_lease, true} | opts]) do + req + |> :eetcd_kv.with_ignore_lease() + |> build_put_opts(opts) + end + + defp build_put_opts(req, [{:ignore_lease, _} | opts]), do: build_put_opts(req, opts) + + defp build_put_opts(req, [{:timeout, timeout} | opts]) do + req + |> :eetcd_kv.with_timeout(timeout) + |> build_get_opts(opts) + end + + @doc """ + Deletes a key-value pair from Etcd. + """ + @spec delete(conn, key, delete_opts) :: {:ok, any} | {:error, any} + def delete(conn, key, opts \\ []) do + request = + conn + |> :eetcd_kv.new() + |> :eetcd_kv.with_key(key) + |> build_delete_opts(opts) + + EtcdEx.Connection.delete(conn, request) + end + + defp build_delete_opts(req, []), do: req + + defp build_delete_opts(req, [{:range_end, range_end} | opts]) do + req + |> :eetcd_kv.with_range_end(range_end) + |> build_delete_opts(opts) + end + + defp build_delete_opts(req, [{:prefix, true} | opts]) do + req + |> :eetcd_kv.with_prefix() + |> build_delete_opts(opts) + end + + defp build_delete_opts(req, [{:prefix, _} | opts]), do: build_delete_opts(req, opts) + + defp build_delete_opts(req, [{:from_key, true} | opts]) do + req + |> :eetcd_kv.with_from_key() + |> build_delete_opts(opts) + end + + defp build_delete_opts(req, [{:from_key, _} | opts]), do: build_delete_opts(req, opts) + + defp build_delete_opts(req, [{:prev_kv, true} | opts]) do + req + |> :eetcd_kv.with_prev_kv() + |> build_delete_opts(opts) + end + + defp build_delete_opts(req, [{:prev_kv, _} | opts]), do: build_delete_opts(req, opts) + + defp build_delete_opts(req, [{:timeout, timeout} | opts]) do + req + |> :eetcd_kv.with_timeout(timeout) + |> build_get_opts(opts) + end +end diff --git a/lib/etcd_ex/connection.ex b/lib/etcd_ex/connection.ex new file mode 100644 index 0000000..a86013a --- /dev/null +++ b/lib/etcd_ex/connection.ex @@ -0,0 +1,88 @@ +defmodule EtcdEx.Connection do + @moduledoc """ + Represents a connection to Etcd. + """ + + use GenServer + + @type t :: atom + + @default_eetcd_options [ + retry: 1, + retry_timeout: :timer.seconds(5), + connect_timeout: :timer.seconds(15), + auto_sync_start_ms: 100, + auto_sync_interval_ms: :timer.seconds(5) + ] + @default_eetcd_transport :tcp + @default_eetcd_transport_opts [] + + @doc false + def get(conn, request), do: GenServer.call(conn, {:get, request}, :infinity) + + @doc false + def put(conn, request), do: GenServer.call(conn, {:put, request}, :infinity) + + @doc false + def delete(conn, request), do: GenServer.call(conn, {:delete, request}, :infinity) + + @doc false + def start_link(options) do + name = + case Keyword.fetch(options, :name) do + {:ok, name} when is_atom(name) -> + name + + {:ok, other} -> + raise ArgumentError, "expected :name to be an atom, got: #{inspect(other)}" + + :error -> + raise ArgumentError, "expected :name option to be present" + end + + endpoints = + case Keyword.fetch(options, :endpoints) do + {:ok, endpoints} when is_list(endpoints) -> + Enum.map(endpoints, &String.to_charlist/1) + + {:ok, other} -> + raise ArgumentError, + "expected :endpoints to be a list of strings, got: #{inspect(other)}" + + :error -> + ['localhost:2379'] + end + + options = Keyword.get(options, :options, @default_eetcd_options) + transport = Keyword.get(options, :transport, @default_eetcd_transport) + transport_opts = Keyword.get(options, :transport_opts, @default_eetcd_transport_opts) + + init_opts = {name, endpoints, options, transport, transport_opts} + GenServer.start_link(__MODULE__, init_opts, name: name) + end + + @impl true + def init({name, endpoints, options, transport, transport_opts}) do + case :eetcd.open(name, endpoints, options, transport, transport_opts) do + {:ok, pid} -> + {:ok, pid} + + {:error, reason} -> + {:stop, reason} + end + end + + @impl true + def handle_call({method, request}, _from, state) when method in [:get, :put, :delete] do + result = apply(:eetcd_kv, method, [request]) + + {:reply, result, state} + end + + @impl true + def handle_info(_, state) do + # XXX: this is required by Gun, as sometimes it keeps sending messages even + # though the request has already finished (specially on error conditions). + {:noreply, state} + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..1552f72 --- /dev/null +++ b/mix.exs @@ -0,0 +1,27 @@ +defmodule EtcdEx.MixProject do + use Mix.Project + + def project do + [ + app: :etcdex, + version: "0.1.0", + elixir: "~> 1.13", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:eetcd, github: "balena/eetcd", tag: "master"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..4819ff6 --- /dev/null +++ b/mix.lock @@ -0,0 +1,5 @@ +%{ + "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, + "eetcd": {:git, "https://github.com/balena/eetcd.git", "530ab2a32a54fc3ce807090044295db57f686175", [tag: "master"]}, + "gun": {:hex, :gun, "1.3.3", "cf8b51beb36c22b9c8df1921e3f2bc4d2b1f68b49ad4fbc64e91875aa14e16b4", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "3106ce167f9c9723f849e4fb54ea4a4d814e3996ae243a1c828b256e749041e0"}, +} diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()