#!/usr/bin/env bash
#
# Update what ArchStar ships, from its own repository, and nothing else.
#
# Refreshes the update channels of repo.clem2k.com through the configuration
# the installer wrote - which names our channels and no Arch mirror, so this
# can only ever install what we signed (architecture.md) - and installs
# whatever is newer. Run from the page through the `update` machine action, or
# over SSH:
#
#   sudo archstar-update            refresh, and install what is newer
#   sudo archstar-update --check    refresh, say what is newer, install nothing
#   sudo archstar-update --install <pkg>...
#                                   refresh, and make sure those packages are
#                                   installed - a repair, not a way in for
#                                   anything new
#
# Since the `os` channel exists, "what ArchStar ships" includes the operating
# system: the dependency closure of the installed machine, republished from
# the very ISO build that produced the image. Two things follow, and both are
# this script's to handle.
#
#   The machine may not know about the channel. A kiosk carries the channel
#   list its image was built with, and its image is a USB stick somebody would
#   have to write again. This script travels - it ships in archstar-web, which
#   goes through the `archstar` channel - so this script is what carries the
#   news: it holds the list, and puts any channel the machine is missing into
#   its configuration. One update brings the new list, the next one uses it.
#
#   The machine may be running what was just replaced. A kernel, a libc and a
#   systemd have all moved under a process tree that still has the old ones
#   open. A marker is left where the page can see it and offer the reboot.
#
# Exit status:
#   0  up to date, or updated
#   2  the repository could not be reached - offline is not an error, but it is
#      not an update either, and nothing on the machine changed
#   1  anything else: a database that failed its signature check, a package
#      that failed to install
#
# The LAST line printed is a one-sentence summary; the page shows it. pacman's
# own output comes before it, for the journal.
#
# Environment - the suite sets these; the machine uses the defaults:
#   ARCHSTAR_PACMAN          the pacman to run (default: pacman)
#   ARCHSTAR_PACMAN_CONF     (default: /etc/archstar/pacman-update.conf)
#   ARCHSTAR_PACMAN_CACHE    (default: /var/cache/pacman/pkg)
#   ARCHSTAR_KEEP_VERSIONS   versions of each of our packages the cache keeps,
#                            for a rollback (default: 2)
#   ARCHSTAR_REBOOT_MARKER   where "this machine is running something older
#                            than what is installed" is written
#                            (default: /run/archstar/reboot-required)
#   ARCHSTAR_MODULES_DIR     where the running kernel's modules would be
#                            (default: /usr/lib/modules)
#   ARCHSTAR_ALLOW_NON_ROOT  1 to skip the root check (the suite only)

set -euo pipefail

PACMAN="${ARCHSTAR_PACMAN:-pacman}"
# The packages `--install` was asked for. Empty for every other mode, which is
# what tells `main` which of the two jobs it is doing.
INSTALL=()
PACMAN_CONF="${ARCHSTAR_PACMAN_CONF:-/etc/archstar/pacman-update.conf}"
PACMAN_CACHE="${ARCHSTAR_PACMAN_CACHE:-/var/cache/pacman/pkg}"
KEEP_VERSIONS="${ARCHSTAR_KEEP_VERSIONS:-2}"
REBOOT_MARKER="${ARCHSTAR_REBOOT_MARKER:-/run/archstar/reboot-required}"
MODULES_DIR="${ARCHSTAR_MODULES_DIR:-/usr/lib/modules}"

# Every channel ArchStar has, in the order pacman reads them: ours first, the
# operating system last and lowest. This is the same list as UPDATE_CHANNELS in
# 03-target-system/configs/install.conf, and every name in it is one
# ci/publish-channel.sh knows how to publish; 03-target-system's
# tests/test-update-config.sh checks both of those, because a list that drifts
# here is a machine asking for a channel nobody creates - and pacman takes one
# missing database for all of them.
CHANNELS=(archstar security game os)

CHECK_ONLY=0
# The channels this run put into the configuration, if any. Kept because a
# server that has not got one of them yet is a failure to undo rather than a
# failure to report.
ADDED_CHANNELS=''
# What pacman said the last time it was asked to refresh.
REFRESH_OUTPUT=''

# The summary is always the last line, whatever happened before it.
finish() {
    local status="$1"
    shift
    printf '%s\n' "$*"
    exit "${status}"
}

