diff --git a/conf/dist/env.production b/conf/dist/env.production new file mode 100644 index 0000000..7e8df6d --- /dev/null +++ b/conf/dist/env.production @@ -0,0 +1,29 @@ +# Production environment — copy to .env before deploying +# cp .env.production .env + +# Docker image tag (master = latest stable build) +DOCKER_IMAGE_TAG=master + +# Database root password — CHANGE THIS +DOCKER_DB_ROOT_PASSWORD=CHANGE_ME_STRONG_PASSWORD + +# External ports +DOCKER_WORLD_EXTERNAL_PORT=8085 +DOCKER_AUTH_EXTERNAL_PORT=3724 +DOCKER_SOAP_EXTERNAL_PORT=7878 +DOCKER_DB_EXTERNAL_PORT=3306 + +# Run server as this user (match host UID/GID to avoid volume permission issues) +# Run: id -u && id -g to get your values +DOCKER_USER=acore +DOCKER_USER_ID=1000 +DOCKER_GROUP_ID=1000 + +# Volume paths (use absolute paths in production) +DOCKER_VOL_ETC=./env/dist/etc +DOCKER_VOL_LOGS=./env/dist/logs +DOCKER_VOL_DATA=ac-client-data + +# Game rates (can also be set in worldserver.conf) +AC_RATE_XP_KILL=1 +ACORE_PLAYERBOTS_RANDOM_BOT_AUTOLOGIN=0 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2518cbb --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,43 @@ +services: + ac-database: + command: > + --innodb_buffer_pool_size=4G + --innodb_redo_log_capacity=1G + --innodb_flush_log_at_trx_commit=2 + --innodb_flush_method=O_DIRECT + --max_connections=300 + --slow_query_log=1 + --long_query_time=2 + deploy: + resources: + limits: + cpus: '2' + memory: 5G + + ac-worldserver: + deploy: + resources: + limits: + cpus: '6' + memory: 6G + ulimits: + nofile: + soft: 65536 + hard: 65536 + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" + + ac-authserver: + deploy: + resources: + limits: + cpus: '1' + memory: 512M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/scripts/db-export.sh b/scripts/db-export.sh new file mode 100644 index 0000000..c245ef2 --- /dev/null +++ b/scripts/db-export.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}" +OUTPUT_FILE="" +COMPRESS=1 + +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +usage() { + cat <&2; exit 1; } + +require_running_db() { + if ! docker compose ps --status running --services 2>/dev/null | grep -qx 'ac-database'; then + die "ac-database is not running. Start it with: docker compose up -d ac-database" + fi +} + +# Returns space-separated list of user databases (excludes MySQL system schemas) +get_user_databases() { + docker compose exec -T ac-database bash -lc \ + "mysql -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -s information_schema" \ + <<<"SELECT schema_name FROM schemata + WHERE schema_name NOT IN ('mysql','information_schema','performance_schema','sys') + ORDER BY schema_name;" +} + +# ── argument parsing ────────────────────────────────────────────────────────── + +while (($#)); do + case "$1" in + --output) OUTPUT_FILE="$2"; shift ;; + --no-compress) COMPRESS=0 ;; + -h|--help) usage; exit 0 ;; + *) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 1 ;; + esac + shift +done + +# ── main ────────────────────────────────────────────────────────────────────── + +cd "$ROOT_DIR" + +require_running_db + +if [[ -z "$OUTPUT_FILE" ]]; then + mkdir -p "$ROOT_DIR/backups" + timestamp="$(date '+%Y-%m-%d_%H-%M-%S')" + if [[ "$COMPRESS" -eq 1 ]]; then + OUTPUT_FILE="$ROOT_DIR/backups/${timestamp}.sql.gz" + else + OUTPUT_FILE="$ROOT_DIR/backups/${timestamp}.sql" + fi +fi + +mkdir -p "$(dirname "$OUTPUT_FILE")" + +log "discovering user databases..." +databases="$(get_user_databases | tr '\n' ' ' | xargs)" +[[ -z "$databases" ]] && die "no user databases found" +log "databases: $databases" +log "exporting → $OUTPUT_FILE" +log "this may take a few minutes for large worlds..." + +if [[ "$COMPRESS" -eq 1 ]]; then + docker compose exec -T ac-database bash -lc \ + "mysqldump -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \ + --databases $databases \ + --add-drop-database \ + --single-transaction \ + --quick \ + --routines \ + --triggers \ + --events" \ + | gzip -6 > "$OUTPUT_FILE" +else + docker compose exec -T ac-database bash -lc \ + "mysqldump -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \ + --databases $databases \ + --add-drop-database \ + --single-transaction \ + --quick \ + --routines \ + --triggers \ + --events" \ + > "$OUTPUT_FILE" +fi + +log "done: $OUTPUT_FILE ($(du -sh "$OUTPUT_FILE" | cut -f1))" +log "" +log "to restore on the new server:" +if [[ "$OUTPUT_FILE" == *.gz ]]; then + log " bash scripts/db-import.sh --from $OUTPUT_FILE" +else + log " bash scripts/db-import.sh --from $OUTPUT_FILE" +fi diff --git a/scripts/db-import.sh b/scripts/db-import.sh new file mode 100644 index 0000000..a307360 --- /dev/null +++ b/scripts/db-import.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}" +DUMP_FILE="" +DRY_RUN=0 +FORCE=0 + +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +usage() { + cat <&2; exit 1; } + +require_ready_db() { + if ! docker compose ps --status running --services 2>/dev/null | grep -qx 'ac-database'; then + die "ac-database is not running. Start it with: docker compose up -d ac-database" + fi + + log "waiting for MySQL to be ready..." + local attempts=0 + until docker compose exec -T ac-database bash -lc \ + "mysql -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -e 'SELECT 1;'" \ + >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [[ $attempts -ge 30 ]]; then + die "MySQL did not become ready in time. Check: docker logs ac-database" + fi + sleep 2 + done + log "MySQL is ready" +} + +mysql_exec() { + docker compose exec -T ac-database bash -lc \ + "mysql -h 127.0.0.1 -uroot -p\"\$MYSQL_ROOT_PASSWORD\"" +} + +# ── argument parsing ────────────────────────────────────────────────────────── + +while (($#)); do + case "$1" in + --from) DUMP_FILE="$2"; shift ;; + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + -h|--help) usage; exit 0 ;; + *) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 1 ;; + esac + shift +done + +# ── validation ──────────────────────────────────────────────────────────────── + +[[ -z "$DUMP_FILE" ]] && { usage >&2; die "--from FILE is required"; } +[[ -f "$DUMP_FILE" ]] || die "file not found: $DUMP_FILE" + +cd "$ROOT_DIR" + +# ── preview ─────────────────────────────────────────────────────────────────── + +file_size="$(du -sh "$DUMP_FILE" | cut -f1)" + +printf '\nMigration plan:\n' +printf ' Source : %s (%s)\n' "$DUMP_FILE" "$file_size" +printf ' Target : ac-database container\n\n' + +if [[ "$DRY_RUN" -eq 1 ]]; then + log "dry-run mode — nothing imported" + exit 0 +fi + +if [[ "$FORCE" -eq 0 ]]; then + printf 'WARNING: this will DROP and recreate all databases in the dump.\n' + read -r -p "Continue? [y/N] " confirm + [[ "$confirm" =~ ^[Yy]$ ]] || { log "aborted"; exit 0; } +fi + +# ── import ──────────────────────────────────────────────────────────────────── + +require_ready_db + +log "restoring $DUMP_FILE into ac-database..." +log "this may take several minutes..." + +if [[ "$DUMP_FILE" == *.gz ]]; then + zcat "$DUMP_FILE" | mysql_exec +else + mysql_exec < "$DUMP_FILE" +fi + +log "import complete" +log "" +log "next steps:" +log " 1. Update realmlist: bash sync-realmlist.sh" +log " 2. Start the server: bash scripts/prod-deploy.sh --no-build" diff --git a/scripts/prod-deploy.sh b/scripts/prod-deploy.sh new file mode 100644 index 0000000..40a5ad4 --- /dev/null +++ b/scripts/prod-deploy.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}" +BUILD_FLAG="--build" + +# Production compose stack — layered on top of the standard override +COMPOSE="docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.prod.yml" + +# ── helpers ────────────────────────────────────────────────────────────────── + +log() { printf '[prod-deploy] %s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +print_service_logs() { + local service="$1" + printf '\n[%s logs]\n' "$service" >&2 + $COMPOSE logs --tail=120 "$service" >&2 || true + printf '\n' >&2 +} + +wait_for_healthy() { + local container_name="$1" + local timeout_seconds="${2:-180}" + local start_ts now status + + start_ts="$(date +%s)" + while :; do + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" + case "$status" in + healthy|running) return 0 ;; + unhealthy|exited|dead) return 1 ;; + esac + now="$(date +%s)" + if (( now - start_ts >= timeout_seconds )); then return 1; fi + sleep 2 + done +} + +run_one_shot_service() { + local service="$1" + local container_name="$2" + + if [[ -n "$BUILD_FLAG" ]]; then + if ! $COMPOSE up --build --no-deps "$service"; then + print_service_logs "$service" + printf '%s failed during startup\n' "$service" >&2 + exit 1 + fi + else + if ! $COMPOSE up --no-deps "$service"; then + print_service_logs "$service" + printf '%s failed during startup\n' "$service" >&2 + exit 1 + fi + fi + + local exit_code + exit_code="$(docker inspect -f '{{.State.ExitCode}}' "$container_name" 2>/dev/null || printf '1')" + if [[ "$exit_code" != "0" ]]; then + print_service_logs "$service" + printf '%s failed with exit code %s\n' "$service" "$exit_code" >&2 + exit 1 + fi +} + +require_docker() { + if ! command -v docker >/dev/null 2>&1; then + die "docker is not installed or not in PATH" + fi + if ! docker compose version >/dev/null 2>&1; then + die "docker compose plugin is not available. On Ubuntu: apt install docker-compose-plugin" + fi + if ! docker info >/dev/null 2>&1; then + die "docker daemon is not reachable. Run: sudo systemctl enable --now docker" + fi +} + +ensure_host_permissions() { + local target_uid="${DOCKER_USER_ID:-1000}" + local target_gid="${DOCKER_GROUP_ID:-1000}" + local dirs=( + "$ROOT_DIR/env/dist/etc" + "$ROOT_DIR/env/dist/logs" + "$ROOT_DIR/env/dist/temp" + ) + + mkdir -p "${dirs[@]}" + + if [[ "$(id -u)" -eq 0 ]]; then + chown -R "${target_uid}:${target_gid}" "${dirs[@]}" + return 0 + fi + + local dir + for dir in "${dirs[@]}"; do + if [[ ! -w "$dir" ]]; then + printf 'host directory is not writable: %s\n' "$dir" >&2 + printf 'Run: sudo chown -R %s:%s %q %q %q\n' \ + "$target_uid" "$target_gid" \ + "${dirs[0]}" "${dirs[1]}" "${dirs[2]}" >&2 + exit 1 + fi + done +} + +apply_sysctl() { + log "applying sysctl settings..." + sysctl -w net.core.somaxconn=4096 2>/dev/null || true + sysctl -w net.ipv4.tcp_max_syn_backlog=4096 2>/dev/null || true + sysctl -w fs.file-max=100000 2>/dev/null || true + # Prevent Docker conntrack table overflow (causes sudden disconnects) + sysctl -w net.netfilter.nf_conntrack_max=131072 2>/dev/null || true + sysctl -w net.ipv4.tcp_keepalive_time=120 2>/dev/null || true + sysctl -w net.ipv4.tcp_keepalive_intvl=30 2>/dev/null || true + sysctl -w net.ipv4.tcp_keepalive_probes=5 2>/dev/null || true +} + +# ── argument parsing ────────────────────────────────────────────────────────── + +usage() { + cat <&2; usage >&2; exit 1 ;; + esac + shift +done + +# ── pre-flight ──────────────────────────────────────────────────────────────── + +if [[ ! -f "$ENV_FILE" ]]; then + if [[ -f "$ROOT_DIR/.env.production" ]]; then + log ".env not found — copying from .env.production" + cp "$ROOT_DIR/.env.production" "$ENV_FILE" + die "Fill in $ENV_FILE (at minimum DOCKER_DB_ROOT_PASSWORD) and re-run." + else + die "$ENV_FILE not found. Create it based on .env.production." + fi +fi + +if grep -q "CHANGE_ME" "$ENV_FILE"; then + die "Change DOCKER_DB_ROOT_PASSWORD in $ENV_FILE before deploying to production." +fi + +# Source env so ensure_host_permissions picks up DOCKER_USER_ID / DOCKER_GROUP_ID +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +cd "$ROOT_DIR" + +require_docker + +if [[ $EUID -eq 0 ]]; then + apply_sysctl +else + log "not root — skipping sysctl (run with sudo to apply kernel settings)" +fi + +# ── startup sequence (mirrors start-server.sh) ─────────────────────────────── + +log "applying module configuration" +bash "$ROOT_DIR/setup-modules.sh" + +log "ensuring host directory permissions" +ensure_host_permissions + +log "starting database" +if [[ -n "$BUILD_FLAG" ]]; then + $COMPOSE up -d --build ac-database +else + $COMPOSE up -d ac-database +fi + +if ! wait_for_healthy "ac-database" 180; then + print_service_logs "ac-database" + die "ac-database did not become healthy" +fi + +log "running database import" +run_one_shot_service "ac-db-import" "ac-db-import" + +log "importing custom SQL" +bash "$ROOT_DIR/import-custom-sql.sh" + +log "applying post-database module setup" +bash "$ROOT_DIR/setup-modules.sh" + +log "initializing client data" +run_one_shot_service "ac-client-data-init" "ac-client-data-init" + +log "starting authserver and worldserver" +if [[ -n "$BUILD_FLAG" ]]; then + $COMPOSE up -d --build ac-authserver ac-worldserver +else + $COMPOSE up -d ac-authserver ac-worldserver +fi + +log "synchronizing realmlist" +bash "$ROOT_DIR/sync-realmlist.sh" + +log "current service status" +$COMPOSE ps + +printf '\n' +printf 'Realm: %s:%s\n' "${ACORE_REALMLIST_PUBLIC_ADDRESS:-127.0.0.1}" "${ACORE_REALMLIST_PORT:-${DOCKER_WORLD_EXTERNAL_PORT:-8085}}" +printf 'World: 127.0.0.1:%s\n' "${DOCKER_WORLD_EXTERNAL_PORT:-8085}" +printf 'Auth: 127.0.0.1:%s\n' "${DOCKER_AUTH_EXTERNAL_PORT:-3724}" +printf 'SOAP: 127.0.0.1:%s\n' "${DOCKER_SOAP_EXTERNAL_PORT:-7878}" +printf 'Account: ./create-account.sh admin your_password\n' +printf 'Logs: %s logs -f ac-worldserver ac-authserver\n' "$COMPOSE"