#!/usr/bin/env bash
# Install this product on a server, from a release directory written by
# deployment/package-release.sh. Takes a box with nothing of ours on it to a
# running instance; uninstall.sh (beside it) takes it back.
#
# Runs ON the server, as a sudo-capable user, from the release directory:
#
#   export NORSK_ADMIN_PASSWORD=...   # first install only: the web login (>=8 chars, >=1 digit)
#   export <PARAMETER>=...            # any launch parameter the template declares
#   bash install.sh --license /path/to/licence.json
#
# Launch parameters are the product template's own: every parameter it declares
# is passed when a variable of that name is set, a required one must be, and
# the rest take the template's defaults. Secrets come from the environment,
# never the command line.
#
# Idempotent: each step checks before it acts, so a re-run after a failure picks
# up where it stopped, and a re-run with a newer release replaces the product (a
# stored template never sees a new image otherwise) and relaunches the instance
# in the same working directory, so its media and settings survive.
#
# Access (ACCESS, default tunnel): the web page stays off the public network and
# is reached over SSH, the shape a host that admits only SSH and media needs:
#   ssh -L 8443:127.0.0.1:8443 <user>@<host>    # then https://localhost:8443
# The proxy listens on PROXY_PORT (8443), the port the tunnel forwards, so the
# URLs norsk-ctl advertises are the ones the browser uses. ACCESS=public serves
# 443 to the world instead, at PUBLIC_HOST (default the detected public IP).
#
# Options (environment):
#   INSTANCE_ID                        default: the product's name
#   CERT_SOURCE, DOMAIN, CERT_EMAIL    passed to the norsk-ctl installer
#   RELAUNCH=1                         replace a running instance, e.g. to apply
#                                      a changed parameter
#
# Copied verbatim from @norskvideo/ctl-dev-kit conventions/server-install.sh and
# drift-gated: edit the dev-kit source and re-sync, never this copy.
set -euo pipefail

CTL=(sudo -u norsk env NORSK_CTL_CONFIG_DIR=/etc/norsk-ctl NORSK_CTL_STATE_DIR=/var/lib/norsk-ctl
  NORSK_CTL_LOG_DIR=/var/log/norsk-ctl norsk-ctl)
CTL_WAIT_SECONDS="${CTL_WAIT_SECONDS:-90}"

info() { printf '\n==> %s\n' "$*"; }
oops() {
  printf 'error: %s\n' "$*" >&2
  exit 1
}
# As the service user with its configuration: the installer adds us to the
# norsk group, but not in this login session, and sudo drops the profile.d
# environment the CLI reads.
ctl() { "${CTL[@]}" "$@"; }

# Bounded by the clock, not by attempts: a CLI call against a daemon that is
# down can itself take many seconds.
wait_for_ctl() {
  local deadline=$((SECONDS + CTL_WAIT_SECONDS))
  while [ "${SECONDS}" -lt "${deadline}" ]; do
    timeout 5 "${CTL[@]}" product list >/dev/null 2>&1 && return 0
    sleep 1
  done
  oops "norsk-ctl did not come back within ${CTL_WAIT_SECONDS}s (journalctl -u norsk-ctl)"
}

# Appends `key: value` to a YAML file unless the key is set. On its own line
# whether or not the file ends in a newline: norsk-ctl's init writes
# config.yaml without one, and a key joined to the last line stops the daemon.
ensure_config_line() {
  local key=$1 value=$2 file=$3
  sudo grep -q "^${key}:" "${file}" && return 1
  printf '\n%s: %s\n' "${key}" "${value}" | sudo tee -a "${file}" >/dev/null
}

