defmodule MishkaInstaller.Installer.DepChangesProtector do
@moduledoc """
This module serializes how to get and install a library and add it to your system.
Based on the structure of `MishkaInstaller`, this module should not be called independently.
- The reason for the indirect call is to make the queue and also to run the processes in the background.
- For this purpose, two workers have been created for this module, which can handle the update operation and add a library.
### Below you can see the graph of connecting this module to another module.
```
+---------------------------------------+
| |
| <-----------------------------------+
| MishkaInstaller.Installer.DepHandler | |
| | +---------------------------+------+
| | | |
+-------------------+-----------------^-+ | |
| | | MishkaInstaller.DepCompileJob |
| | | <-----------+
| | | | |
| | +----------------------------------+ |
| | |
| | +----------------------------------+ |
| | | | |
| | | | |
| | | MishkaInstaller.DepUpdateJob | |
| +---------+ | |
| | | |
| +-^--------------------------------+ |
| | |
+-------------------v---------------------------+ | +-----------------------------------------+
| | | | |
| | | | |
| MishkaInstaller.Installer.DepChangesProtector +-+ | MishkaInstaller.Installer.Live.DepGetter|
| | | |
| | | |
+---------------------+-------------------------+ +-----------------------------------------+
|
|
+-------------------v-----------------------+
| |
| MishkaInstaller.Installer.RunTimeSourcing |
| |
+-------------------------------------------+
```
As you can see in the graph above, most of the requests, except the update request, pass through the path of the
`MishkaInstaller.DepCompileJob` module and call some functions of the `MishkaInstaller.Installer.DepHandler` module.
After completing the operation process, this module finally serializes the queued requests and broadcasts the output by means of `Pubsub`.
- **Warning**: Direct use of this module causes conflict in long operations and causes you to receive an error,
or the system is completely down.
- **Warning**: The update operation is connected to the worker of the `MishkaInstaller.DepUpdateJob` module.
- **Warning**: this section should be limited to the super admin user because it is directly related to the core of the system.
- **Warning**: User should always be notified to get backup.
- **Warning**: Do not send timeout request to Genserver of this module.
- **Warning**: This module must be supervised in the `Application.ex` file and loaded at runtime.
- **Warning**: this module has a direct relationship with the `extension.json` file, so it checks this file every few seconds and
fixes it if it is not created or has a problem.
- **Warning**: If you put `Pubsub` in your configuration settings and the value is not nil, this module will automatically send
a timeout several times until your Pubsub process goes live.
- **Warning**: at the time of starting Genserver, this module also starts `MishkaInstaller.DepUpdateJob.ets/0` runtime database.
- **Warning**: All possible errors are stored in the database introduced in the configuration, and you can access it with the
functions of the `MishkaInstaller.Activity` module.
"""
use GenServer, restart: :permanent
require Logger
@re_check_json_time 10_000
@module "dep_changes_protector"
alias MishkaInstaller.Installer.{DepHandler, RunTimeSourcing}
alias MishkaInstaller.Dependency
@doc false
@spec start_link(list()) :: :ignore | {:error, any} | {:ok, pid}
def start_link(args \\ []) do
GenServer.start_link(__MODULE__, args, name: __MODULE__)
end
@doc false
@spec push(map(), String.t() | atom()) :: map()
def push(app, status) do
GenServer.call(__MODULE__, {:push, app: app, status: status})
end
@doc false
@spec get(String.t()) :: map()
def get(app) do
GenServer.call(__MODULE__, {:get, app})
end
@spec get :: map()
def get() do
GenServer.call(__MODULE__, :get)
end
@doc false
@spec pop(String.t()) :: map()
def pop(app) do
GenServer.call(__MODULE__, {:pop, app})
end
@doc false
@spec clean :: :ok
def clean() do
GenServer.cast(__MODULE__, :clean)
end
@doc """
This function is actually an action function and aggregator.
You call this `GenServer.cast` function with three inputs `{:deps, app, type}`, which executes with a very small timeout.
Warning: It is highly recommended not to call this function directly and use the worker (`MishkaInstaller.DepCompileJob`)
to compile. Finally, this function calls the `MishkaInstaller.Installer.RunTimeSourcing.do_deps_compile/2` function with the help of
`Task.Supervisor.async_nolink/2`.
It is worth mentioning that you can view and monitor the output by subscribing to this module.
## Examples
```elixir
MishkaInstaller.Installer.DepChangesProtector.deps(app_name, output_type)
```
"""
@spec deps(String.t(), atom()) :: :ok
def deps(app, type \\ :port) do
GenServer.cast(__MODULE__, {:deps, app, type})
end
@impl true
def init(_state) do
Logger.info("OTP Dependencies changes protector server was started")
# Start update ets
MishkaInstaller.DepUpdateJob.ets()
{:ok, %{data: nil, ref: nil}, {:continue, :check_json}}
end
@impl true
def handle_continue(:check_json, state) do
check_custom_pubsub_loaded(state)
end
@impl true
def handle_continue(:add_extensions, state) do
add_extensions_when_server_reset(state)
end
@impl true
def handle_info(:check_json, state) do
if Mix.env() not in [:dev, :test],
do: Logger.info("OTP Dependencies changes protector Cache server was valued by JSON.")
Process.send_after(self(), :check_json, @re_check_json_time)
new_state =
if is_nil(state.ref) do
Map.merge(state, %{data: json_check_and_create()})
else
state
end
{:noreply, new_state}
end
@impl true
def handle_info({:ok, :dependency, _action, _repo_data}, state) do
DepHandler.extensions_json_path()
|> File.rm_rf()
{:noreply, Map.merge(state, %{data: json_check_and_create()})}
end
@impl true
def handle_info({_ref, {:installing_app, app_name, _move_apps, app_res}}, state) do
case app_res do
{:ok, :application_ensure} ->
notify_subscribers({:ok, app_res, app_name})
{:error, do_runtime, app, operation: operation, output: output} ->
notify_subscribers({:error, app_res, "#{app}"})
MishkaInstaller.dependency_activity(
%{state: [{:error, do_runtime, "#{app}", operation: operation, output: output}]},
"high"
)
{:error, do_runtime, ensure, output} ->
notify_subscribers({:error, app_res, app_name})
MishkaInstaller.dependency_activity(
%{state: [{:error, do_runtime, app_name, operation: ensure, output: output}]},
"high"
)
end
Oban.resume_queue(queue: :compile_events)
{:noreply, state}
end
@impl true
def handle_info({ref, answer}, %{ref: ref} = state) do
# The task completed successfully
{:noreply,
Map.merge(state, %{data: update_dependency_type(answer, state) || state, ref: nil, app: nil})}
end
@impl true
def handle_info({:DOWN, _ref, :process, _pid, _status}, state) do
{:noreply, %{state | ref: nil}}
end
@impl true
def handle_info({:do_compile, app, type}, state) do
task =
Task.Supervisor.async_nolink(DepChangesProtectorTask, fn ->
RunTimeSourcing.do_deps_compile(app, type)
end)
{:noreply, Map.merge(state, %{ref: task.ref, app: app})}
end
@impl true
def handle_info(:start_oban, state) do
Logger.info("We sent a request to start oban")
MishkaInstaller.start_oban_in_runtime()
{:noreply, state}
end
@impl true
def handle_info(:timeout, state) do
Logger.info("We are waiting for your custom pubsub is loaded")
check_custom_pubsub_loaded(state)
end
@impl true
def handle_info(_param, state) do
Logger.info("We have an uncontrolled output")
{:noreply, state}
end
@impl true
def handle_call({:push, app: app, status: status}, _from, state) do
new_state =
case Enum.find(state.data, &(&1.app == app)) do
nil ->
new_app = %{app: app, status: status, time: DateTime.utc_now()}
{:reply, new_app, Map.merge(state, %{data: state.data ++ [new_app]})}
_ ->
{:reply, {:duplicate, app}, state}
end
new_state
end
@impl true
def handle_call({:get, app}, _from, state) do
{:reply, Enum.find(state.data, &(&1.app == app)), state}
end
@impl true
def handle_call(:get, _from, state) do
{:reply, state, state}
end
@impl true
def handle_call({:pop, app}, _from, state) do
new_state = Enum.reject(state.data, &(&1.app == app))
{:reply, new_state, %{state | data: new_state}}
end
@impl true
def handle_cast({:deps, app, type}, state) do
Process.send_after(self(), {:do_compile, app, type}, 100)
{:noreply, state}
end
@impl true
def handle_cast(:clean, _state) do
{:noreply, []}
end
@doc """
This function checks if any process is running or not.
if `true` means no job is being done, if `false` means there is a job is being done.
## Examples
```elixir
MishkaInstaller.Installer.DepChangesProtector.is_dependency_compiling?()
```
"""
@spec is_dependency_compiling? :: boolean
def is_dependency_compiling?(), do: is_nil(get().ref)
# For now, we decided to remove and re-create JSON file to prevent user not to delete or wrong edit manually
defp json_check_and_create() do
File.rm_rf(DepHandler.extensions_json_path())
{:ok, :check_or_create_deps_json, json} = DepHandler.check_or_create_deps_json()
{:ok, :read_dep_json, data} = DepHandler.read_dep_json(json)
Enum.filter(data, &(&1["dependency_type"] == "force_update"))
|> Enum.map(&%{app: &1["app"], status: &1["dependency_type"], time: DateTime.utc_now()})
rescue
_e -> []
end
defp update_dependency_type(answer, state, dependency_type \\ "none") do
with {:ok, :do_deps_compile, app_name} <- answer,
{:ok, :change_dependency_type_with_app, _repo_data} <-
Dependency.change_dependency_type_with_app(app_name, dependency_type) do
json_check_and_create()
with {:ok, :compare_installed_deps_with_app_file, apps_list} <-
DepHandler.compare_installed_deps_with_app_file("#{app_name}") do
Task.Supervisor.async_nolink(DepChangesProtectorTask, fn ->
RunTimeSourcing.do_runtime(
String.to_atom(state.app),
:uninstall
)
{
:installing_app,
app_name,
DepHandler.move_and_replace_compiled_app_build(apps_list),
RunTimeSourcing.do_runtime(String.to_atom(state.app), :add)
}
end)
end
else
{:error, :do_deps_compile, app, operation: _operation, output: output} ->
with {:ok, :get_record_by_field, :dependency, record_info} <- Dependency.show_by_name(app) do
Dependency.delete(record_info.id)
end
notify_subscribers({:error, output, app})
MishkaInstaller.dependency_activity(%{state: [answer]}, "high")
{:error, :change_dependency_type_with_app, :dependency, :not_found} ->
MishkaInstaller.dependency_activity(%{state: [answer], action: "no_app_found"}, "high")
{:error, :change_dependency_type_with_app, :dependency, repo_error} ->
MishkaInstaller.dependency_activity(
%{state: [answer], action: "edit", error: repo_error},
"high"
)
end
end
defp check_custom_pubsub_loaded(state) do
custom_pubsub = MishkaInstaller.get_config(:pubsub)
custom_repo = MishkaInstaller.get_config(:repo)
cond do
is_nil(custom_pubsub) ->
if Mix.env() != :test do
raise "Please set a Phoenix PubSub module in your config based on MishkaInstaller document."
else
{:noreply, state, 100}
end
is_nil(Process.whereis(custom_pubsub)) ||
is_nil(Process.whereis(custom_repo)) ->
{:noreply, state, 100}
true ->
Process.send_after(self(), :check_json, @re_check_json_time)
Process.send_after(self(), :start_oban, @re_check_json_time)
MishkaInstaller.Dependency.subscribe()
{:noreply, state, {:continue, :add_extensions}}
end
end
@doc """
This function helps the programmer to join the channel of this module(`MishkaInstaller.Installer.DepChangesProtector`)
and receive the output as a broadcast in the form of `{status, :dep_changes_protector, answer, app}`.
It uses `Phoenix.PubSub.subscribe/2`.
## Examples
```elixir
# Subscribe to `MishkaInstaller.Installer.DepChangesProtector` module
MishkaInstaller.Installer.DepChangesProtector.subscribe()
# Getting the answer as Pubsub for examples in LiveView
@impl Phoenix.LiveView
def handle_info({status, :dep_changes_protector, answer, app}, socket) do
{:noreply, socket}
end
```
"""
@spec subscribe :: :ok | {:error, {:already_registered, pid}}
def subscribe do
Phoenix.PubSub.subscribe(
MishkaInstaller.get_config(:pubsub) || MishkaInstaller.PubSub,
@module
)
end
@doc false
@spec notify_subscribers({atom(), any, String.t() | atom()}) :: :ok | {:error, any}
defp notify_subscribers({status, answer, app}) do
Phoenix.PubSub.broadcast(
MishkaInstaller.get_config(:pubsub) || MishkaInstaller.PubSub,
@module,
{status, String.to_atom(@module), answer, app}
)
end
defp add_extensions_when_server_reset(state) do
if Mix.env() != :test do
Logger.warn("Try to re-add installed extensions")
MishkaInstaller.Dependency.dependencies()
|> Enum.map(fn item ->
RunTimeSourcing.do_runtime(String.to_atom(item.app), :add)
|> case do
{:ok, :application_ensure} ->
Logger.info("All installed extensions re-added")
{:error, :application_ensure, :load, {'no such file or directory', _app}} ->
case DepHandler.compare_installed_deps_with_app_file(item.app) do
{:ok, :compare_installed_deps_with_app_file, apps_list} ->
DepHandler.move_and_replace_compiled_app_build(apps_list)
RunTimeSourcing.do_runtime(String.to_atom(item.app), :add)
{:error, :compare_installed_deps_with_app_file, msg} ->
MishkaInstaller.Dependency.delete(item.id)
File.rm_rf!(
Path.join(MishkaInstaller.get_config(:project_path), [
"deployment/",
"extensions/#{item.app}"
])
)
Logger.emergency("We have problem to add all extensions, #{inspect(msg)}")
end
{:error, :prepend_compiled_apps, :no_directory, _} ->
case DepHandler.compare_installed_deps_with_app_file("#{item.app}") do
{:ok, :compare_installed_deps_with_app_file, apps_list} ->
DepHandler.move_and_replace_compiled_app_build(apps_list)
RunTimeSourcing.do_runtime(String.to_atom(item.app), :add)
Logger.info(
"The #{item.app} installed extension re-added from deployment/extensions directory, because your _build directory has been deleted."
)
output ->
Logger.emergency(
"We have problem to add #{item.app} extension, #{inspect(output)}"
)
end
output ->
Logger.emergency("We have problem to add all extensions, #{inspect(output)}")
end
end)
end
{:noreply, state}
end
end