diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000..95d2206936 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,74 @@ +name: Docker publish + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + name: Build and push Docker images + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Determine tags + id: tags + run: | + DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" + CURRENT_BRANCH="${{ github.ref_name }}" + + if [ "$CURRENT_BRANCH" = "$DEFAULT_BRANCH" ]; then + NUMBER="${{ github.run_number }}" + else + NUMBER="$(git rev-parse --short=10 HEAD)" + fi + REPOSITORY="${{ github.repository }}" + REGISTRY="ghcr.io/${REPOSITORY,,,}" + + if [ "$CURRENT_BRANCH" = "$DEFAULT_BRANCH" ]; then + echo "release_tags=${REGISTRY}:build-${NUMBER}-release,${REGISTRY}:latest-release" >> "$GITHUB_OUTPUT" + echo "dev_tags=${REGISTRY}:build-${NUMBER}-dev,${REGISTRY}:latest-dev" >> "$GITHUB_OUTPUT" + else + echo "release_tags=${REGISTRY}:git-${NUMBER}-release" >> "$GITHUB_OUTPUT" + echo "dev_tags=${REGISTRY}:git-${NUMBER}-dev" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push release image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + target: runner + push: true + tags: ${{ steps.tags.outputs.release_tags }} + cache-from: type=gha,scope=docker + cache-to: type=gha,mode=max,scope=docker + + - name: Build and push dev image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + target: dev + push: true + tags: ${{ steps.tags.outputs.dev_tags }} + cache-from: type=gha,scope=docker + cache-to: type=gha,mode=max,scope=docker diff --git a/Dockerfile b/Dockerfile index 5ad75eef89..9cb117974e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,9 @@ # This file is written in a way that tries to optimize caching of the build steps # as much as possible. It is increasing the complexity of the file a bit. # -# The image can be built with the following command from the teiserver repo root: +# The production image can be built with the following command from the teiserver repo root: # -# sudo podman build -t teiserver -f . +# podman build --target runner -t teiserver -f . # # The release is copied and placed in the filesystem of the host, but it could be # also run as a container with something like: @@ -19,6 +19,8 @@ # -v /etc/ssl/dhparam.pem:/etc/ssl/dhparam.pem:ro \ # teiserver # +# Development image (used by docker-compose.yml): +# podman build --target dev -t teiserver-dev . ARG ELIXIR_VERSION=1.19.4 ARG OTP_VERSION=26.2.5.1 @@ -26,7 +28,10 @@ ARG DEBIAN_VERSION=trixie-20251208 ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}-slim" -FROM ${BUILDER_IMAGE} as builder +# ============================================================ +# base — shared foundation for builder and dev stages +# ============================================================ +FROM ${BUILDER_IMAGE} AS base RUN apt-get update \ && apt-get install --no-install-recommends --yes git make build-essential \ @@ -39,15 +44,20 @@ RUN mix local.hex --force \ RUN mkdir /build WORKDIR /build -ENV MIX_ENV="prod" - COPY mix.exs mix.lock ./ RUN mix deps.get +# ============================================================ +# builder — compiles and assembles the production release +# ============================================================ +FROM base AS builder + +ENV MIX_ENV="prod" + RUN mkdir config COPY config/config.exs config/ -# Need to also compiled dev to be able to compile static assets... +# Need to also compile dev to be able to compile static assets... COPY config/dev.exs config/ RUN MIX_ENV=dev mix deps.compile @@ -70,7 +80,58 @@ COPY config/runtime.exs config/ COPY rel rel RUN mix release -FROM ${RUNNER_IMAGE} +# ============================================================ +# dev — hot-reload development container +# ============================================================ +FROM base AS dev + +RUN apt-get update \ + && apt-get install --no-install-recommends --yes \ + curl \ + inotify-tools \ + postgresql-client \ + geoip-bin \ + geoip-database \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +ENV MIX_ENV=dev + +RUN mix deps.compile + +# dart_sass downloads a standalone Dart Sass binary on first use; install it +# here so it is baked into the image rather than fetched at container startup. +RUN mix sass.install + +COPY mise.toml /build/ +COPY *.exs /build/ +COPY *.json /build/ +COPY *.yaml /build/ + +COPY assets /build/assets +COPY bin /build/bin +COPY config /build/config +COPY lib /build/lib +COPY misc /build/misc +COPY priv /build/priv +COPY rel /build/rel +COPY scripts /build/scripts +COPY test /build/test + +RUN mix credo --strict || true +RUN mix format +RUN mix compile + +COPY docker/teiserver/docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["iex", "-S", "mix", "phx.server"] + +# ============================================================ +# runner — minimal production runtime image, last stage is the default output +# ============================================================ +FROM ${RUNNER_IMAGE} AS runner RUN apt-get update \ && apt-get install --no-install-recommends --yes libstdc++6 openssl libncurses6 locales tini \ @@ -85,7 +146,7 @@ ENV LC_ALL en_US.UTF-8 WORKDIR "/app" ENV MIX_ENV="prod" -COPY --from=builder /build/_build/${MIX_ENV}/rel/teiserver ./ +COPY --from=builder /build/_build/${MIX_ENV}/rel/teiserver ./ ENTRYPOINT ["tini", "--"] CMD /app/bin/teiserver start diff --git a/README.md b/README.md index 2ceaf25bd5..2f0ff63f76 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,24 @@ There are two ways to set up Teiserver locally for development or testing: 1. The [Local setup](/documents/guides/local_setup.md) guides you through the process of setting up everything yourself 2. The [Local testing](https://github.com/beyond-all-reason/ansible-teiserver?tab=readme-ov-file#local-testing) instructions use the Ansible playbook, which automates most of the setup and configuration. +## Compose dev setup +For a quick local stack (including teiserver with hot-reload) use the compose setup in this repo: + +```sh +docker compose -f docker-compose.dev.yml up --build +``` + +To start only Postgres for local Teiserver development: + +```sh +docker compose -f docker-compose.dev.yml up -d db +``` + +### Windows performance note +On Windows, bind mounts can make Teiserver startup and runtime noticeably slower (file watcher and filesystem overhead). + +If this bothers you, comment out the source-code mount block for `teiserver` in `docker-compose.dev.yml` (the `volumes` entries under the `teiserver` service), then rebuild/restart the stack. + ## Prod setup Production instance is set up using [Ansible playbook](https://github.com/beyond-all-reason/ansible-teiserver/tree/main), follow the setup instructions there. diff --git a/config/runtime.exs b/config/runtime.exs index 674476db2b..ad2c93efff 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -195,11 +195,10 @@ if Teiserver.ConfigHelpers.get_env("TEI_ENABLE_EMAIL_INTEGRATION", false, :bool) hostname: Teiserver.ConfigHelpers.get_env("TEI_SMTP_HOSTNAME"), # port: 1025, port: Teiserver.ConfigHelpers.get_env("TEI_SMTP_PORT", "587", :int), - username: Teiserver.ConfigHelpers.get_env("TEI_SMTP_USERNAME"), - password: Teiserver.ConfigHelpers.get_env("TEI_SMTP_PASSWORD"), - # tls: :if_available, # can be `:always` or `:never` - # can be `:always` or `:never` - tls: :always, + username: Teiserver.ConfigHelpers.get_env("TEI_SMTP_USERNAME", ""), + password: Teiserver.ConfigHelpers.get_env("TEI_SMTP_PASSWORD", ""), + # can be `:always`, `:never`, or `:if_available` + tls: Teiserver.ConfigHelpers.get_env("TEI_SMTP_TLS", "always") |> String.to_existing_atom(), tls_verify: if(Teiserver.ConfigHelpers.get_env("TEI_SMTP_TLS_VERIFY", true, :bool), do: :verify_peer, @@ -209,8 +208,8 @@ if Teiserver.ConfigHelpers.get_env("TEI_ENABLE_EMAIL_INTEGRATION", false, :bool) allowed_tls_versions: [:"tlsv1.2"], # can be `true` no_mx_lookups: false, - # auth: :if_available # can be `always`. If your smtp relay requires authentication set it to `always`. - auth: :always + # can be `:always`, `:if_available`, or `:never` + auth: Teiserver.ConfigHelpers.get_env("TEI_SMTP_AUTH", "always") |> String.to_existing_atom() end log_root_path = Teiserver.ConfigHelpers.get_env("TEI_LOG_ROOT_PATH", "/tmp/teiserver") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..0e041e6548 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,115 @@ +name: teiserver-dev +services: + db: + image: postgres:18 + restart: unless-stopped + environment: + POSTGRES_PASSWORD: 123456789 + ports: + - "5432:5432" + volumes: + - teiserver_postgres_data:/var/lib/postgresql + - ./docker/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 20 + + mailhog: + image: mailhog/mailhog:v1.0.1 + restart: unless-stopped + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI + + victoriametrics: + image: victoriametrics/victoria-metrics:v1.136.0 + restart: unless-stopped + ports: + - "8428:8428" + volumes: + - teiserver_victoriametrics_data:/victoria-metrics-data + - ./docker/victoriametrics/prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - "-retentionPeriod=14d" + - "-httpListenAddr=:8428" + - "-promscrape.config=/etc/prometheus/prometheus.yml" + + grafana: + image: grafana/grafana:main + container_name: teiserver-grafana + restart: unless-stopped + depends_on: + - victoriametrics + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + GF_USERS_ALLOW_SIGN_UP: "false" + GF_INSTALL_PLUGINS: "" + ports: + - "3000:3000" + volumes: + - teiserver_grafana_data:/var/lib/grafana + - ./docker/grafana/provisioning:/etc/grafana/provisioning:ro + + teiserver: + build: + context: . + dockerfile: Dockerfile + target: dev + container_name: teiserver + depends_on: + db: + condition: service_healthy + environment: + # Database + TEI_DB_HOSTNAME: "db" + TEI_DB_USERNAME: "teiserver_dev" + TEI_DB_PASSWORD: "123456789" + TEI_DB_NAME: "teiserver_dev" + + # Application + SECRET_KEY_BASE: "dev_secret_key_base_change_in_production_min_64_chars_long_string" + TEI_OAUTH_ISSUER: ${TEI_OAUTH_ISSUER:-http://localhost:4000} + TEI_DOMAIN_NAME: ${TEI_DOMAIN_NAME:-localhost} + + # Email integration (MailHog) + TEI_ENABLE_EMAIL_INTEGRATION: "true" + TEI_SMTP_SERVER: "mailhog" + TEI_SMTP_HOSTNAME: "localhost" + TEI_SMTP_PORT: "1025" + TEI_SMTP_TLS: "never" + TEI_SMTP_TLS_VERIFY: "false" + TEI_SMTP_AUTH: "never" + + # Dev settings + GENERATE_FAKE_DATA: "true" + IN_DOCKER: "true" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:4000 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 90s + ports: + - "4000:4000" # HTTP + - "4001:4001" # HTTP Metrics + - "8200:8200" # Spring protocol + - "8201:8201" # Spring protocol + - "8202:8202" # Spring protocol + volumes: + # Mount source code for live reload, might cause issues on Windows. + # If you run into performance issues, remove these mounts. + - ./lib:/build/lib + - ./config:/build/config + - ./priv:/build/priv + - ./assets:/build/assets + - ./test:/build/test + stdin_open: true + tty: true + +volumes: + teiserver_postgres_data: + teiserver_victoriametrics_data: + teiserver_grafana_data: diff --git a/docker/grafana/provisioning/datasources/datasource.yml b/docker/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000000..f643cc9773 --- /dev/null +++ b/docker/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: VictoriaMetrics + type: prometheus + access: proxy + url: http://victoriametrics:8428 + isDefault: true + editable: true + diff --git a/docker/postgres/init/01-init-teiserver.sql b/docker/postgres/init/01-init-teiserver.sql new file mode 100644 index 0000000000..7b1dd290f8 --- /dev/null +++ b/docker/postgres/init/01-init-teiserver.sql @@ -0,0 +1,5 @@ +CREATE ROLE teiserver_dev LOGIN PASSWORD '123456789' SUPERUSER; +CREATE ROLE teiserver_test LOGIN PASSWORD '123456789' SUPERUSER; + +CREATE DATABASE teiserver_dev OWNER teiserver_dev; +CREATE DATABASE teiserver_test OWNER teiserver_test; diff --git a/docker/teiserver/docker-entrypoint.sh b/docker/teiserver/docker-entrypoint.sh new file mode 100644 index 0000000000..6461ac70a3 --- /dev/null +++ b/docker/teiserver/docker-entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +echo "Starting Teiserver development setup..." + +if [ "${GENERATE_FAKE_DATA}" = "true" ]; then + mix teiserver.fakedata +else + echo "Running migrations..." + mix ecto.migrate + echo "Migrations complete" +fi + + +echo "Setting up tachyon clients..." +mix teiserver.tachyon_setup +echo "Tachyon setup complete" + + +echo "Setup complete! Starting Phoenix server..." +exec "$@" diff --git a/docker/victoriametrics/prometheus.yml b/docker/victoriametrics/prometheus.yml new file mode 100644 index 0000000000..25862b9cbc --- /dev/null +++ b/docker/victoriametrics/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: teiserver + metrics_path: /metrics + static_configs: + - targets: ["teiserver:4001"] diff --git a/lib/teiserver/mix_tasks/fake_data.ex b/lib/teiserver/mix_tasks/fake_data.ex index aa5c5cc8d8..9c32776893 100644 --- a/lib/teiserver/mix_tasks/fake_data.ex +++ b/lib/teiserver/mix_tasks/fake_data.ex @@ -5,6 +5,8 @@ defmodule Mix.Tasks.Teiserver.Fakedata do use Mix.Task + @requirements ["app.config"] + alias Teiserver.{Account, Logging, Battle, Moderation} alias Teiserver.Helper.StylingHelper alias Teiserver.Battle.MatchLib @@ -25,6 +27,26 @@ defmodule Mix.Tasks.Teiserver.Fakedata do @spec run(list()) :: :ok def run(_args) do + Application.ensure_all_started([:ecto, :ecto_sql, :tzdata]) + + already_has_data = + try do + case Ecto.Migrator.with_repo(Teiserver.Repo, fn repo -> + repo.aggregate(Teiserver.Account.User, :count, :id) > 3 + end) do + {:ok, result, _} -> result + _ -> false + end + rescue + # DB doesn't exist or account_users table missing – treat as no data. + _ -> false + end + + if already_has_data do + IO.puts("Demo data already present, skipping generation") + exit(:normal) + end + # Start by rebuilding the database Mix.Task.run("ecto.reset") @@ -38,7 +60,7 @@ defmodule Mix.Tasks.Teiserver.Fakedata do make_moderation() make_one_time_code() - # Add fake playtime data to all our non-bot users + # Add fake playtime data to all our non-bot users. Mix.Task.run("teiserver.fake_playtime") :timer.sleep(50) diff --git a/lib/teiserver/mix_tasks/tachyon_setup.ex b/lib/teiserver/mix_tasks/tachyon_setup.ex index 80f6e3d4f2..710d6a3db2 100644 --- a/lib/teiserver/mix_tasks/tachyon_setup.ex +++ b/lib/teiserver/mix_tasks/tachyon_setup.ex @@ -11,6 +11,9 @@ defmodule Mix.Tasks.Teiserver.TachyonSetup do @shortdoc "setup oauth apps for tachyon" use Mix.Task + + @requirements ["app.config"] + alias Teiserver.Tachyon.Tasks.{SetupApps, SetupAssets} @impl Mix.Task diff --git a/lib/teiserver/tachyon/tasks/setup_assets.ex b/lib/teiserver/tachyon/tasks/setup_assets.ex index a994822664..bdbcad7a86 100644 --- a/lib/teiserver/tachyon/tasks/setup_assets.ex +++ b/lib/teiserver/tachyon/tasks/setup_assets.ex @@ -16,7 +16,7 @@ defmodule Teiserver.Tachyon.Tasks.SetupAssets do defp create_engine() do # the engine version can be found by running `spring -version` - case Asset.create_engine(%{name: "2025.01.6", in_matchmaking: true}) do + case Asset.create_engine(%{name: "2025.06.19", in_matchmaking: true}) do {:ok, engine} -> {:ok, {:created, engine}} {:error, changeset} -> {:error, {:create, changeset}} end diff --git a/mise.toml b/mise.toml index 922e3f9152..e99b788c64 100644 --- a/mise.toml +++ b/mise.toml @@ -1,12 +1,8 @@ [tools] -postgres = "15" erlang = "26.2.5.1" elixir = "1.19.4-otp-26" [vars] -pg_data = "tmp/postgres/data" -pg_ctl = "pg_ctl -D {{vars.pg_data}} -l tmp/postgres.log" -pg_status = "({{vars.pg_ctl}} status >/dev/null 2>&1)" certs_path = "priv/certs" [env] @@ -17,9 +13,17 @@ TEI_TLS_CERT_PATH = "{{vars.certs_path}}/localhost.crt" TEI_TLS_CA_CERT_PATH = "{{vars.certs_path}}/localhost.crt" TEI_TLS_DH_FILE_PATH = "{{vars.certs_path}}/dh-params.pem" PGPASSWORD = "123456789" +TEI_ENABLE_EMAIL_INTEGRATION = "true" +TEI_SMTP_SERVER = "127.0.0.1" +TEI_SMTP_HOSTNAME = "localhost" +TEI_SMTP_USERNAME = "" +TEI_SMTP_PASSWORD = "" +TEI_SMTP_PORT = "1025" +TEI_SMTP_TLS = "never" +TEI_SMTP_TLS_VERIFY = "false" +TEI_SMTP_AUTH = "never" [tasks."setup:db"] -depends = ["pg:start"] description = "Sets up the application database" run = "mix do ecto.drop + ecto.setup" @@ -35,43 +39,13 @@ description = "On demand task that sets up fake data for testing" run = "mix teiserver.fakedata" [tasks.setup] -depends = ["pg:setup", "setup:deps"] +depends = ["setup:deps"] description = "Sets up the application dependencies from scratch" run = [{ task = "setup:certs:*" }, { tasks = ["setup:sass", "setup:db"] }] [tasks."setup:sass"] run = "mix sass.install" -[tasks."pg:start"] -description = "Starts the local postgres instance" -run = "{{vars.pg_status}} || {{vars.pg_ctl}} start" - -[tasks."pg:stop"] -description = "Stops the local postgres instance" -run = ''' -{{vars.pg_status}} && {{vars.pg_ctl}} stop || echo "Server already stopped" -''' - -[tasks."pg:setup"] -description = "Sets up the local postgres instance" -confirm = "This will reset your local postgres installation from scratch including data loss, are you sure?" -depends = ["pg:stop"] -run = [ - "rm -rf tmp/postgres", - "mkdir -p tmp/", - "initdb --username=postgres {{vars.pg_data}}", - { task = "pg:start" }, - """ - psql -U postgres <