# The template's parameters (`template show -o json` on stdin) as --param
# arguments, NUL-separated: a parameter is passed when a variable of its name is
# set and non-empty; INSTANCE_NAME defaults to the instance id. Fails naming
# every required parameter that is unset.
launch_params() {
  local name required value missing=()
  while IFS=$'\t' read -r name required; do
    value="${!name:-}"
    [ -z "${value}" ] && [ "${name}" = INSTANCE_NAME ] && value="${INSTANCE_ID}"
    if [ -n "${value}" ]; then
      printf -- '--param\0%s=%s\0' "${name}" "${value}"
    elif [ "${required}" = true ]; then
      missing+=("${name}")
    fi
  done < <(jq -r '.parameters[] | [.name, (.required // false | tostring)] | @tsv')
  [ "${#missing[@]}" = 0 ] || oops "export the required launch parameters: ${missing[*]}"
}

main() {
  local here
  here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  # shellcheck source=/dev/null
  . "${here}/release.env" # PRODUCT, IMAGE, IMAGE_ARCH, IMAGE_TAR, CTL_VERSION, RELEASE_COMMIT

  local license=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --license)
        license=$2
        shift
        ;;
      *)
        echo "unknown argument $1" >&2
        exit 2
        ;;
    esac
    shift
  done
  INSTANCE_ID="${INSTANCE_ID:-${PRODUCT}}"
  local access="${ACCESS:-tunnel}" proxy_port="${PROXY_PORT:-8443}"
  case "${access}" in
    tunnel | public) ;;
    *)
      echo "ACCESS must be tunnel or public, not ${access}" >&2
      exit 2
      ;;
  esac
  local record_dir="/var/lib/${PRODUCT}-install"
  local record="${record_dir}/record.env"

  info "Preflight"
  [ -n "${license}" ] || oops "missing --license"
  [ -r "${license}" ] || oops "cannot read ${license}"
  # Docker is the box's own: the norsk-ctl installer would add it when missing,
  # and uninstall.sh could then not tell whose it was.
  for tool in curl jq docker tar gzip; do
    command -v "${tool}" >/dev/null || oops "${tool} is required"
  done
  sudo -n true 2>/dev/null || sudo -v
  local arch
  case "$(uname -m)" in
    x86_64) arch=amd64 ;;
    aarch64 | arm64) arch=arm64 ;;
    *) arch=unknown ;;
  esac
  [ "${arch}" = "${IMAGE_ARCH}" ] || oops "this release is ${IMAGE_ARCH}, this server is ${arch}"

  # What the box looked like before us, so uninstall.sh restores exactly that.
  # Written once: a re-run must not record our own changes as the baseline.
  if ! sudo test -f "${record}"; then
    info "Recording the baseline (${record})"
    local invoking_user="${SUDO_USER:-$(id -un)}"
    sudo install -d -m 0755 "${record_dir}"
    {
      printf 'RECORDED_AT=%q\n' "$(date -Is)"
      printf 'INVOKING_USER=%q\n' "${invoking_user}"
      printf 'USER_GROUPS_BEFORE=%q\n' "$(id -nG "${invoking_user}")"
      printf 'CTL_PREINSTALLED=%q\n' "$(command -v norsk-ctl >/dev/null && echo 1 || echo 0)"
      printf 'IMAGES_BEFORE=%q\n' "$(sudo docker image ls -q --no-trunc | sort -u | tr '\n' ' ')"
      for key in net.core.rmem_max net.core.wmem_max net.core.rmem_default net.core.wmem_default \
        net.core.netdev_max_backlog net.ipv4.tcp_rmem net.ipv4.tcp_wmem net.ipv4.tcp_congestion_control; do
        printf 'SYSCTL_%s=%q\n' "$(echo "${key}" | tr '.' '_')" "$(sysctl -n "${key}" | tr '\t' ' ')"
      done
    } | sudo tee "${record}" >/dev/null
  fi

  if ! command -v norsk-ctl >/dev/null; then
    info "norsk-ctl ${CTL_VERSION} (public installer)"
    : "${NORSK_ADMIN_PASSWORD:?export NORSK_ADMIN_PASSWORD (>=8 chars, >=1 digit)}"
    export NORSK_ADMIN_PASSWORD
    local installer
    installer="$(mktemp)"
    curl -fsSL https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl/install.sh -o "${installer}"
    local flags=()
    [ -n "${CERT_SOURCE:-}" ] && flags+=(--cert-source "${CERT_SOURCE}")
    [ -n "${DOMAIN:-}" ] && flags+=(--domain "${DOMAIN}")
    [ -n "${CERT_EMAIL:-}" ] && flags+=(--cert-email "${CERT_EMAIL}")
    if [ "${access}" = tunnel ]; then
      flags+=(--public-host localhost --proxy-port "${proxy_port}" --no-http-redirect)
      if grep -q -- '--bind-address)' "${installer}"; then
        flags+=(--bind-address 127.0.0.1)
      else
        echo "warning: this norsk-ctl cannot bind to loopback; its proxy and API listen on every interface" >&2
      fi
    else
      flags+=(--public-host "${PUBLIC_HOST:-auto}")
    fi
    bash "${installer}" --server --version "${CTL_VERSION}" --license "${license}" "${flags[@]}" --pull-images --yes
    rm -f "${installer}"
  fi
  # The public installer leaves a running norsk-ctl as it is, so a box set up by
  # an earlier release is upgraded here, as norsk-ctl's upgrade guide does it:
  # back up its config and state, swap the binary, restart the service. The
  # daemon's SIGTERM stops only itself; the proxy and instances keep running.
  local installed_ctl
  installed_ctl="$(norsk-ctl --version)"
  if [ "${installed_ctl}" != "${CTL_VERSION}" ]; then
    info "Upgrading norsk-ctl ${installed_ctl} -> ${CTL_VERSION}"
    local plat base bin want backup
    plat="linux-$([ "${arch}" = amd64 ] && echo x64 || echo arm64)"
    base="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl/${CTL_VERSION}"
    bin="$(mktemp)"
    curl -fsSL "${base}/norsk-ctl-${CTL_VERSION}-${plat}" -o "${bin}"
    want="$(curl -fsSL "${base}/norsk-ctl-${CTL_VERSION}-${plat}.sha256" | awk '{print $1}')"
    [ "$(sha256sum "${bin}" | awk '{print $1}')" = "${want}" ] || oops "norsk-ctl ${CTL_VERSION} failed its checksum"
    backup="${record_dir}/norsk-ctl-${installed_ctl}.tar.gz"
    sudo tar -czf "${backup}" -C / etc/norsk-ctl var/lib/norsk-ctl
    echo "backed up norsk-ctl's config and state to ${backup}"
    sudo install -o norsk -g norsk -m 0755 "${bin}" "/opt/norsk-ctl/bin/norsk-ctl-${CTL_VERSION}"
    rm -f "${bin}"
    sudo ln -sfn "/opt/norsk-ctl/bin/norsk-ctl-${CTL_VERSION}" /usr/local/bin/norsk-ctl
    sudo systemctl restart norsk-ctl
    wait_for_ctl
  fi

  # Behind a tunnel, norsk-ctl's shared MoQ relay would advertise its preview
  # URLs from the tunnel's localhost, where no browser can reach them over UDP.
  # An instance's own preview listener, given the box's public address, can.
  local relay_dropin="/etc/systemd/system/norsk-ctl.service.d/${PRODUCT}.conf"
  if [ "${access}" = tunnel ] && ! sudo test -f "${relay_dropin}"; then
    info "Turning off norsk-ctl's shared MoQ relay"
    sudo install -d /etc/systemd/system/norsk-ctl.service.d
    printf '[Service]\nEnvironment=NORSK_CTL_MOQ_RELAY_ENABLE=0\n' | sudo tee "${relay_dropin}" >/dev/null
    sudo systemctl daemon-reload
    sudo systemctl restart norsk-ctl
    wait_for_ctl
    sudo docker rm -f norsk-moq-relay >/dev/null 2>&1 || true
  fi

  # Behind a tunnel the proxy and the daemon's API stay on loopback, on a
  # norsk-ctl that can say so. An install made before it could gets the setting
  # here; the restart republishes the proxy on it.
  if [ "${access}" = tunnel ] && norsk-ctl init --help 2>/dev/null | grep -q -- '--bind-address' &&
    ensure_config_line bindAddress 127.0.0.1 /etc/norsk-ctl/config.yaml; then
    info "Bound the proxy and the daemon's API to loopback"
    sudo systemctl restart norsk-ctl
    wait_for_ctl
  fi
  local staged_license
  staged_license="/etc/norsk-ctl/licenses/$(basename "${license}")"
  sudo test -f "${staged_license}" || sudo install -m 0640 -o norsk -g norsk "${license}" "${staged_license}"

  info "Product image ${IMAGE}"
  sudo docker image inspect "${IMAGE}" >/dev/null 2>&1 || gunzip -c "${here}/${IMAGE_TAR}" | sudo docker load

  info "Product ${PRODUCT}"
  local registered
  registered="$(ctl product list -o json |
    jq -r --arg p "${PRODUCT}" '[.. | objects | select(has("spec") and .name == $p) | .spec.image][0] // ""')"
  if [ -n "${registered}" ] && [ "${registered}" != "${IMAGE}" ]; then
    # A stored template is fetched once at `product add`; a new image reaches
    # nothing until the product is added again.
    info "Replacing ${PRODUCT} (${registered} -> ${IMAGE})"
    # Not purged: the relaunch below takes the same working directory.
    if ctl instance exists "${INSTANCE_ID}" >/dev/null 2>&1; then ctl instance delete "${INSTANCE_ID}"; fi
    for t in $(ctl template list -o json |
      jq -r --arg p "${PRODUCT}" '.productTemplates[] | select(.source.productName == $p) | .name'); do
      ctl template remove "${t}"
    done
    ctl product remove "${PRODUCT}"
    registered=""
  fi
  [ -n "${registered}" ] || ctl product add --license-file "${staged_license}" --image "${IMAGE}"

  info "Instance ${INSTANCE_ID}"
  local template
  template="$(ctl template list -o json |
    jq -r --arg p "${PRODUCT}" '.productTemplates[] | select(.source.kind == "product-default" and .source.productName == $p) | .name' |
    head -1)"
  [ -n "${template}" ] || oops "no product-default template for ${PRODUCT}"
  # Through files, not a pipe: launch_params then runs in this shell, and a
  # missing required parameter stops the install before the instance is touched.
  local shown params=()
  shown="$(mktemp)"
  ctl template show "${template}" -o json >"${shown}"
  launch_params <"${shown}" >"${shown}.args"
  mapfile -d '' params <"${shown}.args"
  rm -f "${shown}" "${shown}.args"
  if [ "${RELAUNCH:-}" = 1 ] && ctl instance exists "${INSTANCE_ID}" >/dev/null 2>&1; then
    ctl instance delete "${INSTANCE_ID}"
  fi
  if ! ctl instance exists "${INSTANCE_ID}" >/dev/null 2>&1; then
    # TODO: --param puts secrets on a process argv for the length of the call;
    # move them to an env-file once ctl takes one.
    ctl instance launch-template "${INSTANCE_ID}" --template "${template}" "${params[@]}"
  fi

  info "Done (release ${RELEASE_COMMIT})"
  ctl instance list
  echo
  if [ "${access}" = tunnel ]; then
    echo "Reach it with: ssh -L ${proxy_port}:127.0.0.1:${proxy_port} $(id -un)@<this host>, then https://localhost:${proxy_port}"
    echo "Open inbound only the product's media ports (docs/administering.md lists them)."
  else
    echo "Open inbound: 443/tcp and the product's media ports (docs/administering.md lists them)."
  fi
}

if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi
