#!/usr/bin/env bash
# Puts an ice9 server on a machine that has nothing on it.
#
#   curl -fsSL https://ice9.app/install.sh | bash -s -- --host talk.example.org
#
# or, with the repository in hand:
#
#   ./install.sh --host talk.example.org
#
# The address is the one people will type into their app, and it is the one
# thing this script cannot work out for itself: a machine knows its own IP but
# not the name pointed at it. Everything else - the passwords, the schema, the
# configuration - it generates and never asks about.
#
# Nothing is compiled here. The build needs about 3.5 GB for one package alone
# and is killed on a small machine, so a finished image is pulled instead. That
# image carries everything this script needs: the service configuration, the
# database schema, the patches, and the handshake check run at the end.
#
# Safe to run twice. Passwords are generated once and kept; the data volumes
# outlive the containers; schema patches are applied only if they have not been.
set -euo pipefail

IMAGE="${ICE9_IMAGE:-ghcr.io/dsfox-idea/ice9-server:latest}"
DIR="${ICE9_DIR:-/srv/ice9}"
PORT="${ICE9_PORT:-10443}"
HOST="${ICE9_HOST:-}"

usage() {
  sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
  exit "${1:-0}"
}

while [ $# -gt 0 ]; do
  case "$1" in
    --host)  HOST="$2"; shift 2 ;;
    --port)  PORT="$2"; shift 2 ;;
    --dir)   DIR="$2"; shift 2 ;;
    --image) IMAGE="$2"; shift 2 ;;
    -h|--help) usage 0 ;;
    *) echo "unknown option: $1" >&2; usage 1 ;;
  esac
done

say() { printf '> %s\n' "$*"; }
die() { printf '! %s\n' "$*" >&2; exit 1; }

# Two different questions, and answering them as one gets it wrong both ways.
# Whether Docker needs root is not whether this directory does: a machine where
# the user is in the docker group needs no sudo for containers and may still
# need it for /srv, and a workstation needs it for neither. Asked properly, and
# separately, below.
SUDO=""
DOCKER="docker"

elevate() {
  [ "$(id -u)" = "0" ] && return 1
  command -v sudo >/dev/null 2>&1
}

# ---------------------------------------------------------------- the address

if [ -z "$HOST" ]; then
  # A guess to offer, not an answer: whoever installs this usually has a name
  # pointed at the machine, and the name is what their people will type.
  guess="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)"
  if [ -t 0 ]; then
    printf 'What address will people type into the app?%s ' \
      "${guess:+ [$guess]}"
    read -r HOST </dev/tty || true
    HOST="${HOST:-$guess}"
  else
    HOST="$guess"
  fi
fi
[ -n "$HOST" ] || die "no address given: pass --host <name or IP>"
case "$HOST" in
  *:*) die "give the name or IP in --host and the port in --port" ;;
esac

# --------------------------------------------------------------------- docker

if ! command -v docker >/dev/null 2>&1; then
  say "installing Docker"
  if elevate; then curl -fsSL https://get.docker.com | sudo sh >/dev/null
  else curl -fsSL https://get.docker.com | sh >/dev/null; fi
fi
if ! docker info >/dev/null 2>&1; then
  elevate || die "this user cannot reach Docker, and there is no sudo to borrow"
  DOCKER="sudo docker"
  $DOCKER info >/dev/null 2>&1 || die "Docker does not answer even as root"
fi
$DOCKER compose version >/dev/null 2>&1 \
  || die "this Docker has no compose plugin; install docker-compose-plugin"

say "pulling $IMAGE"
# Bounded: a registry that answers and then trickles - seen from a machine
# behind a slow link, 20 KB a second at "Pulling fs layer" for half an hour -
# is otherwise a pull that never ends, and the copy already here (below) is
# never reached. Fifteen minutes is generous for the image on a normal link.
if ! timeout 900 $DOCKER pull -q "$IMAGE" >/dev/null 2>&1; then
  # A machine that already holds the image is not an error: it may have been
  # loaded from a file on a network that cannot reach a registry.
  if ! $DOCKER image inspect "$IMAGE" >/dev/null 2>&1; then
    # The likeliest reason, and the one the registry reports in a way nobody
    # reads: the image is built for x86-64 and this is not an x86-64 machine.
    # "cannot pull" sends people to look at their network for nothing.
    arch="$($DOCKER version --format '{{.Server.Arch}}' 2>/dev/null || uname -m)"
    case "$arch" in
      amd64|x86_64) die "cannot pull $IMAGE, and there is no copy of it here" ;;
      *) die "$IMAGE is built for x86-64 and this machine is $arch" ;;
    esac
  fi
  say "the registry is unreachable; using the copy already here"
