diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c79d0d..373f8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## 0.4.0 - 2021-04-19 ### Fixed - Updated security guide to use new configuration style +### Added + +- Added the operations API + - `Spear.merge_indexes/2` + - `Spear.resign_node/2` + - `Spear.restart_persistent_subscriptions/2` + - `Spear.set_node_priority/3` + - `Spear.shutdown/2` + - `Spear.start_scavenge/2` + - `Spear.stop_scavenge/3` + - and associated wrappers in `Spear.Client` + ## 0.3.0 - 2021-04-18 ### Added diff --git a/lib/spear.ex b/lib/spear.ex index 84c5709..ff16e4d 100644 --- a/lib/spear.ex +++ b/lib/spear.ex @@ -1216,4 +1216,322 @@ defmodule Spear do |> div(10) |> DateTime.from_unix(:microsecond) end + + @doc """ + Requests that a scavenge be started + + Scavenges are disk-space reclaiming operations run on the EventStoreDB + server. + + ## Options + + * `:thread_count` - (default: `1`) the number of threads to use for the + scavenge process. Scavenging can be resource intensive. Setting this to + a low thread count can lower the impact on the server's resources. + * `:start_from_chunk` - (default: `0`) the chunk number to start the + scavenge from. Generally this is only useful if a prior scavenge has + failed on a certain chunk. + + Remaining options are passed to `request/5`. + + ## Examples + + iex> Spear.start_scavenge(conn) + {:ok, + %Spear.Scavenge{id: "d2897ba8-2f0c-4fc4-bb25-798ba75f3562", result: :Started}} + """ + @doc since: "0.4.0" + @doc api: :operations + @spec start_scavenge(connection :: Spear.Connection.t(), opts :: Keyword.t()) :: + {:ok, Spear.Scavenge.t()} | {:error, any()} + def start_scavenge(conn, opts \\ []) do + import Spear.Records.Operations + + opts = + [ + thread_count: 1, + start_from_chunk: 0 + ] + |> Keyword.merge(opts) + + message = + start_scavenge_req( + options: + start_scavenge_req_options( + thread_count: opts[:thread_count], + start_from_chunk: opts[:start_from_chunk] + ) + ) + + case request( + conn, + Spear.Records.Operations, + :StartScavenge, + [message], + Keyword.take(opts, [:timeout, :credentials]) + ) do + {:ok, scavenge_resp() = resp} -> + {:ok, Spear.Scavenge.from_scavenge_resp(resp)} + + # coveralls-ignore-start + error -> + error + # coveralls-ignore-stop + end + end + + @doc """ + Produces the scavenge stream for a scavenge ID + + `start_scavenge/2` begins an asynchronous scavenge operation since scavenges + may be time consuming. In order to check the progress of a running scavenge, + one may read the scavenge stream with `read_stream/3` or `stream!/3` or + subscribe to updates on the scavenge with `subscribe/4`. + + ## Examples + + iex> {:ok, scavenge} = Spear.start_scavenge(conn) + {:ok, + %Spear.Scavenge{id: "d2897ba8-2f0c-4fc4-bb25-798ba75f3562", result: :Started}} + iex> Spear.scavenge_stream(scavenge) + "$scavenges-d2897ba8-2f0c-4fc4-bb25-798ba75f3562" + """ + @doc since: "0.4.0" + @doc api: :utils + @spec scavenge_stream(scavenge :: String.t() | Spear.Scavenge.t()) :: String.t() + def scavenge_stream(%Spear.Scavenge{id: scavenge_id}), do: scavenge_stream(scavenge_id) + def scavenge_stream(scavenge_id) when is_binary(scavenge_id), do: "$scavenges-" <> scavenge_id + + @doc """ + Stops a running scavenge + + ## Options + + All options are passed to `request/5`. + + ## Examples + + iex> {:ok, scavenge} = Spear.start_scavenge(conn) + iex> Spear.stop_scavenge(conn, scavenge.id) + {:ok, + %Spear.Scavenge{id: "d2897ba8-2f0c-4fc4-bb25-798ba75f3562", result: :Stopped}} + """ + @doc since: "0.4.0" + @doc api: :operations + @spec stop_scavenge( + connection :: Spear.Connection.t(), + scavenge_id :: String.t(), + opts :: Keyword.t() + ) :: {:ok, Spear.Scavenge.t()} | {:error, any()} + def stop_scavenge(conn, scavenge_id, opts \\ []) + + def stop_scavenge(conn, scavenge_id, opts) when is_binary(scavenge_id) do + import Spear.Records.Operations + + message = stop_scavenge_req(options: stop_scavenge_req_options(scavenge_id: scavenge_id)) + + case request(conn, Spear.Records.Operations, :StopScavenge, [message], opts) do + # coveralls-ignore-start + {:ok, scavenge_resp() = resp} -> {:ok, Spear.Scavenge.from_scavenge_resp(resp)} + # coveralls-ignore-stop + error -> error + end + end + + @doc """ + Shuts down the connected EventStoreDB + + The user performing the shutdown (either the connection credentials or + credentials passed by the `:credentials` option) must at least be in the + `$ops` group. `$admins` permissions are a superset of `$ops`. + + ## Options + + Options are passed to `request/5`. + + ## Examples + + iex> Spear.shutdown(conn) + :ok + iex> Spear.ping(conn) + {:error, :closed} + + iex> Spear.shutdown(conn, credentials: {"some_non_ops_user", "changeit"}) + {:error, + %Spear.Grpc.Response{ + data: "", + message: "Access Denied", + status: :permission_denied, + status_code: 7 + }} + iex> Spear.ping(conn) + :pong + """ + @doc since: "0.4.0" + @doc api: :operations + @spec shutdown(connection :: Spear.Connection.t(), opts :: Keyword.t()) :: :ok | {:error, any()} + def shutdown(conn, opts \\ []) do + import Spear.Records.Shared, only: [empty: 0] + + case request(conn, Spear.Records.Operations, :Shutdown, [empty()], opts) do + {:ok, empty()} -> :ok + error -> error + end + end + + @doc """ + Requests that the indices be merged + + + + See the EventStoreDB documentation for more information. + + A user does not need to be in `$ops` or any group to initiate this request. + + ## Options + + Options are passed to `request/5`. + + ## Examples + + iex> Spear.merge_indexes(conn) + :ok + """ + @doc since: "0.4.0" + @doc api: :operations + @spec merge_indexes(connection :: Spear.Connection.t(), opts :: Keyword.t()) :: + :ok | {:error, any()} + def merge_indexes(conn, opts \\ []) do + import Spear.Records.Shared, only: [empty: 0] + + # coveralls-ignore-start + case request(conn, Spear.Records.Operations, :MergeIndexes, [empty()], opts) do + {:ok, empty()} -> + :ok + + error -> + error + # coveralls-ignore-stop + end + end + + @doc """ + Requests that the currently connected node resign its leadership role + + + + See the EventStoreDB documentation for more information. + + A user does not need to be in `$ops` or any group to initiate this request. + + ## Options + + Options are passed to `request/5`. + + ## Examples + + iex> Spear.resign_node(conn) + :ok + """ + @doc since: "0.4.0" + @doc api: :operations + @spec resign_node(connection :: Spear.Connection.t(), opts :: Keyword.t()) :: + :ok | {:error, any()} + def resign_node(conn, opts \\ []) do + import Spear.Records.Shared, only: [empty: 0] + + # coveralls-ignore-start + case request(conn, Spear.Records.Operations, :ResignNode, [empty()], opts) do + {:ok, empty()} -> + :ok + + error -> + error + # coveralls-ignore-stop + end + end + + @doc """ + Sets the node priority number + + + + See the EventStoreDB documentation for more information. + + A user does not need to be in `$ops` or any group to initiate this request. + + ## Options + + Options are passed to `request/5`. + + ## Examples + + iex> Spear.set_node_priority(conn, 1) + :ok + """ + @doc since: "0.4.0" + @doc api: :operations + @spec set_node_priority( + connection :: Spear.Connection.t(), + priority :: integer(), + opts :: Keyword.t() + ) :: :ok | {:error, any()} + # coveralls-ignore-start + def set_node_priority(conn, priority, opts \\ []) + + def set_node_priority(conn, priority, opts) when is_integer(priority) do + import Spear.Records.Shared, only: [empty: 0] + import Spear.Records.Operations + + message = set_node_priority_req(priority: priority) + + case request(conn, Spear.Records.Operations, :SetNodePriority, [message], opts) do + {:ok, empty()} -> + :ok + + error -> + error + # coveralls-ignore-stop + end + end + + @doc """ + Restarts all persistent subscriptions + + See the EventStoreDB documentation for more information. + + A user does not need to be in `$ops` or any group to initiate this request. + + ## Options + + Options are passed to `request/5`. + + ## Examples + + iex> Spear.restart_persistent_subscriptions(conn) + :ok + """ + @doc since: "0.4.0" + @doc api: :operations + @spec restart_persistent_subscriptions(connection :: Spear.Connection.t(), opts :: Keyword.t()) :: + :ok | {:error, any()} + def restart_persistent_subscriptions(conn, opts \\ []) do + import Spear.Records.Shared, only: [empty: 0] + + # coveralls-ignore-start + case request(conn, Spear.Records.Operations, :RestartPersistentSubscriptions, [empty()], opts) do + {:ok, empty()} -> + :ok + + error -> + error + # coveralls-ignore-stop + end + end end diff --git a/lib/spear/client.ex b/lib/spear/client.ex index dfa92ac..655bccb 100644 --- a/lib/spear/client.ex +++ b/lib/spear/client.ex @@ -355,6 +355,96 @@ defmodule Spear.Client do opts :: Keyword.t() ) :: :ok | {:error, any()} + @doc """ + A wrapper around `Spear.merge_indexes/1` + """ + @doc since: "0.4.0" + @callback merge_indexes() :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.merge_indexes/2` + """ + @doc since: "0.4.0" + @callback merge_indexes(opts :: Keyword.t()) :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.resign_node/1` + """ + @doc since: "0.4.0" + @callback resign_node() :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.resign_node/2` + """ + @doc since: "0.4.0" + @callback resign_node(opts :: Keyword.t()) :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.restart_persistent_subscriptions/1` + """ + @doc since: "0.4.0" + @callback restart_persistent_subscriptions() :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.restart_persistent_subscriptions/2` + """ + @doc since: "0.4.0" + @callback restart_persistent_subscriptions(opts :: Keyword.t()) :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.set_node_priority/2` + """ + @doc since: "0.4.0" + @callback set_node_priority(priority :: integer()) :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.set_node_priority/3` + """ + @doc since: "0.4.0" + @callback set_node_priority( + priority :: integer(), + opts :: Keyword.t() + ) :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.shutdown/1` + """ + @doc since: "0.4.0" + @callback shutdown() :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.shutdown/2` + """ + @doc since: "0.4.0" + @callback shutdown(opts :: Keyword.t()) :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.start_scavenge/1` + """ + @doc since: "0.4.0" + @callback start_scavenge() :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.start_scavenge/2` + """ + @doc since: "0.4.0" + @callback start_scavenge(opts :: Keyword.t()) :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.stop_scavenge/2` + """ + @doc since: "0.4.0" + @callback stop_scavenge(scavenge_id :: String.t()) :: :ok | {:error, any()} + + @doc """ + A wrapper around `Spear.stop_scavenge/3` + """ + @doc since: "0.4.0" + @callback stop_scavenge( + scavenge_id :: String.t(), + opts :: Keyword.t() + ) :: :ok | {:error, any()} + @optional_callbacks start_link: 1 defmacro __using__(opts) when is_list(opts) do @@ -461,6 +551,41 @@ defmodule Spear.Client do def user_details(login_name, opts \\ []) do Spear.user_details(__MODULE__, login_name, opts) end + + @impl unquote(__MODULE__) + def merge_indexes(opts \\ []) do + Spear.merge_indexes(__MODULE__, opts) + end + + @impl unquote(__MODULE__) + def resign_node(opts \\ []) do + Spear.resign_node(__MODULE__, opts) + end + + @impl unquote(__MODULE__) + def restart_persistent_subscriptions(opts \\ []) do + Spear.restart_persistent_subscriptions(__MODULE__, opts) + end + + @impl unquote(__MODULE__) + def set_node_priority(priority, opts \\ []) when is_integer(priority) do + Spear.set_node_priority(__MODULE__, priority, opts) + end + + @impl unquote(__MODULE__) + def shutdown(opts \\ []) do + Spear.shutdown(__MODULE__, opts) + end + + @impl unquote(__MODULE__) + def start_scavenge(opts \\ []) do + Spear.start_scavenge(__MODULE__, opts) + end + + @impl unquote(__MODULE__) + def stop_scavenge(scavenge_id, opts \\ []) do + Spear.stop_scavenge(__MODULE__, scavenge_id, opts) + end end end diff --git a/lib/spear/scavenge.ex b/lib/spear/scavenge.ex new file mode 100644 index 0000000..c8e3c6a --- /dev/null +++ b/lib/spear/scavenge.ex @@ -0,0 +1,31 @@ +defmodule Spear.Scavenge do + @moduledoc """ + A struct representing a scavenge and its progress + """ + @moduledoc since: "0.4.0" + + import Spear.Records.Operations, only: [scavenge_resp: 1] + + @typedoc """ + The result of starting or stopping a scavenge + + This structure does not represent the current status of a scavenge. The + scavenge stream (`Spear.scavenge_stream/1`) may be read to determine the + current status of a scavenge. + + ## Examples + + iex> {:ok, scavenge} = Spear.start_scavenge(conn) + {:ok, + %Spear.Scavenge{id: "d2897ba8-2f0c-4fc4-bb25-798ba75f3562", result: :Started}} + """ + @typedoc since: "0.4.0" + @type t :: %__MODULE__{id: String.t(), result: :Started | :InProgress | :Stopped} + + defstruct [:id, :result] + + @doc false + def from_scavenge_resp(scavenge_resp(scavenge_id: id, scavenge_result: result)) do + %__MODULE__{id: id, result: result} + end +end diff --git a/test/spear_test.exs b/test/spear_test.exs index f932e0e..16bd08e 100644 --- a/test/spear_test.exs +++ b/test/spear_test.exs @@ -4,6 +4,7 @@ defmodule SpearTest do @moduletag :capture_log import Spear.Records.Streams, only: [read_resp: 0, read_resp: 1] + import Spear.Event, only: [uuid_v4: 0] # bytes @max_append_bytes 1_048_576 @@ -16,7 +17,9 @@ defmodule SpearTest do [ conn: conn, - stream_name: random_stream_name() + stream_name: random_stream_name(), + user: random_user(), + password: uuid_v4() ] end @@ -144,9 +147,9 @@ defmodule SpearTest do end test "a user can be CRUD-ed", c do - login_name = "spear-test-user-" <> Spear.Event.uuid_v4() - full_name = "Spear Test User (CRUD)" - password = "open sesame" + login_name = c.user.login_name + full_name = c.user.full_name + password = c.password groups = [] assert Spear.create_user(c.conn, full_name, login_name, password, groups) == :ok @@ -175,9 +178,9 @@ defmodule SpearTest do end test "a disabled user cannot read from a stream", c do - login_name = "spear-test-user-" <> Spear.Event.uuid_v4() - full_name = "Spear Test User (CRUD)" - password = "open sesame" + login_name = c.user.login_name + full_name = c.user.full_name + password = c.password groups = [] assert Spear.create_user(c.conn, full_name, login_name, password, groups) == :ok @@ -513,9 +516,9 @@ defmodule SpearTest do end test "you cannot operate on a user that does not exist", c do - login_name = "pichael" - full_name = "Pichael Thompson" - password = "changeit" + login_name = c.user.login_name + full_name = c.user.full_name + password = c.password not_found = %Spear.Grpc.Response{ data: "", @@ -533,10 +536,58 @@ defmodule SpearTest do assert {:error, ^not_found} = Spear.change_user_password(c.conn, login_name, password, password) end + + test "a user not in the `$ops` group cannot shut down the server", c do + assert Spear.create_user( + c.conn, + c.user.full_name, + c.user.login_name, + c.password, + _groups = [] + ) == :ok + + assert {:error, %Spear.Grpc.Response{status: :permission_denied}} = + Spear.shutdown(c.conn, credentials: {c.user.login_name, c.password}) + + assert Spear.ping(c.conn) == :pong + + assert Spear.delete_user(c.conn, c.user.login_name) == :ok + end + + test "a scavenge can be started, followed, and deleted", c do + assert {:ok, %Spear.Scavenge{result: :Started} = scavenge} = Spear.start_scavenge(c.conn) + assert {:ok, sub} = Spear.subscribe(c.conn, self(), Spear.scavenge_stream(scavenge)) + assert_receive %Spear.Event{type: "$scavengeStarted"} + assert_receive %Spear.Event{type: "$scavengeCompleted"} + # cannot stop a scavenge after it is complete, get a not-found error + assert {:error, reason} = Spear.stop_scavenge(c.conn, scavenge.id) + assert reason.status == :not_found + Spear.cancel_subscription(c.conn, sub) + end + + @tag :operations + test "a request to merge indices succeeds", c do + assert Spear.merge_indexes(c.conn) == :ok + end + + @tag :operations + test "a request for the node to resign succeeds", c do + assert Spear.resign_node(c.conn) == :ok + end + + @tag :operations + test "a request to set the node priority succeeds", c do + assert Spear.set_node_priority(c.conn, 1) == :ok + end + + @tag :operations + test "a request to restart persistent subscriptions succeeds", c do + assert Spear.restart_persistent_subscriptions(c.conn) == :ok + end end defp random_stream_name do - "Spear.Test-" <> Spear.Event.uuid_v4() + "Spear.Test-" <> uuid_v4() end defp random_event do @@ -547,6 +598,10 @@ defmodule SpearTest do Stream.iterate(0, &(&1 + 1)) |> Stream.map(&Spear.Event.new("counter-test", &1)) end + defp random_user do + %Spear.User{full_name: "Spear Test User", login_name: "spear-test-#{uuid_v4()}", groups: []} + end + defp maximum_append_size_error do %Spear.Grpc.Response{ data: "", diff --git a/test/test_helper.exs b/test/test_helper.exs index b699be8..cc3573b 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,3 @@ # defaults to 100 & 100 -ExUnit.configure(assert_receive_timeout: 1_000, refute_receive_timeout: 300) +ExUnit.configure(assert_receive_timeout: 1_000, refute_receive_timeout: 300, exclude: :operations) ExUnit.start()