#!/bin/sh
# =============================================================================
#  m2-updater -- the other side of the panel's update request.
#
#  WHAT THIS IS FOR
#  ----------------
#  Updating this server means refreshing a checkout, re-staging a build context,
#  rebuilding images and recreating containers.  All of that needs the Docker
#  socket, and the Docker socket is root on the host: anything holding it can
#  start a container with the host's filesystem mounted inside it and do
#  whatever it likes there.
#
#  The admin panel must therefore never hold it.  The panel faces the internet,
#  takes registrations from strangers, sends password links and serves a
#  gigabyte file.  Handing it the socket would turn its next bug into a host
#  takeover.
#
#  So this is the same shape as the rates change (see panel/bin/apply_rates.sh
#  and game/bin/m2-rates), for the same reason:
#
#      panel container                    updater container (this file)
#      ---------------                    -----------------------------
#      writes $SPOOL/request       ---->  poll, every 5 seconds
#                                            |
#                                            v
#                                         one fixed sequence, written below
#                                            |
#        reads $SPOOL/update.status <-----  status + log as it goes
#        reads $SPOOL/update.log
#
#  The socket is mounted into THIS, which does exactly one thing, and not into
#  the panel, which does everything else.
#
#  WHAT THE PANEL CAN MAKE THIS DO
#  -------------------------------
#  Run the sequence below.  That is the whole list.
#
#  The request file is read for exactly two fields: `id', used only to tell a
#  new request from one already carried out, and `version', which is written
#  into the log and never acted on.  There is no field for a command, a URL, a
#  branch, a path or an image name, and nothing out of the request is ever
#  passed to a shell.  A panel that has been completely taken over can make this
#  container update the server, repeatedly, and nothing else.
#
#  WHAT IT WILL NOT DO, EVER
#  -------------------------
#  Remove a volume.  `docker compose down' does not appear in this file in any
#  form and neither does `-v'; the update is `up -d --build', which recreates
#  containers and leaves every volume where it is.  The database -- every
#  account, character, item and guild on the server -- is in a volume.
#
#  It also never rewrites .env or docker-compose.override.yml: the new context
#  is unpacked over the old one with tar, which writes the files it carries and
#  leaves everything else alone.  Those two are not in the context, so the
#  operator's settings and the installer's port choices survive.
#
#  ENABLING IT
#  -----------
#  It is in the `update' compose profile, so `docker compose up -d' never starts
#  it.  It runs only when asked for by name:
#
#      docker compose --profile update up -d updater
#
#  Being in a non-default profile is also what stops it cutting its own throat:
#  `docker compose up -d --build', which is what it runs, only touches services
#  in the active profiles, so it does not recreate the container the command is
#  running inside.  (Updating the updater itself is therefore a deliberate act
#  -- see UPDATING.md.)
#
#  Subcommands:
#      watch     (default) poll the spool forever
#      once      carry out one update now, then exit -- for trying it by hand
#      selftest  check everything it needs, change nothing, and report
# =============================================================================
set -u
LC_ALL=C
export LC_ALL

SPOOL="${M2_UPDATE_SPOOL:-/opt/m2update}"
REQUEST="$SPOOL/request"
STATUS="$SPOOL/update.status"
LOG="$SPOOL/update.log"
BEAT="$SPOOL/watcher"
LAST_ID="$SPOOL/last-id"
OUT="$SPOOL/.out"

# The host paths, mounted into this container at the SAME path they have on the
# host.  That is not tidiness: `docker compose' hands the daemon a path, and the
# daemon resolves the compose file's relative bind mounts (./mariadb/conf.d and
# the rest) against the HOST filesystem.  Mounted anywhere else, every one of
# them would point at a directory that does not exist out there.
STACK_DIR="${M2_UPDATE_STACK_DIR:-/opt/metin2/stack}"
CACHE_DIR="${M2_UPDATE_CACHE_DIR:-/var/cache/m2src}"
REPO_DIR="${M2_UPDATE_REPO_DIR:-$CACHE_DIR/repo}"
BRANCH="${M2_UPDATE_BRANCH:-main}"

POLL="${M2_UPDATE_POLL:-5}"
# A ceiling on each long step, so that a build which has genuinely wedged is
# reported as a failure instead of leaving the panel's progress page turning
# forever.  Generous: a full recompile of the game on a small VPS is slow.
DEADLINE="${M2_UPDATE_TIMEOUT:-5400}"

# The spool is shared with the panel, which runs as another user in the group
# whose gid is 2050 in every image in this stack.
SPOOL_GID="${M2_SPOOL_GID:-2050}"

STEP=0
STEPS=5
RC=0

TIMEOUT_BIN=""
command -v timeout >/dev/null 2>&1 && TIMEOUT_BIN="timeout"

now()  { date -u '+%Y-%m-%d %H:%M:%SZ'; }
# Everything goes to the container log AND to the file the panel shows, so that
# `docker compose logs updater' and the browser say the same words.  There is no
# second version of events.
say()  { printf '%s [updater] %s\n' "$(now)" "$*"; }
note() { say "$*"; printf '%s %s\n' "$(now)" "$*" >>"$LOG" 2>/dev/null; }

# set_status STATE MESSAGE -- the file the panel reads.  The same key=value
# shape the rates spool uses, because the panel's reader is the same shape.
set_status() {
    ( umask 007
      { printf 'state=%s\n'   "$1"
        printf 'time=%s\n'    "$(date +%s 2>/dev/null)"
        printf 'step=%s\n'    "$STEP"
        printf 'steps=%s\n'   "$STEPS"
        printf 'message=%s\n' "$2"
      } > "$STATUS.new" ) && mv "$STATUS.new" "$STATUS"
    return 0
}

step() {
    STEP=$((STEP + 1))
    note ""
    note "== [$STEP/$STEPS] $*"
    set_status running "$*"
}

fail() {
    note ""
    note "!! $1"
    note "   Nothing was removed and no data was touched. The server is still"
    note "   running the version it had."
    set_status failed "$1"
    RC=1
}

# run COMMAND... -- run it under the deadline, put every line it printed into
# the log, and return its own exit status.  The command is a list of arguments,
# never a string: nothing here is handed to a shell to be parsed.
run() {
    if [ -n "$TIMEOUT_BIN" ]; then
        "$TIMEOUT_BIN" "$DEADLINE" "$@" >"$OUT" 2>&1
    else
        "$@" >"$OUT" 2>&1
    fi
    _rc=$?
    while IFS= read -r _line; do note "   $_line"; done < "$OUT"
    rm -f "$OUT" 2>/dev/null
    [ "$_rc" = 124 ] && note "   (gave up after ${DEADLINE}s)"
    return $_rc
}

kv() {  # kv FILE KEY -> the value of a "key=value" line, empty when there is none
    [ -f "$1" ] || return 0
    sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1
}

# A fingerprint of the port patch, to notice when the patched source tree in the
# cache has to be built again.  A missing hashing tool is not an error: the sum
# is then empty on both sides, compares equal, and the answer is "no rebuild
# needed" -- the fast, safe one.
patch_sum() {
    cat "$REPO_DIR/linux-port/patches/"*.patch 2>/dev/null \
        | { sha256sum 2>/dev/null || md5sum 2>/dev/null || cat; } | head -1
}

repo_version() {
    tr -d ' \r\n' < "$REPO_DIR/VERSION" 2>/dev/null || printf 'unknown'
}

# ---------------------------------------------------------------------------
#  Which line this stack is.  The 2.x (mt2009) line ships an ENGINE file with
#  the word mt2009 beside its docker-compose.yml and updates from the release
#  package with linux-port/tools/update.sh -- never from this checkout.  What
#  this script copies over the stack is the repository's linux-port/docker,
#  and on a 2.x server that is the r40250 compose file: MariaDB 10.11 started
#  on a database 11.8 made, and two log indexes damaged in the two minutes it
#  ran (seban latino, 15 September, through `docker compose exec updater
#  m2-updater', which the 2.x compose file's own entrypoint never runs).
#  Without the ENGINE file, a compose file naming MariaDB 11 says the same.
# ---------------------------------------------------------------------------
stack_engine() {
    for _f in "$STACK_DIR/ENGINE" "$STACK_DIR/linux-port/docker/ENGINE"; do
        if [ -f "$_f" ]; then
            tr -d ' \r\n' < "$_f" 2>/dev/null
            return 0
        fi
    done
    for _f in "$STACK_DIR/docker-compose.yml" "$STACK_DIR/linux-port/docker/docker-compose.yml"; do
        if grep -q 'image: mariadb:11\.' "$_f" 2>/dev/null; then
            printf 'mt2009'
            return 0
        fi
    done
    printf 'r40250'
}

refuse_mt2009() {
    note "!! this server runs the 2.x (mt2009) line, and m2-updater updates only the 1.x line."
    note "   It would copy the repository's 1.x build files over this server - among them a"
    note "   docker-compose.yml that starts MariaDB 10.11 on a database made by 11.8."
    note "   The 2.x line updates from the release package instead:"
    note "       sh linux-port/tools/update.sh        (from the server folder)"
    note "   and the updater container started from this server's own docker-compose.yml"
    note "   runs exactly that when the panel asks. Nothing was changed."
    set_status failed "this is a 2.x (mt2009) server: it updates with linux-port/tools/update.sh, not m2-updater"
    RC=1
}

# ---------------------------------------------------------------------------
#  Is everything this needs actually here?
# ---------------------------------------------------------------------------
check_env() {
    _bad=0
    for _t in git tar docker sh; do
        command -v "$_t" >/dev/null 2>&1 || { note "   missing tool: $_t"; _bad=1; }
    done
    docker compose version >/dev/null 2>&1 \
        || { note "   'docker compose' is not available in this image"; _bad=1; }
    docker info >/dev/null 2>&1 \
        || { note "   the Docker socket is not reachable from this container"; _bad=1; }
    [ -d "$REPO_DIR/.git" ] \
        || { note "   there is no git checkout at $REPO_DIR"; _bad=1; }
    [ -f "$STACK_DIR/docker-compose.yml" ] \
        || { note "   there is no docker-compose.yml in $STACK_DIR"; _bad=1; }
    [ -w "$STACK_DIR" ] \
        || { note "   $STACK_DIR is not writable from this container"; _bad=1; }
    return $_bad
}

# ---------------------------------------------------------------------------
#  Build and start.  Split out only because the override file makes it two
#  spellings of one command, and because it is the step worth reading twice.
#
#  What is NOT here, deliberately:
#    down, down -v     `down -v' destroys the database volume -- every account
#                      and character on the server. It is not in this file and
#                      it must never be added to it.
#    --remove-orphans  it would count this very container as an orphan (it is
#                      in a profile that is not active) and stop it in the
#                      middle of its own update.
#    --force-recreate  it would restart the game even when nothing about the
#                      game changed, for no reason but tidiness.
# ---------------------------------------------------------------------------
compose_up() {
    if [ -f "$STACK_DIR/docker-compose.override.yml" ]; then
        run docker compose --project-directory "$STACK_DIR" \
            -f "$STACK_DIR/docker-compose.yml" \
            -f "$STACK_DIR/docker-compose.override.yml" \
            up -d --build
    else
        run docker compose --project-directory "$STACK_DIR" \
            -f "$STACK_DIR/docker-compose.yml" \
            up -d --build
    fi
}

# ---------------------------------------------------------------------------
#  The update.  Five steps, in this order, and nothing else.
# ---------------------------------------------------------------------------
do_update() {
    STEP=0
    RC=0
    : > "$LOG" 2>/dev/null
    chmod 0660 "$LOG" 2>/dev/null
    note "update requested (the panel was told the published version is ${1:-unknown})"
    note "checkout  $REPO_DIR"
    note "stack     $STACK_DIR"
    note "branch    $BRANCH"
    note ""
    set_status running "starting"

    if [ "$(stack_engine)" = mt2009 ]; then
        refuse_mt2009
        return 1
    fi

    if ! check_env; then
        fail "this container is missing something it needs -- see the lines above"
        return 1
    fi

    # -- 1. refresh the checkout ------------------------------------------
    # --depth 1 keeps it the few megabytes it has always been. reset --hard
    # against FETCH_HEAD rather than a merge: the checkout is a copy of the
    # published branch, nothing local is ever committed into it, so there is
    # never anything to merge and a conflict could only ever be a surprise.
    step "getting the newest version of the project"
    _before=$(git -C "$REPO_DIR" rev-parse HEAD 2>/dev/null || echo none)
    _patch_before=$(patch_sum)
    if ! run git -C "$REPO_DIR" fetch --depth 1 origin "$BRANCH"; then
        fail "the project could not be fetched -- no internet, or GitHub is unreachable"
        return 1
    fi
    if ! run git -C "$REPO_DIR" reset --hard FETCH_HEAD; then
        fail "the checkout could not be moved to the new version"
        return 1
    fi
    note "   $_before -> $(git -C "$REPO_DIR" rev-parse HEAD 2>/dev/null || echo '?')"
    note "   the checkout now says version $(repo_version)"

    # -- 2. re-stage the build context ------------------------------------
    # fetch-sources.sh is idempotent and skips everything it has already done,
    # so on an installed machine this is seconds: the upstream package is in
    # the cache and only prepare-context.sh really runs, re-copying the panel,
    # the scripts and the server-files profile out of the refreshed checkout.
    #
    # Unless the port patch itself changed -- then the patched source tree in
    # the cache is stale and has to be built from the new patch, which is what
    # `--force restage' does. Checked rather than assumed, because forcing it
    # every time would add ten minutes to every update.
    step "preparing the new build"
    if [ "$_patch_before" != "$(patch_sum)" ]; then
        note "   the Linux port itself changed -- the source tree is rebuilt from it"
        run sh "$REPO_DIR/linux-port/fetch-sources.sh" fetch --cache "$CACHE_DIR" --force restage
    else
        run sh "$REPO_DIR/linux-port/fetch-sources.sh" fetch --cache "$CACHE_DIR"
    fi || {
        fail "the new build could not be prepared -- see the lines above"
        return 1
    }

    # -- 3. copy the context over the installed stack ----------------------
    # tar rather than cp -r or rsync: it writes the files it carries and does
    # not delete anything it does not carry. .env (the operator's settings,
    # including every password) and docker-compose.override.yml (what the
    # installer chose about ports and addresses) are not in the context, so
    # they are not in the tar, so they survive untouched. This is also why
    # there is no --delete anywhere near this step.
    step "putting the new server files in place"
    if ! ( cd "$REPO_DIR/linux-port/docker" && tar cf - . ) | ( cd "$STACK_DIR" && tar xf - ); then
        fail "the new server files could not be written into $STACK_DIR"
        return 1
    fi
    note "   .env and docker-compose.override.yml were left exactly as they are"

    # -- 4. build and start ------------------------------------------------
    step "building and restarting -- players are disconnected from here on"
    if ! compose_up; then
        fail "the new version was not built or not started -- the log above says where it stopped"
        return 1
    fi

    # -- 5. done -----------------------------------------------------------
    step "finished"
    note ""
    note "the server is now running version $(repo_version)"
    note "if the game itself was rebuilt it may take another minute to accept logins"
    set_status ok "the server is running version $(repo_version)"
    return 0
}

# ---------------------------------------------------------------------------
#  The spool
# ---------------------------------------------------------------------------
prepare_spool() {
    mkdir -p "$SPOOL" 2>/dev/null
    # setgid + the shared group, so that a file this container creates can be
    # read and replaced by the panel, which is a different user. The panel's
    # own entrypoint does the same to the rates spool; whichever of the two
    # gets there first sets it up and the other agrees with it.
    chgrp "$SPOOL_GID" "$SPOOL" 2>/dev/null && chmod 2770 "$SPOOL" 2>/dev/null \
        || say "WARNING: could not set the group on $SPOOL -- the panel may not be able to leave a request there"
    umask 007
    { : >> "$LOG"; } 2>/dev/null
    chmod 0660 "$LOG" 2>/dev/null
}

# ---------------------------------------------------------------------------
#  Subcommands
# ---------------------------------------------------------------------------
cmd_selftest() {
    note "self-test -- nothing is changed"
    if [ "$(stack_engine)" = mt2009 ]; then
        refuse_mt2009
        return 1
    fi
    if check_env; then
        note "   everything it needs is here"
        note "   spool     $SPOOL"
        note "   checkout  $REPO_DIR ($(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo '?'), version $(repo_version))"
        note "   stack     $STACK_DIR"
        return 0
    fi
    note "   as it stands it would not be able to update this server"
    return 1
}

cmd_watch() {
    say "watching $SPOOL for update requests (every ${POLL}s)"
    say "stack $STACK_DIR, checkout $REPO_DIR, branch $BRANCH"
    [ "$(stack_engine)" = mt2009 ] \
        && say "NOTE: this is a 2.x (mt2009) server -- every request is refused; its updater is linux-port/tools/update.sh"
    check_env >/dev/null 2>&1 \
        || say "NOTE: something it needs is missing -- 'm2-updater selftest' says what"
    while :; do
        # The heartbeat. The panel offers its update button only while this
        # file is fresh, so an updater that is not running means no button --
        # rather than a button that quietly does nothing.
        ( umask 007; : > "$BEAT" ) 2>/dev/null

        if [ -f "$REQUEST" ]; then
            _id=$(kv "$REQUEST" id)
            _seen=$(cat "$LAST_ID" 2>/dev/null || echo "")
            if [ -n "$_id" ] && [ "$_id" != "$_seen" ]; then
                # Written BEFORE the work rather than after: if this container
                # dies halfway through an update, the request must not be
                # picked up and run all over again when it comes back.
                ( umask 007; printf '%s' "$_id" > "$LAST_ID" ) 2>/dev/null
                _ver=$(kv "$REQUEST" version)
                # The version is a label for the log and nothing else. Anything
                # that is not digits and dots is not shown at all, rather than
                # shown as whatever it happened to be.
                case "${_ver:-x}" in
                    *[!0-9.]*) _ver="unknown" ;;
                esac
                do_update "${_ver:-unknown}" || true
            fi
        fi
        sleep "$POLL"
    done
}

prepare_spool

case "${1:-watch}" in
    watch)    cmd_watch ;;
    once)     do_update "${2:-manual}" ;;
    selftest) cmd_selftest ;;
    -h|--help|help) sed -n '2,74p' "$0" | sed 's/^# \{0,1\}//' ;;
    *) printf 'm2-updater: unknown command: %s (try watch, once or selftest)\n' "$1" >&2
       exit 2 ;;
esac