# In pacman's own language, whatever the machine's: this script reads what
# pacman says - "signature", "404" - and the kiosk is installed in French.
pacman_ours() {
    LC_ALL=C "${PACMAN}" --config "${PACMAN_CONF}" "$@"
}

parse_arguments() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --check) CHECK_ONLY=1 ;;
            --install)
                # Everything after it is a package name. The names come from a
                # file that ships in the package (`actions/install-tools`) and
                # never from the network: the machine-action protocol carries a
                # name and at most one word out of a closed list, so a request
                # naming packages would be free text on its way to pacman.
                shift
                (( $# > 0 )) || finish 1 '--install needs at least one package'
                while [[ $# -gt 0 ]]; do
                    # A pacman package name, and nothing that could be an
                    # option or a path. Checked here as well as at the call
                    # site because this is the end that runs pacman.
                    [[ "$1" =~ ^[a-z0-9][a-z0-9._+-]*$ ]] || \
                        finish 1 "not a package name: $1"
                    INSTALL+=("$1")
                    shift
                done
                continue
                ;;
            -h|--help)
                sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
                exit 0
                ;;
            *) finish 1 "unknown argument: $1" ;;
        esac
        shift
    done
}

preflight() {
    [[ -f "${PACMAN_CONF}" ]] || \
        finish 1 "no update configuration at ${PACMAN_CONF}; this machine was installed from an image that predates the update channels, and needs a reinstallation to get them"
    # The suite drives this with a fake pacman and no root; the real one refuses
    # a non-root caller anyway, this only makes the message say why.
    (( EUID == 0 || ${ARCHSTAR_ALLOW_NON_ROOT:-0} == 1 )) ||         finish 1 'archstar-update must run as root'
    command -v "${PACMAN}" >/dev/null 2>&1 || finish 1 "${PACMAN} is not available"
}

# --- The channels the machine asks for -----------------------------------------

# The configuration this machine reads, generated. A byte-for-byte twin of
# update_pacman_conf in 03-target-system/configure.sh, which is what the
# installer writes - so a machine that has been updated into a new channel and
# a machine freshly installed hold the same file, and there is one shape of
# this file and not two. 03-target-system/tests/test-update-config.sh diffs
# them; a change to either without the other fails there.
update_pacman_conf() {
    local url="$1"
    shift
    printf '%s\n' \
        '# Written by the ArchStar installer, and kept in step by archstar-update' \
        '# (architecture.md). Read by archstar-update and by nothing else.' \
        '#' \
        '# It names the ArchStar update channels and no Arch mirror: everything an' \
        '# update can install - the operating system included, through the os' \
        '# channel - is a file ArchStar built, signed and published.' \
        '# /etc/pacman.conf is untouched and plays no part in updates.' \
        '' \
        '[options]' \
        'HoldPkg = pacman glibc' \
        'Architecture = auto' \
        'CheckSpace' \
        'SigLevel = Required DatabaseRequired' \
        'LocalFileSigLevel = Required'
    local channel
    for channel in "$@"; do
        # shellcheck disable=SC2016 # $repo is pacman's to expand, not ours
        printf '\n[%s]\nServer = %s/$repo\n' "${channel}" "${url}"
    done
}

# The address the machine was installed with, read off its own configuration.
# Never assumed and never defaulted: this script's business is which channels
# a machine asks for, and a script that invents a repository address is a
# script that could point a machine somewhere nobody chose.
repository_url_in() {
    # shellcheck disable=SC2016 # $repo is pacman's, and is matched literally
    sed -n 's|^[[:space:]]*Server[[:space:]]*=[[:space:]]*\(.*\)/\$repo[[:space:]]*$|\1|p' \
        "$1" 2>/dev/null | head -n1
}

# The channels it names, in order. `[options]` is a section too, and is not one.
channels_in() {
    sed -n 's/^\[\([a-z][a-z0-9-]*\)\][[:space:]]*$/\1/p' "$1" 2>/dev/null \
        | grep -vx 'options' || true
}

# A machine installed before a channel existed has that channel nowhere, and
# nothing on it will ever add one: the list it was installed with came from an
# image, and the image is a stick somebody would have to write again. This
# script ships in archstar-web, which travels - so this is where the news
# arrives. The machine keeps the address it was given; only the list of
# channels is this project's to say.
#
# Sets ADDED_CHANNELS to what it put in, because a server that has not created
# one of them yet must not leave the machine unable to update at all: pacman
# takes one missing database for all of them, and refresh() undoes this rather
# than reporting it.
ensure_the_configuration_names_every_channel() {
    local -a have=() missing=()
    mapfile -t have < <(channels_in "${PACMAN_CONF}")

    local channel known found
    for channel in "${CHANNELS[@]}"; do
        found=0
        for known in ${have[@]+"${have[@]}"}; do
            [[ "${known}" == "${channel}" ]] && { found=1; break; }
        done
        (( found == 1 )) || missing+=("${channel}")
    done
    (( ${#missing[@]} > 0 )) || return 0

    local url
    url="$(repository_url_in "${PACMAN_CONF}")"
    if [[ -z "${url}" ]]; then
        # Nothing to copy the address from. Said out loud and then left alone:
        # a machine whose configuration this script cannot read is a machine
        # somebody has to look at, not one to guess about.
        printf 'this machine is missing the %s channel(s), and %s names no repository to take them from; leaving it alone\n' \
            "${missing[*]}" "${PACMAN_CONF}" >&2
        return 0
    fi

    cp -f "${PACMAN_CONF}" "${PACMAN_CONF}.before-update" 2>/dev/null || true
    update_pacman_conf "${url}" "${CHANNELS[@]}" >"${PACMAN_CONF}.new"
    chmod 0644 "${PACMAN_CONF}.new"
    mv -f "${PACMAN_CONF}.new" "${PACMAN_CONF}"
    ADDED_CHANNELS="${missing[*]}"
    printf 'this machine did not know about the %s channel(s); added\n' "${ADDED_CHANNELS}"
}

# Puts back what the machine had before this run touched it.
restore_the_previous_configuration() {
    [[ -f "${PACMAN_CONF}.before-update" ]] || return 1
    mv -f "${PACMAN_CONF}.before-update" "${PACMAN_CONF}"
}

# --- The refresh -----------------------------------------------------------

# One attempt. Everything pacman says is printed, so the journal has the
# reason, and kept in REFRESH_OUTPUT so the caller can read it: what a failure
# means depends on whether this run had just changed the configuration.
try_refresh() {
    local status=0
    REFRESH_OUTPUT="$(pacman_ours -Sy 2>&1)" || status=$?
    printf '%s\n' "${REFRESH_OUTPUT}"
    return "${status}"
}

# Fetch our databases. The two reasons a person can act on are told apart, and
# the one this script can act on itself is acted on.
refresh() {
    try_refresh && return 0

    # A channel this very run added, that the server has not got. The machine
    # updated fine a minute ago and would now be stuck for good, because pacman
    # takes one missing database for all of them - so the configuration goes
    # back the way it was and the update goes on without the new channel. The
    # next run tries again, and succeeds the day the channel is published.
    if [[ -n "${ADDED_CHANNELS}" ]] && grep -q 'returned error: 404' <<<"${REFRESH_OUTPUT}"; then
        if restore_the_previous_configuration; then
            printf 'the server has no %s channel yet; put the configuration back and going on without it\n' \
                "${ADDED_CHANNELS}"
            ADDED_CHANNELS=''
            try_refresh && return 0
        fi
    fi

    # A database that arrived and was refused is a different thing from one
    # that never arrived, and the first one is the one worth a phone call.
    if grep -qi 'signature' <<<"${REFRESH_OUTPUT}"; then
        finish 1 "the repository's database was refused: its signature did not check out; nothing changed"
    fi
    # A server that answers and has no such channel. pacman takes one missing
    # database for all of them, so this blocks every update until the channel
    # is created on the server (ci/publish-channel.sh --ensure) - nothing on
    # the machine is wrong, and nothing here can fix it.
    if grep -q 'returned error: 404' <<<"${REFRESH_OUTPUT}"; then
        local missing
        missing="$(grep -oE '[a-z]+\.(db|files)\b' <<<"${REFRESH_OUTPUT}" | head -1 || true)"
        finish 2 "the server has no ${missing:-database} (404): a channel this machine was told about was never created there; nothing changed"
    fi
    finish 2 'the repository could not be reached; nothing changed'
}

# `name old -> new`, one per line, or nothing. pacman exits 1 when there is
# nothing to upgrade, which is an answer and not a failure.
available_upgrades() {
    pacman_ours -Qu 2>/dev/null || true
}

# `name old -> new, name old -> new` on one line, for the summary.
#
# Capped at four names. This used to name every package, which was right when
# the most an update could move was three of ours; an operating system upgrade
# moves a hundred and thirty, and a hundred and thirty of them on one line is
# not a sentence anybody reads - least of all on a phone. The journal above
# still has all of them, package by package, which is where somebody who wants
# the list goes.
NAMES_IN_A_SUMMARY=4

describe() {
    local upgrades="$1"
    printf '%s' "${upgrades}" | awk -v limit="${NAMES_IN_A_SUMMARY}" '
        NR <= limit { printf "%s%s %s -> %s", (NR > 1 ? ", " : ""), $1, $2, $4 }
        END { if (NR > limit) printf ", and %d other%s", NR - limit, (NR - limit == 1 ? "" : "s") }'
}

# --- What the machine is still running -----------------------------------------

# The names one channel serves, or nothing when the machine does not have it.
channel_package_names() {
    pacman_ours -Slq "$1" 2>/dev/null | sort -u || true
}

# True when the machine is now running something older than what is installed.
#
# Two ways to be sure of it, and either is enough:
#
#   The running kernel's modules are gone. pacman replaced /usr/lib/modules/
#   wholesale, so nothing can be modprobed again - no USB microphone plugged
#   in after this point would come up - until the machine has booted the
#   kernel that is now on the disk. This is exact, and it is the one an
#   ordinary Arch machine checks.
#
#   Something out of the `os` channel moved. A libc, a systemd, a Mesa: the
#   process tree still has the old files open, and the game will keep running
#   against them until it is restarted or the machine is. Nothing here tries
#   to be clever about which package deserves a reboot, because the answer for
#   an appliance is "any of them, and rebooting one costs twenty seconds".
a_reboot_is_needed() {
    local upgrades="$1"

    [[ -d "${MODULES_DIR}/$(uname -r)" ]] || return 0

    local -a names=()
    mapfile -t names < <(channel_package_names os)
    (( ${#names[@]} > 0 )) || return 1

    local name
    while IFS= read -r name; do
        [[ -n "${name}" ]] || continue
        local known
        for known in "${names[@]}"; do
            [[ "${known}" == "${name}" ]] && return 0
        done
    done < <(printf '%s\n' "${upgrades}" | awk '{ print $1 }')
    return 1
}

# Where the page looks. In /run on purpose: the one thing that clears this
# condition is a reboot, and a reboot clears /run - so the marker cannot
# outlive the truth it states, and no code has to remember to delete it.
#
# Best effort, and never fatal: a machine that updated and could not write a
# marker has still updated, and the summary says a reboot is needed anyway.
leave_the_reboot_marker() {
    local reason="$1"
    install -d -m 0755 "$(dirname "${REBOOT_MARKER}")" 2>/dev/null || return 0
    printf '%s\n' "${reason}" >"${REBOOT_MARKER}" 2>/dev/null || return 0
    chmod 0644 "${REBOOT_MARKER}" 2>/dev/null || true
}

# --- The cache --------------------------------------------------------------

# The names our channels carry. Only those are pruned: this machine's cache
# also holds whatever nothing of ours ever published, and that is not this
# script's to delete.
#
# Since the `os` channel exists this is most of the system, which is the point:
# the cache is where a whole operating system's worth of packages lands, and
# nothing else on this machine would ever bound it.
our_package_names() {
    pacman_ours -Slq 2>/dev/null | sort -u || true
}

# The version out of a package file name. A name may carry dashes; a version
# may not, nor a release, nor an architecture - so the three last dash-separated
# fields are the ones that are not the name.
version_of() {
    local file="$1" name="$2"
    local rest="${file##*/}"
    rest="${rest#"${name}"-}"
    rest="${rest%.pkg.tar.*}"
    [[ "${rest}" =~ ^([^-]+)-([^-]+)-([^-]+)$ ]] || return 1
    printf '%s-%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
}

# Every cached file of one package, newest first, by pacman's own ordering.
cached_versions_of() {
    local name="$1" file version
    local -a lines=()
    for file in "${PACMAN_CACHE}/${name}"-*.pkg.tar.*; do
        [[ -f "${file}" ]] || continue
        [[ "${file}" == *.sig ]] && continue
        version="$(version_of "${file}" "${name}")" || continue
        lines+=("${version} ${file}")
    done
    (( ${#lines[@]} > 0 )) || return 0
    # An insertion sort on vercmp: the cache holds a handful of files, and
    # `sort -V` does not agree with pacman on every version it could meet.
    local -a ordered=()
    local entry i inserted
    for entry in "${lines[@]}"; do
        inserted=0
        for (( i = 0; i < ${#ordered[@]}; i++ )); do
            if (( $(vercmp "${entry%% *}" "${ordered[i]%% *}") > 0 )); then
                ordered=("${ordered[@]:0:i}" "${entry}" "${ordered[@]:i}")
                inserted=1
                break
            fi
        done
        (( inserted == 1 )) || ordered+=("${entry}")
    done
    printf '%s\n' "${ordered[@]}"
}

# Keep the newest KEEP_VERSIONS of each of our packages, and their signatures,
# so that the previous version is there for a `pacman -U` rollback; delete the
# rest. pacman never cleans its cache by itself, and a 300 MB database package
# arriving twice a week would fill a root partition in a year.
prune_cache() {
    [[ -d "${PACMAN_CACHE}" ]] || return 0
    local name line file kept
    while IFS= read -r name; do
        [[ -n "${name}" ]] || continue
        kept=0
        while IFS= read -r line; do
            [[ -n "${line}" ]] || continue
            file="${line#* }"
            if (( kept < KEEP_VERSIONS )); then
                kept=$(( kept + 1 ))
                continue
            fi
            rm -f -- "${file}" "${file}.sig"
            printf 'removed from the cache: %s\n' "${file##*/}"
        done < <(cached_versions_of "${name}")
    done < <(our_package_names)
}

# --- Putting a package back ----------------------------------------------------

# `-S --needed`: install what is missing, leave what is there.
#
# Never `-Syu` and never a plain `-S`. The first would turn a repair into a
# system upgrade nobody asked for, in the middle of an evening; the second
# would reinstall three working programs to fix none of them. `--needed` is
# what makes this safe to run whenever something looks wrong, which is exactly
# when it will be run.
install_these() {
    local wanted
    wanted="$(printf '%s ' "${INSTALL[@]}")"
    printf 'installing if missing: %s\n' "${wanted% }"
    if ! pacman_ours -S --needed --noconfirm "${INSTALL[@]}"; then
        finish 1 "could not install ${wanted% }; pacman said why above"
    fi
    finish 0 "installed if missing: ${wanted% }"
}

# --- Main --------------------------------------------------------------------

main() {
    parse_arguments "$@"
    preflight
    # Before the refresh, so that a channel this machine has never heard of is
    # one this very run can fetch. Never on --check: looking at what is newer
    # is a question, and a question does not rewrite the machine's
    # configuration.
    (( CHECK_ONLY == 1 )) || ensure_the_configuration_names_every_channel
    refresh

    # A repair rather than an update, and it stops here: `-S --needed` installs
    # what is absent and leaves alone what is present, so a machine whose
    # three programs are all there is not dragged through a system upgrade it
    # did not ask for. That distinction is the whole reason this is a mode and
    # not a second script - the channel list, the refresh and the signature
    # checking above are the same, and are what must not be written twice.
    if (( ${#INSTALL[@]} > 0 )); then
        install_these
    fi

    local upgrades
    upgrades="$(available_upgrades)"

    if (( CHECK_ONLY == 1 )); then
        [[ -n "${upgrades}" ]] || finish 0 'up to date'
        printf '%s\n' "${upgrades}"
        finish 0 "newer: $(describe "${upgrades}")"
    fi

    [[ -n "${upgrades}" ]] || finish 0 'up to date'
    printf 'installing: %s\n' "$(describe "${upgrades}")"

    # Before the download as well as after it. Root is 16 GiB, an operating
    # system upgrade fetches well over a gigabyte into the cache *before*
    # pacman installs a byte of it, and pacman's own CheckSpace weighs what
    # gets installed and not what gets downloaded. On a machine that prunes
    # every time this is a no-op; on one whose cache the installer filled and
    # nothing has touched since, it is the difference between an update and a
    # full root partition.
    prune_cache

    if ! pacman_ours -Su --noconfirm; then
        finish 1 'the update did not complete; pacman said why above'
    fi
    prune_cache

    local summary
    summary="updated: $(describe "${upgrades}")"
    if a_reboot_is_needed "${upgrades}"; then
        leave_the_reboot_marker "${summary}"
        finish 0 "${summary} - restart the machine to run it"
    fi
    finish 0 "${summary}"
}

# Guarded so that tests/test-update.sh can source this file and drive the
# pieces with a fake pacman.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi
