#!/bin/bash
# Install the current Body Shape device release from the private APT
# repository, converge the configuration, and prove the device still works.
#
# The agent never accepts systemd state as evidence that a release is live: a
# unit can be active while the code behind it fails to load its settings. It
# asks the API itself, and if the API cannot answer it puts the previous
# release back.
set -Eeuo pipefail

# Upgrading body-shape-device-updater replaces this file while bash is still
# reading it, which corrupts execution part way through a run. Re-exec from a
# private copy first so the running agent owns its own text.
if [[ "${BODY_SHAPE_UPDATE_REEXEC:-}" != "1" ]]; then
    self="$(readlink -f "$0")"
    runtime_copy="$(mktemp /run/body-shape-update.XXXXXX.sh)"
    cp -- "${self}" "${runtime_copy}"
    chmod 0700 "${runtime_copy}"
    export BODY_SHAPE_UPDATE_REEXEC=1
    export BODY_SHAPE_UPDATE_RUNTIME_COPY="${runtime_copy}"
    exec "${runtime_copy}" "$@"
fi
cleanup_runtime_copy() {
    if [[ -n "${BODY_SHAPE_UPDATE_RUNTIME_COPY:-}" ]]; then
        rm -f -- "${BODY_SHAPE_UPDATE_RUNTIME_COPY}"
    fi
}
trap cleanup_runtime_copy EXIT

UPDATE_ENABLED=true
UPDATE_WINDOW_START=""
UPDATE_WINDOW_END=""
UPDATE_IDLE_MINUTES=15
UPDATE_HEALTH_TIMEOUT_SECONDS=120
UPDATE_API_URL=http://127.0.0.1:8080
UPDATE_PACKAGES="body-shape-device-api body-shape-device-config body-shape-device-console body-shape-device-updater"
UPDATE_SCAN_DIR=/var/lib/body-shape-device/scans
UPDATE_ACTIVATION_FILE=/var/lib/body-shape-provisioning/activated.json

CONFIG_FILE=/etc/body-shape-device/update.conf
STATE_DIR=/var/lib/body-shape-device
STATE_FILE="${STATE_DIR}/update-state.json"
ROLLBACK_FILE="${STATE_DIR}/update-rollback.json"
ROLLBACK_LIST="${STATE_DIR}/update-rollback.list"
INHIBIT_FILES=(/run/body-shape-device/update-inhibit /etc/body-shape-device/update-inhibit)
LOCK_FILE=/run/body-shape-update.lock
APT_SOURCE=/etc/apt/sources.list.d/body-shape.sources
CONVERGE=/usr/sbin/body-shape-device-converge
API_UNIT=body-shape-device-api.service
API_TLS_UNIT=body-shape-device-api-tls.service
KIOSK_UNIT=lightdm.service

mode=run
force=false

log() { printf '%s %s\n' "$(date --iso-8601=seconds)" "$*"; }
fail() { log "ERROR: $*" >&2; }

usage() {
    cat <<'USAGE'
Usage: body-shape-update [OPTION]

  (no option)   Run a scheduled update: honour the maintenance window, the
                idle check and the inhibit files.
  --now         Run immediately, ignoring the window and the idle check.
  --check       Report what the channel offers and change nothing.
  --rollback    Reinstall the release recorded before the last update.
  --help        Show this message.
USAGE
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --now) force=true ;;
        --check) mode=check ;;
        --rollback) mode=rollback ;;
        --help|-h) usage; exit 0 ;;
        *) fail "unknown option: $1"; usage >&2; exit 2 ;;
    esac
    shift
done

if [[ "${EUID}" -ne 0 ]]; then
    fail "body-shape-update must run as root"
    exit 1
fi

if [[ -r "${CONFIG_FILE}" ]]; then
    # shellcheck source=/dev/null
    source "${CONFIG_FILE}"
fi

read -r -a packages <<<"${UPDATE_PACKAGES}"

channel_name() {
    if [[ -r "${APT_SOURCE}" ]]; then
        awk '/^Suites:/ {print $2; exit}' "${APT_SOURCE}"
    else
        printf 'unknown'
    fi
}

json_string() {
    local value="$1"
    value="${value//\\/\\\\}"
    value="${value//\"/\\\"}"
    value="$(printf '%s' "${value}" | tr -d '\000-\037')"
    printf '"%s"' "${value}"
}

