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). 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