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