mitgliederverwaltung/lib/mv/release.ex
Simon c40f3135a1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
feat: only run all seeds on first startup
2026-03-16 17:52:59 +01:00

255 lines
7.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.
- `bootstrap_seeds_applied?/0` - Returns whether bootstrap was already applied (admin user exists). Used to skip re-running seeds.
- `run_seeds/0` - If bootstrap already applied, skips; otherwise 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 """
Returns whether bootstrap seeds have already been applied (admin user exists).
We check for the admin user (from ADMIN_EMAIL or default), not the Admin role,
because migrations may create the Admin role for the system actor. Only seeds
create the admin (login) user. Used to skip re-running seeds on subsequent starts.
Call only when the application is already started.
"""
def bootstrap_seeds_applied? do
admin_email = get_env("ADMIN_EMAIL", "admin@localhost")
case User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(authorize?: false, domain: Mv.Accounts) do
{:ok, %User{}} -> true
_ -> false
end
rescue
_ -> false
end
@doc """
Runs seed scripts so the database has required bootstrap data (and optionally dev data).
- Skips if bootstrap was already applied (Admin role exists); otherwise runs bootstrap seeds.
- If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data)
when bootstrap is run.
Uses paths from the application's priv dir so it works in releases (no Mix).
"""
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
if bootstrap_seeds_applied?() do
IO.puts("Seeds already applied (admin user exists). Skipping.")
else
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
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