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. - `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 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). - 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 load_app() 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 == "" -> # Do not create or update any user without a password (no fallback in production) :ok true -> ensure_admin_user(admin_email, admin_password) 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