First
[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     {:ok, node} = :slave.start(~c"127.0.0.1", node_name(node_host), vm_args())
131     add_code_paths(node)
132     load_apps_and_transfer_configuration(node, override_configs)
133     ensure_apps_started(node)
134     {:ok, node}
135   end
136
137   def rpc(node, module, function, args) do
138     :rpc.block_call(node, module, function, args)
139   end
140
141   defp vm_args do
142     ~c"-loader inet -hosts 127.0.0.1 -setcookie #{:erlang.get_cookie()}"
143   end
144
145   defp allow_boot(host) do
146     {:ok, ipv4} = :inet.parse_ipv4_address(~c"#{host}")
147     :ok = :erl_boot_server.add_slave(ipv4)
148   end
149
150   defp add_code_paths(node) do
151     rpc(node, :code, :add_paths, [:code.get_path()])
152   end
153
154   defp load_apps_and_transfer_configuration(node, override_configs) do
155     Enum.each(Application.loaded_applications(), fn {app_name, _, _} ->
156       app_name
157       |> Application.get_all_env()
158       |> Enum.each(fn {key, primary_config} ->
159         rpc(node, Application, :put_env, [app_name, key, primary_config, [persistent: true]])
160       end)
161     end)
162
163     Enum.each(override_configs, fn {app_name, key, val} ->
164       rpc(node, Application, :put_env, [app_name, key, val, [persistent: true]])
165     end)
166   end
167
168   defp log(node, msg), do: IO.puts("[#{node}] #{msg}")
169
170   defp ensure_apps_started(node) do
171     loaded_names = Enum.map(Application.loaded_applications(), fn {name, _, _} -> name end)
172     app_names = @extra_apps ++ (loaded_names -- @extra_apps)
173
174     rpc(node, Application, :ensure_all_started, [:mix])
175     rpc(node, Mix, :env, [Mix.env()])
176     rpc(node, __MODULE__, :prepare_database, [])
177
178     log(node, "starting application")
179
180     Enum.reduce(app_names, MapSet.new(), fn app, loaded ->
181       if Enum.member?(loaded, app) do
182         loaded
183       else
184         {:ok, started} = rpc(node, Application, :ensure_all_started, [app])
185         MapSet.union(loaded, MapSet.new(started))
186       end
187     end)
188   end
189
190   @doc false
191   def prepare_database do
192     log(node(), "preparing database")
193     repo_config = Application.get_env(:pleroma, Pleroma.Repo)
194     repo_config[:adapter].storage_down(repo_config)
195     repo_config[:adapter].storage_up(repo_config)
196
197     {:ok, _, _} =
198       Ecto.Migrator.with_repo(Pleroma.Repo, fn repo ->
199         Ecto.Migrator.run(repo, :up, log: false, all: true)
200       end)
201
202     Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, :manual)
203     {:ok, _} = Application.ensure_all_started(:ex_machina)
204   end
205
206   defp silence_logger_warnings(func) do
207     prev_level = Logger.level()
208     Logger.configure(level: :error)
209     res = func.()
210     Logger.configure(level: prev_level)
211
212     res
213   end
214
215   defp node_name(node_host) do
216     node_host
217     |> to_string()
218     |> String.split("@")
219     |> Enum.at(0)
220     |> String.to_atom()
221   end
222 end