diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1aa356d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.env +.elixir_ls +.git +.github +_build +data +deps +erl_crash.dump +node_modules +tmp diff --git a/Dockerfile b/Dockerfile index caac9d5..a541b9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,53 @@ -FROM elixir:1.17.2 +FROM elixir:1.17.2 AS base + +ENV MIX_ENV=prod + +FROM base AS build RUN apt-get update && \ - apt-get install -y \ + apt-get install -y --no-install-recommends \ build-essential \ - ffmpeg \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* + git \ + && apt-get clean && \ + rm -rf /var/lib/apt/lists/* WORKDIR /app +RUN mix local.hex --force && \ + mix local.rebar --force + COPY mix.exs mix.lock ./ -RUN mix do local.hex --force, local.rebar --force, deps.get +COPY config config + +RUN mix deps.get --only $MIX_ENV +RUN mix deps.compile + +COPY lib lib +COPY priv priv + +RUN mix compile +RUN mix release + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + ffmpeg \ + libncurses6 \ + libstdc++6 \ + openssl \ + && apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +ENV LANG=C.UTF-8 + +WORKDIR /app + +RUN useradd --system --create-home --home-dir /app save_it + +COPY --from=build --chown=save_it:save_it /app/_build/prod/rel/save_it /app -COPY . . +USER save_it -CMD ["mix", "run", "--no-halt"] +CMD ["/app/bin/save_it", "start"] diff --git a/config/runtime.exs b/config/runtime.exs index d528644..82f0941 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -14,7 +14,7 @@ config :save_it, :google_oauth_client_secret, System.get_env("GOOGLE_OAUTH_CLIEN config :sentry, dsn: System.get_env("SENTRY_DSN"), - environment_name: Mix.env(), + environment_name: config_env(), enable_source_code_context: true, root_source_code_path: File.cwd!(), included_dependencies: [:req, :jason, :hackney] diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 5e899de..59c4a92 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -22,4 +22,19 @@ GOOGLE_OAUTH_CLIENT_SECRET= - Published image: `ghcr.io/thaddeusjiang/save_it` - Zeabur template image tag: `latest` -- Local acceptance build check: `mise run confirm-docker-build` +- Local acceptance build check: `mise run build-acceptance-test` +- The container now boots from the Elixir release entrypoint: `/app/bin/save_it start` + +## Release Commands + +- Run Typesense migration in a release container: + +```sh +/app/bin/save_it eval 'SaveIt.Release.ts_migrate()' +``` + +- Example with Docker: + +```sh +docker exec -it /app/bin/save_it eval 'SaveIt.Release.ts_migrate()' +``` diff --git a/lib/mix/tasks/ts.migrate.ex b/lib/mix/tasks/ts.migrate.ex index 9be4a3d..7a5b626 100644 --- a/lib/mix/tasks/ts.migrate.ex +++ b/lib/mix/tasks/ts.migrate.ex @@ -1,71 +1,13 @@ defmodule Mix.Tasks.Ts.Migrate do use Mix.Task - alias Req.TransportError alias SaveIt.Migration.Typesense - alias SaveIt.Migration.Typesense.Photo @shortdoc "Run Typesense collection migrations" @impl Mix.Task def run(_args) do Mix.Task.run("app.start") - - typesense_url = Application.fetch_env!(:save_it, :typesense_url) - - try do - ensure_photos_collection!() - migrate_photos_if_needed!() - - Mix.shell().info("Typesense migration done") - rescue - error in TransportError -> - raise """ - Typesense request failed: #{Exception.message(error)} - Current TYPESENSE_URL: #{typesense_url} - - If you are using docker-compose locally, try: - export TYPESENSE_URL=http://localhost:8108 - docker compose up -d typesense - mix ts.migrate - """ - end - end - - defp ensure_photos_collection! do - case photos_collection() do - nil -> - Mix.shell().info("creating photos collection") - Photo.create_photos_20241024!() - - _collection -> - Mix.shell().info("photos collection already exists, skipping create") - end - end - - defp migrate_photos_if_needed! do - case photos_collection() do - nil -> - :ok - - collection -> - if has_field?(collection, "url") do - Mix.shell().info("applying photos migration 20241029") - Photo.migrate_photos_20241029!() - else - Mix.shell().info("photos migration already applied, skipping") - end - end - end - - defp photos_collection do - Typesense.list_collections!() - |> Enum.find(fn collection -> collection["name"] == "photos" end) - end - - defp has_field?(collection, field_name) do - collection - |> Map.get("fields", []) - |> Enum.any?(fn field -> field["name"] == field_name end) + Typesense.migrate!() end end diff --git a/lib/save_it/migration/typesense.ex b/lib/save_it/migration/typesense.ex index 0097c95..a516869 100644 --- a/lib/save_it/migration/typesense.ex +++ b/lib/save_it/migration/typesense.ex @@ -3,6 +3,9 @@ defmodule SaveIt.Migration.Typesense do import SaveIt.SmallHelper.UrlHelper, only: [validate_url!: 1] + alias Req.TransportError + alias SaveIt.Migration.Typesense.Photo + def create_collection!(schema) do req = build_request("/collections") res = Req.post!(req, json: schema) @@ -24,6 +27,31 @@ defmodule SaveIt.Migration.Typesense do Typesense.handle_response!(res) end + def migrate! do + typesense_url = Application.fetch_env!(:save_it, :typesense_url) + + try do + ensure_photos_collection!() + migrate_photos_if_needed!() + + IO.puts("Typesense migration done") + rescue + error in TransportError -> + raise """ + Typesense request failed: #{Exception.message(error)} + Current TYPESENSE_URL: #{typesense_url} + + If you are using docker-compose locally, try: + export TYPESENSE_URL=http://localhost:8108 + docker compose up -d typesense + mix ts.migrate + + If you are running a release container, use: + /app/bin/save_it eval 'SaveIt.Release.ts_migrate()' + """ + end + end + def delete_collection(collection_name) do req = build_request("/collections/#{collection_name}") res = Req.delete(req) @@ -31,6 +59,43 @@ defmodule SaveIt.Migration.Typesense do Typesense.handle_response(res) end + defp ensure_photos_collection! do + case photos_collection() do + nil -> + IO.puts("creating photos collection") + Photo.create_photos_20241024!() + + _collection -> + IO.puts("photos collection already exists, skipping create") + end + end + + defp migrate_photos_if_needed! do + case photos_collection() do + nil -> + :ok + + collection -> + if has_field?(collection, "url") do + IO.puts("applying photos migration 20241029") + Photo.migrate_photos_20241029!() + else + IO.puts("photos migration already applied, skipping") + end + end + end + + defp photos_collection do + list_collections!() + |> Enum.find(fn collection -> collection["name"] == "photos" end) + end + + defp has_field?(collection, field_name) do + collection + |> Map.get("fields", []) + |> Enum.any?(fn field -> field["name"] == field_name end) + end + defp get_env() do url = Application.fetch_env!(:save_it, :typesense_url) |> validate_url!() diff --git a/lib/save_it/release.ex b/lib/save_it/release.ex new file mode 100644 index 0000000..baa5424 --- /dev/null +++ b/lib/save_it/release.ex @@ -0,0 +1,19 @@ +defmodule SaveIt.Release do + @moduledoc false + + alias SaveIt.Migration.Typesense + + def ts_migrate do + ensure_runtime_dependencies!() + Typesense.migrate!() + end + + defp ensure_runtime_dependencies! do + Application.load(:save_it) + + case Application.ensure_all_started(:req) do + {:ok, _started} -> :ok + {:error, reason} -> raise "failed to start release migration dependencies: #{inspect(reason)}" + end + end +end diff --git a/others/postmortem/2026-05-24-docker-release-runtime-mix-env.md b/others/postmortem/2026-05-24-docker-release-runtime-mix-env.md new file mode 100644 index 0000000..b020d84 --- /dev/null +++ b/others/postmortem/2026-05-24-docker-release-runtime-mix-env.md @@ -0,0 +1,21 @@ +# Docker release runtime Mix.env crash + +## What happened + +While converting the container image from `mix run --no-halt` to a `mix release`-based image, the Docker image built successfully but failed during a release smoke test. + +## Root cause + +`config/runtime.exs` used `Mix.env()` for the Sentry `environment_name`. `Mix` is not available in a production release runtime, so the config provider crashed during boot. + +## Fix applied + +- Replaced `Mix.env()` with `config_env()` in `config/runtime.exs` +- Rebuilt the release and Docker image +- Verified the release binary inside the image with `/app/bin/save_it eval 'IO.puts("release-ok")'` + +## What we learned + +- A successful `mix release` build is not enough to prove a release boots correctly. +- Any code in `runtime.exs` must avoid `Mix` runtime dependencies. +- A cheap `release eval` smoke test is a good guardrail after Dockerfile or runtime config changes.