#!/bin/bash
#
# Sets up the LigandScout Server (iserver) on an AWS ParallelCluster v3 head node.
#
# Run it from your cluster configuration as an OnNodeConfigured custom action:
#
#   HeadNode:
#     CustomActions:
#       OnNodeConfigured:
#         Script: https://docs.inteligand.com/ls-remote/aws/bootstrap-iserver.sh
#         Args:
#           - "<your LigandScout serial number>"
#
# Arguments:
#   $1  LigandScout serial number                        (required)
#   $2  URL of the LigandScout-XT Linux archive          (optional)
#   $3  URL of the iserver Linux archive                 (optional)
#
# The script installs both applications onto the shared volume, activates
# LigandScout-XT with your serial number, and registers iserver as a systemd
# service. Compute nodes need no setup of their own: they reach the same
# installation over the shared volume.
#
# Progress and errors are written to /var/log/iserver-bootstrap.log.

set -euo pipefail

exec > >(tee -a /var/log/iserver-bootstrap.log) 2>&1

# Pinned to a tested combination. Override via Args if you need a different one.
DEFAULT_XT_URL="https://www.inteligand.com/ligandscout-xt/downloads/LigandScout-XT_114_linux64_20260709.tar.gz"
DEFAULT_ISERVER_URL="https://www.inteligand.com/download/iserver/iserver_1.6.6_linux64.tar.gz"

SERIAL="${1:-}"
XT_URL="${2:-$DEFAULT_XT_URL}"
ISERVER_URL="${3:-$DEFAULT_ISERVER_URL}"

# The default login user of an Ubuntu ParallelCluster. Jobs run as this user.
CLUSTER_USER="ubuntu"

SHARED_DIR="/shared"
APPS_DIR="${SHARED_DIR}/apps"
XT_DIR="${APPS_DIR}/LigandScout-XT"
ISERVER_DIR="${APPS_DIR}/ligandscout_server"

echo "=== iserver bootstrap started $(date -u +'%Y-%m-%dT%H:%M:%SZ') ==="

if [ -z "${SERIAL}" ]; then
    echo "ERROR: no LigandScout serial number was given." >&2
    echo "       Add it as the first entry of CustomActions.OnNodeConfigured.Args" >&2
    echo "       in your cluster configuration file." >&2
    exit 1
fi

# Only the head node runs iserver. This guard makes the script harmless if it
# is ever attached to a compute queue as well.
if [ -r /opt/parallelcluster/cfnconfig ]; then
    # shellcheck disable=SC1091
    . /opt/parallelcluster/cfnconfig
    if [ "${cfn_node_type:-}" != "HeadNode" ]; then
        echo "Node type is '${cfn_node_type:-unknown}', not HeadNode. Nothing to do."
        exit 0
    fi
fi

# The host that LigandScout-XT contacts to activate. Checked up front so that a
# head node without outbound internet access fails immediately, with a message
# naming the cause, rather than ten minutes later at the activation step.
LICENSE_HOST_URL="http://license1.inteligand.com"

# ---------------------------------------------------------------------------
# Pre-flight: confirm everything this script depends on is reachable before
# spending several minutes downloading a third of a gigabyte.
# ---------------------------------------------------------------------------
check_reachable()
{
    local label="$1"
    local url="$2"

    printf '    %-18s ' "${label}"
    if curl -fsSL --head --connect-timeout 10 --max-time 20 --retry 1 --retry-delay 5 \
            "${url}" > /dev/null 2>&1; then
        echo "reachable"
        return 0
    fi
    echo "UNREACHABLE  (${url})"
    return 1
}

echo "--- Pre-flight checks"
preflight_failures=0
check_reachable "LigandScout-XT" "${XT_URL}" || preflight_failures=$((preflight_failures + 1))
check_reachable "iserver" "${ISERVER_URL}" || preflight_failures=$((preflight_failures + 1))
check_reachable "license server" "${LICENSE_HOST_URL}" || preflight_failures=$((preflight_failures + 1))

if [ "${preflight_failures}" -ne 0 ]; then
    echo "ERROR: ${preflight_failures} of 3 required hosts could not be reached from this" >&2
    echo "       head node, so the installation cannot proceed." >&2
    echo "" >&2
    echo "       The usual cause is that the head node has no outbound internet access." >&2
    echo "       Check that its subnet routes to an internet gateway and that the node" >&2
    echo "       has a public IP (HeadNode.Networking.ElasticIp: true), or that a NAT" >&2
    echo "       gateway is present if you placed it in a private subnet." >&2
    echo "" >&2
    echo "       If instead an archive URL is wrong or has been withdrawn, correct it in" >&2
    echo "       CustomActions.OnNodeConfigured.Args." >&2
    exit 1
fi