fi

# ------------------------------------------------------ what the image brings

if ! mkdir -p "$DIR/secrets" 2>/dev/null; then
  elevate || die "cannot create $DIR"
  SUDO="sudo"
  $SUDO mkdir -p "$DIR/secrets"
fi
[ -w "$DIR" ] || SUDO="sudo"
# And once the directory needs root, so does every docker command that touches
# it: `docker cp` writes the kit into it and `docker compose` reads the .env
# out of it. A person in the docker group on a machine where /srv is root's
# ran this a second time and got "mkdirat sql: permission denied".
[ -n "$SUDO" ] && DOCKER="sudo docker"

kit="$($DOCKER create "$IMAGE")"
trap '$DOCKER rm -f "$kit" >/dev/null 2>&1 || true' EXIT
for part in deploy/sql deploy/mysql-init deploy/sql-patches deploy/check-mtproto.py; do
  $SUDO rm -rf "$DIR/$(basename "$part")"
  $DOCKER cp "$kit:/app/$part" "$DIR/$(basename "$part")"
done

# ------------------------------------------------------------------- secrets

# Generated once. Regenerating them on a second run would leave the database
# holding data nobody has the password to any more.
if [ ! -f "$DIR/.env" ]; then
  say "generating passwords"
  $SUDO tee "$DIR/.env" >/dev/null <<ENV
# Generated by install.sh. Keep this file; the database is behind it.
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 24)
MYSQL_PASSWORD=$(openssl rand -hex 24)
ENV
  $SUDO chmod 600 "$DIR/.env"
fi

# --------------------------------------------------------------- the compose

# Written here rather than fetched, so that this script plus the image is the
# whole delivery. It is the production compose with the mounts that came from a
# repository replaced by what was copied out of the image, and with the address
# handed to the server instead of edited into its config file.
$SUDO tee "$DIR/docker-compose.yml" >/dev/null <<COMPOSE
services:
  mysql:
    image: mysql:8.0
    container_name: ice9-mysql
    environment:
      MYSQL_ROOT_PASSWORD: \${MYSQL_ROOT_PASSWORD:?generated by install.sh}
      MYSQL_DATABASE: teamgram
      MYSQL_USER: teamgram
      MYSQL_PASSWORD: \${MYSQL_PASSWORD:?generated by install.sh}
    volumes:
      - mysql_data:/var/lib/mysql
      - ./sql:/upstream-sql:ro
      - ./sql-patches:/sql-patches:ro
      - ./mysql-init:/docker-entrypoint-initdb.d:ro
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-authentication-plugin=mysql_native_password
      # Settings for a modest machine: about 160 MB instead of 450 MB.
      - --innodb-buffer-pool-size=64M
      - --performance-schema=OFF
      - --innodb-log-buffer-size=8M
      - --max-connections=100
      - --table-open-cache=200
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p\\"\$\$MYSQL_ROOT_PASSWORD\\" || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    restart: unless-stopped
    logging:
      driver: json-file
      options: {max-size: "50m", max-file: "5"}
    networks: [teamgram_net]

  redis:
    image: redis:7-alpine
    container_name: ice9-redis
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped
    logging:
      driver: json-file
      options: {max-size: "50m", max-file: "5"}
    networks: [teamgram_net]

  teamgram:
    image: $IMAGE
    container_name: ice9-teamgram
    volumes:
      - files_data:/app/data/files
      # Apple and Firebase keys, if this server ever gets any. Empty is fine:
      # without them everything works except notifications.
      - ./secrets:/app/secrets:ro
    environment:
      MYSQL_PASSWORD: \${MYSQL_PASSWORD:?generated by install.sh}
      # The address this server hands out in help.getConfig. Clients keep that
      # list and dial it from then on, so it must be this machine and not the
      # one the image was built against.
      ICE9_ADDRESS: "$HOST:$PORT"
      # Notifications go through the relay that holds the Apple and Google
      # keys (#167); the server registers itself with it. Another relay - the
      # same open code, run by somebody with keys of their own - goes here.
      PUSH_RELAY_URL: "\${PUSH_RELAY_URL:-https://push.ice9.app}"
      # Eleven processes on a tight machine: 550 MB instead of 785 MB.
      GOGC: "25"
    ports:
      - "$PORT:10443"
      # The client alternates between the two while connecting, and a refused
      # port costs it a whole reconnect cycle.
      - "5222:5222"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
    logging:
      driver: json-file
      options: {max-size: "50m", max-file: "5"}
    networks: [teamgram_net]

volumes:
  mysql_data:
  redis_data:
  files_data:

networks:
  teamgram_net:
    driver: bridge
COMPOSE

# ------------------------------------------------------------------ bring up

compose() { $DOCKER compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" "$@"; }
# Over TCP on purpose, never the socket. While a new database is being laid
# down MySQL runs a private server that listens on the socket and on no port at
# all; talking to it means racing the schema, and this script did exactly that -
# it re-applied a patch the init had already applied and recorded. Refusing the
# socket makes "the database answers" mean "the init is finished".
sql() { $DOCKER exec -i ice9-mysql mysql --protocol=TCP -h 127.0.0.1 -uroot -p"$root_password" teamgram; }
root_password="$(sed -n 's/^MYSQL_ROOT_PASSWORD=//p' <($SUDO cat "$DIR/.env"))"

# The database first and the server after it, deliberately, rather than all
# three at once. On a new machine MySQL lays the schema down through a server
# it starts privately and then stops; its healthcheck answers during that
# window, so "healthy" arrives before there is a single table. Started
# together, the server comes up against an empty database and this script
# applies patches to tables that do not exist yet - which is exactly what it
# did before this comment was written.
say "starting the database"
compose up -d mysql redis

say "waiting for the schema"
for attempt in $(seq 1 90); do
  tables="$(echo "select count(*) from information_schema.tables where table_schema='teamgram';" \
    | sql 2>/dev/null | tail -1 || true)"
  case "$tables" in
    ''|0|*[!0-9]*) ;;
    *) break ;;
  esac
  [ "$attempt" = 90 ] && die "the database never got its schema: docker logs ice9-mysql"
  sleep 2
done
say "  $tables tables"

# A second run against an older installation finds a database that exists and
# patches it has not seen - the same bookkeeping deploy/apply-patches.sh does,
# in the few lines that need no repository.
say "schema patches"
echo "CREATE TABLE IF NOT EXISTS schema_patches (
        name varchar(191) NOT NULL, applied_at int(11) NOT NULL,
        PRIMARY KEY (name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" | sql
applied="$(echo "select name from schema_patches;" | sql | tail -n +2 || true)"
for patch in "$DIR"/sql-patches/*.sql; do
  name="$(basename "$patch")"
  if echo "$applied" | grep -qx "$name"; then
    printf '  %-40s already applied\n' "$name"
    continue
  fi
  printf '  %-40s applying\n' "$name"
  $SUDO cat "$patch" | sql
  echo "insert into schema_patches(name, applied_at) values ('$name', unix_timestamp());" | sql
done

say "starting the server"
compose up -d

# ------------------------------------------------------------------- and does it work

# A running container is not a working server: with the wrong database address
# it stays up, answers the port and fails every handshake. So the check is the
# thing a phone does first, run from inside the image because the machine has
# nothing installed on it.
say "handshake"
for attempt in $(seq 1 20); do
  if $DOCKER exec ice9-teamgram python3 /app/deploy/check-mtproto.py 127.0.0.1 >/dev/null 2>&1; then
    break
  fi
  [ "$attempt" = 20 ] && die "the server is up but completes no handshake: docker logs ice9-teamgram"
  sleep 3
done

# ------------------------------------------------------------ the first person in
# Signing up needs an invitation code, and a server that was installed a minute
# ago has nobody to mint one. Walked in a fresh machine: the address alone left
# its owner in front of a code field with no code. So the first one is minted
# here - for any one number, good for a day - and printed beside the address.
# Every code after it comes from a member's phone, the way invitations work.
say "the first sign-in code"
code="$($DOCKER exec ice9-teamgram /app/bin/invite --anyone --hours 24 --note "first sign-in" 2>/dev/null | tail -1 || true)"
case "$code" in
  [0-9][0-9][0-9][0-9][0-9][0-9]) ;;
  *) code="" ;;
esac
cat <<DONE

  The server is up and answers a handshake.

  Type this into the app, on the first screen:

      $HOST${PORT:+:$PORT}

DONE
if [ -n "$code" ]; then
cat <<DONE
  Then your number, and this as the code (it works once, within a day):

      $code

  Everybody after you is invited from a member's phone, by SMS.

DONE
else
cat <<DONE
  Then your number and a sign-in code. This image could not mint one; on this
  machine run:  docker exec ice9-teamgram /app/bin/invite --anyone

DONE
fi
cat <<DONE
  Notifications go through push.ice9.app: your server hands it a sealed
  notification that holds none of your messages, and it wakes the phone.
  Set PUSH_RELAY_URL in $DIR/.env to use a relay of your own.

  It lives in $DIR. Passwords are in $DIR/.env - the database is behind them.
  To see how it is doing:  docker logs -f ice9-teamgram
DONE
