## Description of the implemented changes The changes were: - [ ] Bugfixing - [x] New Feature - [ ] Breaking Change - [x] Refactoring **Seeds run only on first startup.** On every application start (e.g. `just run`, Docker entrypoint), seed scripts are still invoked, but they exit immediately when the admin user already exists. This avoids duplicate seed data (e.g. join requests), keeps startup fast after the first run, and works the same in dev and production. ## What has been changed? - **`lib/mv/release.ex`** - Added `bootstrap_seeds_applied?/0`: returns whether the admin user (from `ADMIN_EMAIL` or default `admin@localhost`) exists. We check the admin *user*, not the Admin *role*, so we do not skip when only migrations have run (migrations can create the Admin role for the system actor). - `run_seeds/0`: if `bootstrap_seeds_applied?()` is true, prints “Seeds already applied (admin user exists). Skipping.” and returns without running bootstrap or dev seeds; otherwise unchanged behaviour. - Module docs updated for the new function and the skip behaviour. - **`priv/repo/seeds.exs`** - Ensures the app is started (`Application.ensure_all_started(:mv)`). - If `Mv.Release.bootstrap_seeds_applied?()` is true, prints the same skip message and does not run bootstrap or dev seeds; otherwise runs as before (bootstrap + dev seeds in dev/test). - Comment at the top updated to describe the skip behaviour. - **Documentation** - `CODE_GUIDELINES.md` §1.2.1: seeds run on every start but exit early when already applied; mentions `bootstrap_seeds_applied?/0`. - `docs/admin-bootstrap-and-oidc-role-sync.md`: run_seeds skips when admin user exists; description of `run_seeds/0` updated. - `CHANGELOG.md` [Unreleased]: new “Seeds run only when needed” entry under Changed. ## Definition of Done ### Code Quality - [x] No new technical depths - [x] Linting passed - [x] Documentation is added where needed ### Accessibility - [x] New elements are properly defined with html-tags *(no new UI)* - [x] Colour contrast follows WCAG criteria *(no new UI)* - [x] Aria labels are added when needed *(no new UI)* - [x] Everything is accessible by keyboard *(no new UI)* - [x] Tab-Order is comprehensible *(no new UI)* - [x] All interactive elements have a visible focus *(no new UI)* ### Testing - [x] Tests for new code are written *(existing seeds and release tests cover behaviour; idempotency test still passes when second run skips)* - [x] All tests pass - [x] axe-core dev tools show no critical or major issues *(no UI changes)* ## Additional Notes - **Review focus:** Logic in `Mv.Release` and `priv/repo/seeds.exs`; the “already applied” check is a single DB read for the admin user. On failure (e.g. DB down), `bootstrap_seeds_applied?/0` returns `false`, so seeds run (safe for first deploy). - **Suggested check:** Run `mix test test/seeds_test.exs test/mv/release_test.exs` to confirm seeds and release behaviour. Reviewed-on: #475 Co-authored-by: Simon <s.thiessen@local-it.org> Co-committed-by: Simon <s.thiessen@local-it.org>
258 lines
7.6 KiB
Elixir
258 lines
7.6 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). Set `FORCE_SEEDS=true` to re-run seeds even when already applied. 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
|
|
require Logger
|
|
|
|
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
|
|
e ->
|
|
Logger.warning("Could not check seed status (#{inspect(e)}), assuming not applied.")
|
|
false
|
|
end
|
|
|
|
@doc """
|
|
Runs seed scripts so the database has required bootstrap data (and optionally dev data).
|
|
|
|
- Skips if bootstrap was already applied (admin user exists); set `FORCE_SEEDS=true` to override and re-run.
|
|
- 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?() and System.get_env("FORCE_SEEDS") != "true" do
|
|
IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)")
|
|
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
|