# ---------------------------------------------------------------------------
# Download and unpack an archive into a target directory.
#
# Both archives contain a single top-level directory whose name carries the
# version, so we unpack into a temporary location and rename. That keeps the
# installation paths stable across releases.
# ---------------------------------------------------------------------------
install_archive()
{
    local url="$1"
    local dest="$2"
    local tmp

    case "${dest}" in
        "${APPS_DIR}"/*) ;;
        *) echo "ERROR: refusing to install outside ${APPS_DIR}: ${dest}" >&2; exit 1 ;;
    esac

    tmp="$(mktemp -d)"

    echo "Downloading ${url}"
    curl -fSL --retry 5 --retry-delay 10 --retry-connrefused \
        "${url}" -o "${tmp}/archive.tar.gz"

    echo "Unpacking into ${dest}"
    mkdir -p "${tmp}/unpacked"
    tar -xzf "${tmp}/archive.tar.gz" -C "${tmp}/unpacked"

    rm -rf "${dest}"
    mkdir -p "$(dirname "${dest}")"

    local top_level entry_count
    entry_count="$(find "${tmp}/unpacked" -mindepth 1 -maxdepth 1 | wc -l)"
    top_level="$(find "${tmp}/unpacked" -mindepth 1 -maxdepth 1)"
    if [ "${entry_count}" -eq 1 ] && [ -d "${top_level}" ]; then
        mv "${top_level}" "${dest}"
    else
        mv "${tmp}/unpacked" "${dest}"
    fi

    rm -rf "${tmp}"
}

echo "--- Creating directories on the shared volume"
mkdir -p \
    "${APPS_DIR}" \
    "${SHARED_DIR}/jobs/screening" \
    "${SHARED_DIR}/jobs/confgen" \
    "${SHARED_DIR}/data/compound-databases/re-confgen" \
    "${SHARED_DIR}/data/compound-databases/upload" \
    "${SHARED_DIR}/data/input"

echo "--- Installing LigandScout-XT"
install_archive "${XT_URL}" "${XT_DIR}"

if [ ! -x "${XT_DIR}/iscreen" ]; then
    echo "ERROR: ${XT_DIR}/iscreen is missing. The archive layout is not what" >&2
    echo "       this script expects. Check the URL in Args." >&2
    exit 1
fi

# LigandScout-XT activates itself the first time a licensed tool runs and finds
# a file named 'serial' in its installation directory. Every cluster node reads
# the same file from the shared volume, so this single write covers the whole
# cluster, including nodes added later by autoscaling.
echo "--- Writing serial number for automatic activation"
printf '%s\n' "${SERIAL}" > "${XT_DIR}/serial"
chmod 600 "${XT_DIR}/serial"

echo "--- Installing iserver"
install_archive "${ISERVER_URL}" "${ISERVER_DIR}"

if [ ! -x "${ISERVER_DIR}/iserver" ]; then
    echo "ERROR: ${ISERVER_DIR}/iserver is missing. The archive layout is not" >&2
    echo "       what this script expects. Check the URL in Args." >&2
    exit 1
fi

# The launcher runs 'java -jar ilib-server-current.jar', but the release archive
# only contains the versioned jar. Normally update.sh creates this symlink; on a
# fresh install nothing has, so we do it here.
ISERVER_JAR="$(find "${ISERVER_DIR}" -maxdepth 1 -name 'ilib-server-*.jar' \
    ! -name 'ilib-server-current.jar' -exec basename {} \; | sort | tail -n 1)"

if [ -z "${ISERVER_JAR}" ]; then
    echo "ERROR: no ilib-server-*.jar found in ${ISERVER_DIR}." >&2
    echo "       The archive layout is not what this script expects." >&2
    exit 1
fi

ln -sfn "${ISERVER_JAR}" "${ISERVER_DIR}/ilib-server-current.jar"
echo "Linked ilib-server-current.jar -> ${ISERVER_JAR}"

# Release archives are packed with world-writable modes. chown alone would not
# correct that, so tighten the tree before handing it over.
chmod -R go-w "${XT_DIR}" "${ISERVER_DIR}"

echo "--- Writing iserver configuration"
cat > "${ISERVER_DIR}/application.properties" <<PROPERTIES
######################################################################
# Written by bootstrap-iserver.sh. Edit and restart with
#   sudo systemctl restart iserver
######################################################################

job.screening.directory=${SHARED_DIR}/jobs/screening
job.confgen.directory=${SHARED_DIR}/jobs/confgen

ligandscout.path=${XT_DIR}

ldb.directories=${SHARED_DIR}/data/compound-databases
ldb.directories.monitoring.recursive=true
ldb.directories.upload=${SHARED_DIR}/data/compound-databases/upload
job.confgen.databases.directory=${SHARED_DIR}/data/compound-databases/re-confgen

job.confgen.input.directories=${SHARED_DIR}/data/input
job.confgen.input.recursive=true

######################################################################
# Scheduler
######################################################################
scheduler=slurm
scheduler.slurm.partition=
scheduler.slurm.option.string=
scheduler.priority=0

scheduler.number.processors.screening=4
scheduler.number.processors.confgen=2

# Match these to the compute instance type in your cluster configuration.
iscreen.amount.cores=6
iscreen.memory=8
idbgen.memory=4
idbgen.memory.slaves=3

job.splitting.max.chunk.size=2000000
job.splitting.max.chunk.size.confgen=10000

merge.confgen.databases=false
move_finished_jobs=false
finished_jobs_directory=/home/<user>/jobs

######################################################################
# Logging
######################################################################
logging.level.root=INFO
logging.level.org.springframework=WARN
logging.level.org.springframework.web=WARN
logging.level.org.hibernate=WARN
logging.level.com.tupilabs.pbs=WARN
logging.level.ilib.server.grid.slurm=WARN

logging.directory=./logs
logging.allow.download=true

######################################################################
# Internal database
######################################################################
spring.datasource.url=jdbc:h2:./database/ilib-server;MVCC=true
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=admin
spring.datasource.password=password
spring.jpa.generate-ddl=true
spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.use-new-id-generator-mappings=true
spring.h2.console.enabled=true
spring.h2.console.path=/console/

######################################################################
# Web server
######################################################################
server.tomcat.basedir=./tomcat-logs
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.pattern=%t %a "%r" %s (%D ms)

spring.servlet.multipart.max-file-size=32768MB
spring.servlet.multipart.max-request-size=32768MB

server.port=8080
# Bind to the loopback interface only. The REST API has no authentication, so it
# must not be exposed: LigandScout-XT and KNIME reach it through an SSH tunnel.
# Removing this line makes iserver listen on every interface, including the
# public one, and anyone who can reach port 8080 gains full control of the server.
server.address=127.0.0.1

######################################################################
# Startup self-tests
######################################################################
testing.skip=false
testing.paths=true
testing.scheduler=true
testing.quit-on-error=true
PROPERTIES

echo "--- Handing the shared volume to ${CLUSTER_USER}"
chown -R "${CLUSTER_USER}:${CLUSTER_USER}" "${SHARED_DIR}"

# Activate now rather than waiting for the first screening job. Activation is
# otherwise lazy, so an incorrect serial would produce a cluster that builds
# cleanly and then fails every job with an opaque licensing error.
echo "--- Activating LigandScout-XT"
if ! su - "${CLUSTER_USER}" -c "'${XT_DIR}/activation' -s '${SERIAL}'"; then
    echo "ERROR: LigandScout-XT could not be activated with the serial number" >&2
    echo "       supplied in CustomActions.OnNodeConfigured.Args." >&2
    echo "       Check that the serial is correct and that the head node can reach" >&2
    echo "       license1.inteligand.com. Screening jobs cannot run until this" >&2
    echo "       succeeds, so the cluster build is being failed deliberately." >&2
    exit 1
fi

# iserver runs on the Java runtime that ships inside LigandScout-XT, so the
# cluster needs no separate JDK installation. The launcher calls a bare 'java',
# which the PATH below resolves to the bundled runtime.
#
# /opt/slurm/bin must also be on that PATH: iserver invokes sbatch, sinfo,
# squeue and scancel by bare name, and its startup self-test quits the server
# if it cannot query the scheduler.
#
# Restart=always rather than on-failure: when the self-test fails, iserver exits
# cleanly with status 0, which on-failure would not catch.
echo "--- Registering the iserver systemd service"
cat > /etc/systemd/system/iserver.service <<UNIT
[Unit]
Description=LigandScout Server (iserver)
Documentation=https://docs.inteligand.com/ls-remote/working-with-aws/
After=network-online.target slurmctld.service
Wants=network-online.target

[Service]
Type=simple
User=${CLUSTER_USER}
Group=${CLUSTER_USER}
WorkingDirectory=${ISERVER_DIR}
Environment=JAVA_HOME=${XT_DIR}/jre
Environment=PATH=${XT_DIR}/jre/bin:/opt/slurm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=${ISERVER_DIR}/iserver
Restart=always
RestartSec=15

[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload
systemctl enable --now iserver

echo "--- Waiting for iserver to answer on http://localhost:8080/status"
for attempt in $(seq 1 60); do
    if curl -fsS --max-time 5 http://localhost:8080/status > /dev/null 2>&1; then
        echo "iserver is up after ${attempt} attempt(s)."
        echo "=== iserver bootstrap finished $(date -u +'%Y-%m-%dT%H:%M:%SZ') ==="
        exit 0
    fi
    sleep 10
done

echo "ERROR: iserver did not answer within 10 minutes." >&2
echo "       Diagnose with: journalctl -u iserver" >&2
exit 1
