229 lines
6.3 KiB
Elixir
229 lines
6.3 KiB
Elixir
defmodule Mv.Release do
|
|
@moduledoc """
|
|
Used for executing DB release tasks when run in production without Mix
|
|
installed.
|
|
|
|
## Tasks
|
|
|
|
- `migrate/0` - Runs all pending Ecto migrations.
|
|
- `run_seeds/0` - Runs bootstrap seeds (fee types, custom fields, roles, settings).
|
|
In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data).
|
|
- `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD
|
|
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
|
to update the admin password without redeploying.
|
|
"""
|
|
@app :mv
|
|
|
|
alias Mv.Accounts
|
|
alias Mv.Accounts.User
|
|
alias Mv.Authorization.Role
|
|
|
|
require Ash.Query
|
|
|
|
def migrate do
|
|
load_app()
|
|
|
|
for repo <- repos() do
|
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Runs seed scripts so the database has required bootstrap data (and optionally dev data).
|
|
|
|
- Always runs bootstrap seeds (fee types, custom fields, roles, system user, settings).
|
|
- If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data).
|
|
|
|
Uses paths from the application's priv dir so it works in releases (no Mix). Idempotent.
|
|
"""
|
|
def run_seeds do
|
|
case Application.ensure_all_started(@app) do
|
|
{:ok, _} -> :ok
|
|
{:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}"
|
|
end
|
|
|
|
priv = :code.priv_dir(@app)
|
|
bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs")
|
|
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
|
|
|
prev = Code.compiler_options()
|
|
Code.compiler_options(ignore_module_conflict: true)
|
|
|
|
try do
|
|
Code.eval_file(bootstrap_path)
|
|
IO.puts("✅ Bootstrap seeds completed.")
|
|
|
|
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
|
Code.eval_file(dev_path)
|
|
IO.puts("✅ Dev seeds completed.")
|
|
end
|
|
after
|
|
Code.compiler_options(prev)
|
|
end
|
|
end
|
|
|
|
def rollback(repo, version) do
|
|
load_app()
|
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
|
end
|
|
|
|
@doc """
|
|
Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE).
|
|
|
|
Starts the application if not already running (required when called via `bin/mv eval`;
|
|
Ash/Telemetry need the running app). Idempotent.
|
|
|
|
- If ADMIN_EMAIL is unset: no-op (idempotent).
|
|
- If ADMIN_PASSWORD (and ADMIN_PASSWORD_FILE) are unset and the user does not exist:
|
|
no user is created (no fallback password in production).
|
|
- If both ADMIN_EMAIL and ADMIN_PASSWORD are set: creates or updates the user with
|
|
Admin role and the given password. Safe to run on every deployment or via
|
|
`bin/mv eval "Mv.Release.seed_admin()"` to change the admin password without redeploying.
|
|
"""
|
|
def seed_admin do
|
|
# Ensure app (and Telemetry/Ash deps) are started when run via bin/mv eval
|
|
case Application.ensure_all_started(@app) do
|
|
{:ok, _} -> :ok
|
|
{:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}"
|
|
end
|
|
|
|
admin_email = get_env("ADMIN_EMAIL", nil)
|
|
admin_password = get_env_or_file("ADMIN_PASSWORD", nil)
|
|
|
|
cond do
|
|
is_nil(admin_email) or admin_email == "" ->
|
|
:ok
|
|
|
|
is_nil(admin_password) or admin_password == "" ->
|
|
ensure_admin_role_only(admin_email)
|
|
|
|
true ->
|
|
ensure_admin_user(admin_email, admin_password)
|
|
end
|
|
end
|
|
|
|
defp ensure_admin_role_only(email) do
|
|
case Role.get_admin_role() do
|
|
{:ok, nil} ->
|
|
:ok
|
|
|
|
{:ok, %Role{} = admin_role} ->
|
|
case get_user_by_email(email) do
|
|
{:ok, %User{} = user} ->
|
|
user
|
|
|> Ash.Changeset.for_update(:update, %{})
|
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
|> Ash.update!(authorize?: false)
|
|
|
|
:ok
|
|
|
|
_ ->
|
|
:ok
|
|
end
|
|
|
|
{:error, _} ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp ensure_admin_user(email, password) do
|
|
if is_nil(password) or password == "" do
|
|
:ok
|
|
else
|
|
do_ensure_admin_user(email, password)
|
|
end
|
|
end
|
|
|
|
defp do_ensure_admin_user(email, password) do
|
|
case Role.get_admin_role() do
|
|
{:ok, nil} ->
|
|
# Admin role does not exist (e.g. migrations not run); skip
|
|
:ok
|
|
|
|
{:ok, %Role{} = admin_role} ->
|
|
case get_user_by_email(email) do
|
|
{:ok, nil} ->
|
|
create_admin_user(email, password, admin_role)
|
|
|
|
{:ok, user} ->
|
|
update_admin_user(user, password, admin_role)
|
|
|
|
{:error, _} ->
|
|
:ok
|
|
end
|
|
|
|
{:error, _} ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp create_admin_user(email, password, admin_role) do
|
|
case Accounts.create_user(%{email: email}, authorize?: false) do
|
|
{:ok, user} ->
|
|
user
|
|
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
|
|> Ash.update!(authorize?: false)
|
|
|> then(fn u ->
|
|
u
|
|
|> Ash.Changeset.for_update(:update, %{})
|
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
|> Ash.update!(authorize?: false)
|
|
end)
|
|
|
|
:ok
|
|
|
|
{:error, _} ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp update_admin_user(user, password, admin_role) do
|
|
user
|
|
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
|
|> Ash.update!(authorize?: false)
|
|
|> then(fn u ->
|
|
u
|
|
|> Ash.Changeset.for_update(:update, %{})
|
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
|> Ash.update!(authorize?: false)
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp get_user_by_email(email) do
|
|
User
|
|
|> Ash.Query.filter(email == ^email)
|
|
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
|
|
end
|
|
|
|
defp get_env(key, default) do
|
|
System.get_env(key, default)
|
|
end
|
|
|
|
defp get_env_or_file(var_name, default) do
|
|
file_var = "#{var_name}_FILE"
|
|
|
|
case System.get_env(file_var) do
|
|
nil ->
|
|
System.get_env(var_name, default)
|
|
|
|
file_path ->
|
|
case File.read(file_path) do
|
|
{:ok, content} ->
|
|
String.trim_trailing(content)
|
|
|
|
{:error, _} ->
|
|
default
|
|
end
|
|
end
|
|
end
|
|
|
|
defp repos do
|
|
Application.fetch_env!(@app, :ecto_repos)
|
|
end
|
|
|
|
defp load_app do
|
|
Application.load(@app)
|
|
end
|
|
end
|