#!/bin/bash
# Keycloak update script for the Carta identity servers.
#
# Usage:
#   co_update_keycloak.sh <production|development> <version>
#
# Both arguments are mandatory. There are no defaults: an unpinned version or an
# implied mode is how you end up with dev and prod on different builds.
#
# Required environment variables (no defaults, fail fast if missing):
#   production  -> KC_PROD_DB_PASSWORD
#   development -> KC_DEV_DB_PASSWORD
# Set them in the environment or a root-only sourced secrets file, not here.
#
# -e: exit on any failing command. -u: unset variable is an error.
# -o pipefail: a pipeline fails if any stage fails.
set -euo pipefail

# ---------------------------------------------------------------------------
# Constants (single source of truth)
# ---------------------------------------------------------------------------
REMOTE_DB_HOST="h4.lead.nl"
SQLCMD="/opt/mssql-tools/bin/sqlcmd"
RELEASE_API="https://api.github.com/repos/keycloak/keycloak/releases/tags"

# The two lists below are edited in this file and nowhere else. Setting a shell
# variable of the same name before invoking the script has no effect: the script
# runs in its own process and assigns these arrays itself.
#
# Custom themes to carry over from the previous installation, by directory name.
# Empty means: use the themes shipped with the distribution and nothing else.
# Every entry is copied verbatim, so an entry that was built against an older
# Keycloak will break that older Keycloak's templates on the new server.
# Any directory found in the old themes/ that is not listed here aborts the run,
# so a theme is never dropped silently.
CUSTOM_THEMES=()

# Custom provider JARs to carry over, by file name. Empty means: none.
# A JAR built against an older Keycloak can still load and then misbehave, so
# verify the provider actually works after the build, not just that it starts.
# Any *.jar found in the old providers/ that is not listed here aborts the run,
# so a forgotten artefact is a stop, not a silent carry-over. Only JARs are
# considered: the distribution ships a providers/README.md of its own.
CUSTOM_PROVIDERS=()

# Theme directory names that would shadow a theme shipped inside the
# distribution. Copying one of these over is what breaks the admin console.
# Maintain this list when Keycloak adds a built-in theme.
RESERVED_THEME_NAMES=(base keycloak keycloak.v2 welcome)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
echo_error_and_exit() {
    echo "Error: $1" >&2
    exit 1
}

get_user_consent() {
    local message="$1"
    read -r -p "$message [y/n]: " consent
    if [[ "$consent" != "y" ]]; then
        echo "User aborted operation."
        exit 1
    fi
}

require_command() {
    command -v "$1" >/dev/null 2>&1 || echo_error_and_exit "Required command not found: $1"
}

# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
if [[ $# -ne 2 ]]; then
    echo_error_and_exit "Usage: $(basename "$0") <production|development> <version>   (example: $(basename "$0") development 26.7.2)"
fi

mode="$1"
target_version="$2"

case "$mode" in
    production)
        BASE_NAME="keycloak"
        REMOTE_DB_USER="KeyCloakAdmin"
        REMOTE_DB_PASSWORD="${KC_PROD_DB_PASSWORD:?Set KC_PROD_DB_PASSWORD in the environment before running (production DB backup password)}"
        ;;
    development)
        BASE_NAME="keycloakdev"
        REMOTE_DB_USER="KeyCloakDevAdmin"
        REMOTE_DB_PASSWORD="${KC_DEV_DB_PASSWORD:?Set KC_DEV_DB_PASSWORD in the environment before running (development DB backup password)}"
        ;;
    *)
        echo_error_and_exit "Mode must be exactly 'production' or 'development', got '$mode'."
        ;;
esac

[[ "$target_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
    || echo_error_and_exit "Version must be a pinned release tag such as 26.7.2, got '$target_version'."

require_command curl
require_command jq
require_command unzip
require_command sha256sum
[[ -x "$SQLCMD" ]] || echo_error_and_exit "sqlcmd not found or not executable at $SQLCMD."

timestamp="$(date +'%Y%m%d_%H%M%S')"
destination_folder="/opt/$BASE_NAME"
previous_folder="/opt/$BASE_NAME.previous_$timestamp"
REMOTE_DB_NAME="$BASE_NAME"
REMOTE_BACKUP_PATH="/var/backups/$BASE_NAME/backup_$timestamp.bak"
SERVICE_NAME="$BASE_NAME"
work_folder="/var/tmp/$BASE_NAME/$timestamp"
zip_folder="$work_folder/zip"
installation_folder="$work_folder/install"
staging_folder="$installation_folder/keycloak-$target_version"

[[ -d "$destination_folder" ]] || echo_error_and_exit "Destination $destination_folder does not exist."

# State for the exit trap, so an abort does not leave the service down.
service_stopped="no"
deployed="no"

on_exit() {
    local status=$?
    [[ $status -eq 0 ]] && return
    if [[ "$deployed" == "yes" ]]; then
        echo "Aborted with $target_version already in place. Roll back with:" >&2
        echo "  sudo systemctl stop $SERVICE_NAME" >&2
        echo "  sudo mv $destination_folder /opt/$BASE_NAME.failed_$timestamp" >&2
        echo "  sudo mv $previous_folder $destination_folder" >&2
        echo "  sudo systemctl start $SERVICE_NAME" >&2
    elif [[ "$service_stopped" == "yes" ]]; then
        echo "Aborted before anything was replaced. Restarting the previous installation." >&2
        sudo systemctl start "$SERVICE_NAME" \
            || echo "Failed to restart $SERVICE_NAME. Start it manually." >&2
    fi
}
trap on_exit EXIT

# ---------------------------------------------------------------------------
# Preflight: everything that can be judged without touching the running server
# is judged here, so an abort costs no downtime.
# ---------------------------------------------------------------------------
validate_carry_over() {
    local theme reserved provider existing name known allowed source_theme

    for theme in "${CUSTOM_THEMES[@]}"; do
        source_theme="$destination_folder/themes/$theme"
        [[ -d "$source_theme" ]] \
            || echo_error_and_exit "Custom theme '$theme' not found in $destination_folder/themes."
        for reserved in "${RESERVED_THEME_NAMES[@]}"; do
            if [[ "$theme" == "$reserved" ]]; then
                echo_error_and_exit "Custom theme '$theme' shadows a built-in theme. Rename it instead of overriding it."
            fi
        done
        if [[ -d "$source_theme/META-INF" || -d "$source_theme/theme" ]]; then
            echo_error_and_exit "Custom theme '$theme' looks like an unpacked JAR (contains META-INF/ or theme/). Deploy the JAR in providers/ or unpack it at the right level."
        fi
    done

    # Symmetric with the theme check below: an artefact that is present but not
    # listed is a stop, never a silent drop.
    shopt -s nullglob
    for existing in "$destination_folder/providers/"*.jar; do
        name="$(basename "$existing")"
        known="no"
        for allowed in "${CUSTOM_PROVIDERS[@]}"; do
            if [[ "$name" == "$allowed" ]]; then
                known="yes"
            fi
        done
        if [[ "$known" != "yes" ]]; then
            shopt -u nullglob
            echo_error_and_exit "Unknown artefact in providers/: '$name'. Add it to CUSTOM_PROVIDERS or remove it, then run again."
        fi
    done

    for existing in "$destination_folder/themes/"*/; do
        name="$(basename "$existing")"
        known="no"
        for allowed in "${CUSTOM_THEMES[@]}"; do
            if [[ "$name" == "$allowed" ]]; then
                known="yes"
            fi
        done
        if [[ "$known" != "yes" ]]; then
            shopt -u nullglob
            echo_error_and_exit "Theme '$name' is installed but not listed in CUSTOM_THEMES. Add it to carry it over, or remove the directory if it is obsolete."
        fi
    done
    shopt -u nullglob

    for provider in "${CUSTOM_PROVIDERS[@]}"; do
        [[ -f "$destination_folder/providers/$provider" ]] \
            || echo_error_and_exit "Custom provider '$provider' not found in $destination_folder/providers."
    done

    if [[ ${#CUSTOM_THEMES[@]} -eq 0 ]]; then
        echo "No custom themes configured; using the distribution themes."
    fi
    if [[ ${#CUSTOM_PROVIDERS[@]} -eq 0 ]]; then
        echo "No custom providers configured."
    fi
}

validate_carry_over
echo "Preflight passed."

echo "Mode:              $mode"
echo "Target version:    $target_version"
echo "Destination:       $destination_folder"
echo "Themes to carry:   ${CUSTOM_THEMES[*]:-none}"
echo "Providers to carry: ${CUSTOM_PROVIDERS[*]:-none}"
echo "Installed version: $("$destination_folder/bin/kc.sh" --version 2>/dev/null | head -1 || echo 'unknown')"
echo

# ---------------------------------------------------------------------------
# Download and verify
# ---------------------------------------------------------------------------
get_user_consent "Do you want to download Keycloak $target_version?"

mkdir -p "$zip_folder" "$installation_folder"

release_json="$(curl -fsSL "$RELEASE_API/$target_version")" \
    || echo_error_and_exit "Release tag $target_version not found on GitHub."

asset_name="keycloak-$target_version.zip"
download_url="$(jq -r --arg n "$asset_name" '.assets[] | select(.name==$n) | .browser_download_url' <<<"$release_json")"
expected_digest="$(jq -r --arg n "$asset_name" '.assets[] | select(.name==$n) | .digest' <<<"$release_json")"

[[ -n "$download_url" && "$download_url" != "null" ]] \
    || echo_error_and_exit "Release $target_version has no asset named $asset_name."
[[ "$expected_digest" == sha256:* ]] \
    || echo_error_and_exit "Release $target_version publishes no sha256 digest for $asset_name; refusing to install unverified bytes."

zip_path="$zip_folder/$asset_name"
curl -fL "$download_url" -o "$zip_path" || echo_error_and_exit "Failed to download $download_url."

actual_sha="$(sha256sum "$zip_path" | cut -d' ' -f1)"
[[ "$actual_sha" == "${expected_digest#sha256:}" ]] \
    || echo_error_and_exit "Checksum mismatch for $asset_name. Expected ${expected_digest#sha256:}, got $actual_sha."
unzip -tq "$zip_path" || echo_error_and_exit "Downloaded archive is corrupt."
echo "Downloaded and verified $asset_name (sha256 $actual_sha)."

unzip -q "$zip_path" -d "$installation_folder" || echo_error_and_exit "Failed to extract $zip_path."
[[ -d "$staging_folder" ]] || echo_error_and_exit "Archive did not contain keycloak-$target_version."

# ---------------------------------------------------------------------------
# Stop the service, then back up. A copy taken while Keycloak is running is not
# consistent, and neither is a database backup that predates the last write.
# ---------------------------------------------------------------------------
get_user_consent "Do you want to stop $SERVICE_NAME and take backups?"

sudo systemctl stop "$SERVICE_NAME" || echo_error_and_exit "Failed to stop $SERVICE_NAME."
service_stopped="yes"
echo "Service $SERVICE_NAME stopped."

BACKUP_COMMAND="BACKUP DATABASE [$REMOTE_DB_NAME] TO DISK = N'$REMOTE_BACKUP_PATH' WITH NOFORMAT, NOINIT, NAME = N'$REMOTE_DB_NAME-full', SKIP, NOREWIND, NOUNLOAD, STATS = 10"
"$SQLCMD" -S "$REMOTE_DB_HOST" -U "$REMOTE_DB_USER" -P "$REMOTE_DB_PASSWORD" -Q "$BACKUP_COMMAND" \
    || echo_error_and_exit "Database backup failed on $REMOTE_DB_HOST."
echo "Database backed up to $REMOTE_BACKUP_PATH on $REMOTE_DB_HOST."

# ---------------------------------------------------------------------------
# Stage the new installation: config, curated themes, curated providers, logs.
# ---------------------------------------------------------------------------
cp -a "$destination_folder/conf/." "$staging_folder/conf/" \
    || echo_error_and_exit "Failed to copy conf/ into the staging folder."
echo "Copied conf/."

# The allow lists were validated in the preflight; here they are only copied.
for theme in "${CUSTOM_THEMES[@]}"; do
    cp -a "$destination_folder/themes/$theme" "$staging_folder/themes/" \
        || echo_error_and_exit "Failed to copy theme '$theme'."
    [[ -d "$staging_folder/themes/$theme" ]] \
        || echo_error_and_exit "Theme '$theme' is not present in the staging folder after copying."
    echo "Copied theme '$theme'."
done

for provider in "${CUSTOM_PROVIDERS[@]}"; do
    cp -a "$destination_folder/providers/$provider" "$staging_folder/providers/" \
        || echo_error_and_exit "Failed to copy provider '$provider'."
    echo "Copied provider '$provider'."
done

# Keep the logs. They are needed most on the day an upgrade goes wrong.
if [[ -d "$destination_folder/data/log" ]]; then
    mkdir -p "$staging_folder/data"
    cp -a "$destination_folder/data/log" "$staging_folder/data/" \
        || echo_error_and_exit "Failed to carry over data/log."
    echo "Carried over data/log."
fi

sudo chown -R "$BASE_NAME:$BASE_NAME" "$staging_folder" \
    || echo_error_and_exit "Failed to set ownership on the staging folder."

# ---------------------------------------------------------------------------
# Deploy. The old installation is moved aside, not deleted, so a rollback is a
# move back rather than a restore from backup.
# ---------------------------------------------------------------------------
get_user_consent "Do you want to deploy $target_version to $destination_folder? (current install moves to $previous_folder)"

sudo mv "$destination_folder" "$previous_folder" || echo_error_and_exit "Failed to move the current installation aside."
sudo mv "$staging_folder" "$destination_folder" || echo_error_and_exit "Failed to move the new installation into place."
sudo chown -R "$BASE_NAME:$BASE_NAME" "$destination_folder" \
    || echo_error_and_exit "Failed to set ownership on $destination_folder."
deployed="yes"
echo "Deployed $target_version to $destination_folder."

# ---------------------------------------------------------------------------
# Build in both modes, so dev exercises the same code path as production.
# ---------------------------------------------------------------------------
get_user_consent "Do you want to run 'kc.sh build'?"
sudo -u "$BASE_NAME" "$destination_folder/bin/kc.sh" build || echo_error_and_exit "Keycloak build failed."
echo "Build completed."

# ---------------------------------------------------------------------------
# Start and verify
# ---------------------------------------------------------------------------
get_user_consent "Do you want to start $SERVICE_NAME?"
sudo systemctl start "$SERVICE_NAME" || echo_error_and_exit "Failed to start $SERVICE_NAME."

for theme in "${CUSTOM_THEMES[@]}"; do
    [[ -d "$destination_folder/themes/$theme" ]] \
        || echo_error_and_exit "Theme '$theme' is missing from $destination_folder/themes after deployment."
done
for provider in "${CUSTOM_PROVIDERS[@]}"; do
    [[ -f "$destination_folder/providers/$provider" ]] \
        || echo_error_and_exit "Provider '$provider' is missing from $destination_folder/providers after deployment."
done

running_version="$("$destination_folder/bin/kc.sh" --version | head -1)"
grep -q "$target_version" <<<"$running_version" \
    || echo_error_and_exit "Deployed version does not report $target_version: $running_version"
echo "Service started. $running_version"

# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
get_user_consent "Do you want to remove the temporary files in $work_folder?"
rm -rf "$work_folder"

echo
echo "Done. Verify the login page, the admin console and a test login before you"
echo "remove the previous installation:"
echo "  sudo rm -rf $previous_folder"
echo "Rollback, if needed:"
echo "  sudo systemctl stop $SERVICE_NAME"
echo "  sudo mv $destination_folder /opt/$BASE_NAME.failed_$timestamp"
echo "  sudo mv $previous_folder $destination_folder"
echo "  sudo systemctl start $SERVICE_NAME"
