total rebase
[anni] / test / support / cluster.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Cluster do
6   @moduledoc """
7   Facilities for managing a cluster of slave VM's for federated testing.
8
9   ## Spawning the federated cluster
10
11   `spawn_cluster/1` spawns a map of slave nodes that are started
12   within the running VM. During startup, the slave node is sent all configuration
13   from the parent node, as well as all code. After receiving configuration and
14   code, the slave then starts all applications currently running on the parent.
15   The configuration passed to `spawn_cluster/1` overrides any parent application
16   configuration for the provided OTP app and key. This is useful for customizing
17   the Ecto database, Phoenix webserver ports, etc.
18
19   For example, to start a single federated VM named ":federated1", with the
20   Pleroma Endpoint running on port 4123, and with a database named
21   "pleroma_test1", you would run:
22
23     endpoint_conf = Application.fetch_env!(:pleroma, Pleroma.Web.Endpoint)
24     repo_conf = Application.fetch_env!(:pleroma, Pleroma.Repo)
25
26     Pleroma.Cluster.spawn_cluster(%{
27       :"federated1@127.0.0.1" => [
28         {:pleroma, Pleroma.Repo, Keyword.merge(repo_conf, database: "pleroma_test1")},
29         {:pleroma, Pleroma.Web.Endpoint,
30         Keyword.merge(endpoint_conf, http: [port: 4011], url: [port: 4011], server: true)}
31       ]
32     })
33
34   *Note*: application configuration for a given key is not merged,
35   so any customization requires first fetching the existing values
36   and merging yourself by providing the merged configuration,
37   such as above with the endpoint config and repo config.
38
39   ## Executing code within a remote node
40
41   Use the `within/2` macro to execute code within the context of a remote
42   federated node. The code block captures all local variable bindings from
43   the parent's context and returns the result of the expression after executing
44   it on the remote node. For example:
45
46       import Pleroma.Cluster
47
48       parent_value = 123
49
50       result =
51         within :"federated1@127.0.0.1" do
52           {node(), parent_value}
53         end
54
55       assert result == {:"federated1@127.0.0.1, 123}
56
57   *Note*: while local bindings are captured and available within the block,
58   other parent contexts like required, aliased, or imported modules are not
59   in scope. Those will need to be reimported/aliases/required within the block
60   as `within/2` is a remote procedure call.
61   """
62
63   @extra_apps Pleroma.Mixfile.application()[:extra_applications]
64
65   @doc """
66   Spawns the default Pleroma federated cluster.
67
68   Values before may be customized as needed for the test suite.
69   """
70   def spawn_default_cluster do
71     endpoint_conf = Application.fetch_env!(:pleroma, Pleroma.Web.Endpoint)
72     repo_conf = Application.fetch_env!(:pleroma, Pleroma.Repo)
73
74     spawn_cluster(%{
75       :"federated1@127.0.0.1" => [
76         {:pleroma, Pleroma.Repo, Keyword.merge(repo_conf, database: "pleroma_test_federated1")},
77         {:pleroma, Pleroma.Web.Endpoint,
78          Keyword.merge(endpoint_conf, http: [port: 4011], url: [port: 4011], server: true)}
79       ],
80       :"federated2@127.0.0.1" => [
81         {:pleroma, Pleroma.Repo, Keyword.merge(repo_conf, database: "pleroma_test_federated2")},
82         {:pleroma, Pleroma.Web.Endpoint,
83          Keyword.merge(endpoint_conf, http: [port: 4012], url: [port: 4012], server: true)}
84       ]
85     })
86   end
87
88   @doc """
89   Spawns a configured map of federated nodes.
90
91   See `Pleroma.Cluster` module documentation for details.
92   """
93   def spawn_cluster(node_configs) do
94     # Turn node into a distributed node with the given long name
95     :net_kernel.start([:"primary@127.0.0.1"])
96
97     # Allow spawned nodes to fetch all code from this node
98     {:ok, _} = :erl_boot_server.start([])
99     allow_boot("127.0.0.1")
100
101     silence_logger_warnings(fn ->
102       node_configs
103       |> Enum.map(&Task.async(fn -> start_slave(&1) end))
104       |> Enum.map(&Task.await(&1, 90_000))
105     end)
106   end
107
108   @doc """
109   Executes block of code again remote node.
110
111   See `Pleroma.Cluster` module documentation for details.
112   """
113   defmacro within(node, do: block) do
114     quote do
115       rpc(unquote(node), unquote(__MODULE__), :eval_quoted, [
116         unquote(Macro.escape(block)),
117         binding()
118       ])
119     end
120   end
121
122   @doc false
123   def eval_quoted(block, binding) do
124     {result, _binding} = Code.eval_quoted(block, binding, __ENV__)
125     result
126   end
127
128   defp start_slave({node_host, override_configs}) do
129     log(node_host, "booting federated VM")
130
131     {:ok, node} =
132       do_start_slave(%{host: "127.0.0.1", name: node_name(node_host), args: vm_args()})
133
134     add_code_paths(node)
135     load_apps_and_transfer_configuration(node, override_configs)
136     ensure_apps_started(node)
137     {:ok, node}
138   end
139
140   def rpc(node, module, function, args) do
141     :rpc.block_call(node, module, function, args)
142   end
143
144   defp vm_args do
145     ~c"-loader inet -hosts 127.0.0.1 -setcookie #{:erlang.get_cookie()}"
146   end
147
148   defp allow_boot(host) do
149     {:ok, ipv4} = :inet.parse_ipv4_address(~c"#{host}")
150     :ok = :erl_boot_server.add_slave(ipv4)
151   end
152
153   defp add_code_paths(node) do
154     rpc(node, :code, :add_paths, [:code.get_path()])
155   end
156
157   defp load_apps_and_transfer_configuration(node, override_configs) do
158     Enum.each(Application.loaded_applications(), fn {app_name, _, _} ->
159       app_name
160       |> Application.get_all_env()
161       |> Enum.each(fn {key, primary_config} ->
162         rpc(node, Application, :put_env, [app_name, key, primary_config, [persistent: true]])
163       end)
164     end)
165
166     Enum.each(override_configs, fn {app_name, key, val} ->
167       rpc(node, Application, :put_env, [app_name, key, val, [persistent: true]])
168     end)
169   end
170
171   defp log(node, msg), do: IO.puts("[#{node}] #{msg}")
172
173   defp ensure_apps_started(node) do
174     loaded_names = Enum.map(Application.loaded_applications(), fn {name, _, _} -> name end)
175     app_names = @extra_apps ++ (loaded_names -- @extra_apps)
176
177     rpc(node, Application, :ensure_all_started, [:mix])
178     rpc(node, Mix, :env, [Mix.env()])
179     rpc(node, __MODULE__, :prepare_database, [])
180
181     log(node, "starting application")
182
183     Enum.reduce(app_names, MapSet.new(), fn app, loaded ->
184       if Enum.member?(loaded, app) do
185         loaded
186       else
187         {:ok, started} = rpc(node, Application, :ensure_all_started, [app])
188         MapSet.union(loaded, MapSet.new(started))
189       end
190     end)
191   end
192
193   @doc false
194   def prepare_database do
195     log(node(), "preparing database")
196     repo_config = Application.get_env(:pleroma, Pleroma.Repo)
197     repo_config[:adapter].storage_down(repo_config)
198     repo_config[:adapter].storage_up(repo_config)
199
200     {:ok, _, _} =
201       Ecto.Migrator.with_repo(Pleroma.Repo, fn repo ->
202         Ecto.Migrator.run(repo, :up, log: false, all: true)
203       end)
204
205     Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, :manual)
206     {:ok, _} = Application.ensure_all_started(:ex_machina)
207   end
208
209   defp silence_logger_warnings(func) do
210     prev_level = Logger.level()
211     Logger.configure(level: :error)
212     res = func.()
213     Logger.configure(level: prev_level)
214
215     res
216   end
217
218   defp node_name(node_host) do
219     node_host
220     |> to_string()
221     |> String.split("@")
222     |> Enum.at(0)
223     |> String.to_atom()
224   end
225
226   defp do_start_slave(%{host: host, name: name, args: args} = opts) do
227     peer_module = Application.get_env(__MODULE__, :peer_module)
228
229     if peer_module == :peer do
230       peer_module.start(opts)
231     else
232       peer_module.start(host, name, args)
233     end
234   end
235 end