# Renders {"package": "version", ...} from name=version arguments.
json_versions() {
    local first=true entry name version
    printf '{'
    for entry in "$@"; do
        name="${entry%%=*}"
        version="${entry#*=}"
        if [[ "${first}" == true ]]; then
            first=false
        else
            printf ', '
        fi
        printf '%s: %s' "$(json_string "${name}")" "$(json_string "${version}")"
    done
    printf '}'
}

started_at="$(date --iso-8601=seconds)"
state_result=skipped
state_error=""
declare -a state_previous=()
declare -a state_current=()

write_state() {
    local target="$1"
    local temporary
    mkdir -p "${STATE_DIR}"
    temporary="$(mktemp "${STATE_DIR}/.update-state.XXXXXX")"
    {
        printf '{\n'
        printf '  "schema": 1,\n'
        printf '  "result": %s,\n' "$(json_string "${state_result}")"
        printf '  "started_at": %s,\n' "$(json_string "${started_at}")"
        printf '  "finished_at": %s,\n' "$(json_string "$(date --iso-8601=seconds)")"
        printf '  "channel": %s,\n' "$(json_string "$(channel_name)")"
        printf '  "previous": %s,\n' "$(json_versions ${state_previous[@]+"${state_previous[@]}"})"
        printf '  "current": %s,\n' "$(json_versions ${state_current[@]+"${state_current[@]}"})"
        printf '  "error": %s\n' "$(json_string "${state_error}")"
        printf '}\n'
    } >"${temporary}"
    chmod 0644 "${temporary}"
    mv -f "${temporary}" "${target}"
}

finish() {
    local status="$1"
    write_state "${STATE_FILE}"
    log "result=${state_result}${state_error:+ (${state_error})}"
    exit "${status}"
}

installed_version() {
    local status_version
    status_version="$(dpkg-query -W -f='${db:Status-Status} ${Version}' "$1" 2>/dev/null || true)"
    if [[ "${status_version}" == "installed "* ]]; then
        printf '%s' "${status_version#installed }"
    fi
}

candidate_version() {
    local candidate
    candidate="$(apt-cache policy "$1" 2>/dev/null | awk '/Candidate:/ {print $2; exit}')"
    if [[ -n "${candidate}" && "${candidate}" != "(none)" ]]; then
        printf '%s' "${candidate}"
    fi
}

collect_versions() {
    local -n destination="$1"
    local package
    destination=()
    for package in "${packages[@]}"; do
        destination+=("${package}=$(installed_version "${package}")")
    done
}

minutes_of_day() {
    local value="$1"
    [[ "${value}" =~ ^([0-9]{1,2}):([0-9]{2})$ ]] || return 1
    printf '%d' "$((10#${BASH_REMATCH[1]} * 60 + 10#${BASH_REMATCH[2]}))"
}

inside_window() {
    local start end now
    [[ -n "${UPDATE_WINDOW_START}" && -n "${UPDATE_WINDOW_END}" ]] || return 0
    start="$(minutes_of_day "${UPDATE_WINDOW_START}")" || return 0
    end="$(minutes_of_day "${UPDATE_WINDOW_END}")" || return 0
    now="$((10#$(date +%H) * 60 + 10#$(date +%M)))"
    if [[ "${start}" -le "${end}" ]]; then
        [[ "${now}" -ge "${start}" && "${now}" -lt "${end}" ]]
    else
        # A window that crosses midnight, for example 22:00 to 04:00.
        [[ "${now}" -ge "${start}" || "${now}" -lt "${end}" ]]
    fi
}

# The API keeps no session state on disk that an outside process can read, so
# recent writes under the scan directory stand in for "a customer is using the
# machine". Anything that needs a hard guarantee touches an inhibit file.
recently_scanned() {
    local recent
    [[ "${UPDATE_IDLE_MINUTES}" -gt 0 ]] || return 1
    [[ -d "${UPDATE_SCAN_DIR}" ]] || return 1
    recent="$(find "${UPDATE_SCAN_DIR}" -mindepth 1 \
        -newermt "-${UPDATE_IDLE_MINUTES} minutes" -print -quit 2>/dev/null || true)"
    [[ -n "${recent}" ]]
}

apt_get() {
    DEBIAN_FRONTEND=noninteractive apt-get \
        -o DPkg::Lock::Timeout=600 \
        -o Dpkg::Options::=--force-confdef \
        -o Dpkg::Options::=--force-confold \
        "$@"
}

unit_enabled() {
    systemctl is-enabled --quiet "$1" 2>/dev/null
}

restart_runtime() {
    if unit_enabled "${API_UNIT}"; then
        log "restarting ${API_UNIT}"
        # A restart that fails is exactly the case the rollback exists for, so
        # it must not abort the run before the health check gets to decide.
        systemctl restart "${API_UNIT}" || fail "${API_UNIT} did not restart"
    else
        log "${API_UNIT} is disabled on this host; leaving it stopped"
    fi
    if unit_enabled "${API_TLS_UNIT}"; then
        log "restarting ${API_TLS_UNIT}"
        systemctl restart "${API_TLS_UNIT}" || true
    fi
    # Last, and unconditionally while the kiosk is running: the WebView does not
    # recover from the API restart on its own, a new console binary only takes
    # effect in a fresh graphical session, and the entry URL is read once at
    # session start. Restarting it earlier would put the session back in front
    # of an API that is about to go down again.
    if systemctl is-active --quiet "${KIOSK_UNIT}"; then
        log "restarting ${KIOSK_UNIT}"
        systemctl restart "${KIOSK_UNIT}" || true
    fi
}

healthy() {
    local deadline
    if ! unit_enabled "${API_UNIT}"; then
        log "health check skipped: ${API_UNIT} is disabled on this host"
        return 0
    fi
    deadline=$((SECONDS + UPDATE_HEALTH_TIMEOUT_SECONDS))
    while [[ "${SECONDS}" -lt "${deadline}" ]]; do
        if curl --fail --silent --show-error --max-time 5 \
            "${UPDATE_API_URL}/health/ready" >/dev/null 2>&1; then
            # Readiness only proves the process answers. /v1/device proves the
            # new code loaded its settings and its turntable driver, which is
            # where a bad release actually fails.
            if curl --fail --silent --show-error --max-time 10 \
                "${UPDATE_API_URL}/v1/device" 2>/dev/null |
                grep -q '"turntable_model"'; then
                log "health check passed"
                return 0
            fi
        fi
        sleep 3
    done
    fail "the API did not answer /v1/device within ${UPDATE_HEALTH_TIMEOUT_SECONDS}s"
    return 1
}

install_versions() {
    local -a targets=()
    local entry name version
    for entry in "$@"; do
        name="${entry%%=*}"
        version="${entry#*=}"
        [[ -n "${version}" ]] || continue
        targets+=("${name}=${version}")
    done
    [[ "${#targets[@]}" -gt 0 ]] || return 0
    apt_get install --yes --allow-downgrades "${targets[@]}"
}

converge() {
    if [[ -x "${CONVERGE}" ]]; then
        log "converging configuration"
        "${CONVERGE}"
    else
        log "${CONVERGE} is absent; skipping convergence"
    fi
}

record_rollback_point() {
    local temporary entry
    mkdir -p "${STATE_DIR}"
    temporary="$(mktemp "${STATE_DIR}/.update-rollback.XXXXXX")"
    for entry in ${state_previous[@]+"${state_previous[@]}"}; do
        [[ -n "${entry#*=}" ]] || continue
        printf '%s\n' "${entry}" >>"${temporary}"
    done
    chmod 0644 "${temporary}"
    mv -f "${temporary}" "${ROLLBACK_LIST}"
    write_state "${ROLLBACK_FILE}"
}

exec 9>"${LOCK_FILE}"
if ! flock --nonblock 9; then
    log "another update run holds ${LOCK_FILE}; exiting"
    exit 0
fi

collect_versions state_previous
state_current=("${state_previous[@]}")

if [[ "${mode}" == check ]]; then
    printf '%-32s %-26s %s\n' PACKAGE INSTALLED CANDIDATE
    for package in "${packages[@]}"; do
        printf '%-32s %-26s %s\n' \
            "${package}" \
            "$(installed_version "${package}")" \
            "$(candidate_version "${package}")"
    done
    exit 0
fi

if [[ "${mode}" == rollback ]]; then
    if [[ ! -s "${ROLLBACK_LIST}" ]]; then
        fail "no recorded release at ${ROLLBACK_LIST}"
        exit 1
    fi
    mapfile -t recorded <"${ROLLBACK_LIST}"
    log "rolling back to: ${recorded[*]}"
    install_versions "${recorded[@]}"
    converge
    restart_runtime
    collect_versions state_current
    if healthy; then
        state_result=rolled-back
        finish 0
    fi
    state_result=failed
    state_error="the recorded release is also unhealthy"
    finish 1
fi

if [[ "${UPDATE_ENABLED}" != true ]]; then
    state_error="updates are disabled in ${CONFIG_FILE}"
    log "${state_error}"
    finish 0
fi

for inhibit in "${INHIBIT_FILES[@]}"; do
    if [[ -e "${inhibit}" ]]; then
        state_error="inhibited by ${inhibit}"
        log "${state_error}"
        finish 0
    fi
done

# An unactivated device has no identity, no site configuration and often no
# route to the repository. A sealed golden image is in exactly that state and
# must never pull a release before it is commissioned.
if [[ ! -r "${UPDATE_ACTIVATION_FILE}" ]]; then
    state_error="the device is not activated"
    log "${state_error}"
    finish 0
fi

if [[ "${force}" != true ]]; then
    if ! inside_window; then
        state_error="outside the ${UPDATE_WINDOW_START}-${UPDATE_WINDOW_END} maintenance window"
        log "${state_error}"
        finish 0
    fi
    if recently_scanned; then
        state_error="a scan wrote to ${UPDATE_SCAN_DIR} within ${UPDATE_IDLE_MINUTES} minutes"
        log "${state_error}"
        finish 0
    fi
fi

log "refreshing package indexes"
# Not fatal on its own. Many devices reach the release repository over the
# tailnet but have no route to the Ubuntu archive, and apt reports the whole
# refresh as failed when any one source is unreachable. What matters is
# whether the release channel answered, which the candidate versions below
# show directly.
if ! apt_get update; then
    log "apt-get update reported errors; continuing with the indexes that refreshed"
fi

declare -a wanted=()
reachable=false
for package in "${packages[@]}"; do
    installed="$(installed_version "${package}")"
    candidate="$(candidate_version "${package}")"
    if [[ -z "${candidate}" ]]; then
        log "${package}: the channel offers no candidate; leaving it as it is"
        continue
    fi
    reachable=true
    if [[ "${installed}" != "${candidate}" ]]; then
        log "${package}: ${installed:-<absent>} -> ${candidate}"
        wanted+=("${package}=${candidate}")
    fi
done

if [[ "${reachable}" != true ]]; then
    state_result=failed
    state_error="the release channel offered no package; the repository is unreachable"
    finish 1
fi

if [[ "${#wanted[@]}" -eq 0 ]]; then
    state_result=unchanged
    log "every package already matches the channel"
    finish 0
fi

# Recorded before the first dpkg run, so --rollback still knows where to go
# back to if the machine loses power in the middle of the upgrade.
state_result=in-progress
record_rollback_point

if ! install_versions "${wanted[@]}"; then
    fail "installing the release failed; restoring the previous one"
    install_versions "${state_previous[@]}" || true
    converge || true
    restart_runtime || true
    collect_versions state_current
    state_result=failed
    state_error="apt-get install failed"
    finish 1
fi

converge_failed=false
converge || converge_failed=true
restart_runtime
collect_versions state_current

if [[ "${converge_failed}" == false ]] && healthy; then
    state_result=updated
    finish 0
fi

fail "the new release did not come up; rolling back"
rollback_failed=false
install_versions "${state_previous[@]}" || rollback_failed=true
converge || rollback_failed=true
restart_runtime || rollback_failed=true
collect_versions state_current

if [[ "${rollback_failed}" == false ]] && healthy; then
    state_result=rolled-back
    if [[ "${converge_failed}" == true ]]; then
        state_error="convergence failed on the new release"
    else
        state_error="the new release failed its health check"
    fi
    finish 1
fi

state_result=failed
state_error="the new release failed and the rollback did not recover the device"
finish 1
