#!/bin/bash

# A bash script utilizing coreutils, ssh, sudo, and apt-get to ease the burden
# of keeping Debian hosts patched across your network.

# Copyright (C) 2022  Johannes Truschnigg <johannes@truschnigg.info>
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# this program. If not, see <https://www.gnu.org/licenses/>.

HOSTS_LIST_FILE="${HOSTS_LIST_FILE:-${HOME}/debian_host_list}"
MAX_PARALLEL_JOBS="${MAX_PARALLEL_JOBS:-8}"



# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## #
declare -a target_hosts hosts_list
error_count=0
warn_count=0
fixall_mode=interactive

if [[ -n ${COLODEBUG:-} && ${-} != *x* ]]
then
:() {
  [[ ${1:--} != ::* ]] && return 0
  printf '%s\n' "${*}" >&2
}
fi

for p in awk ssh tput
do
  if ! type "${p}" &>/dev/null
  then
    printf 'apt-fixall: FATAL: Please install this program: `%s`\n' "${p}" >&2
    exit 10
  fi
done

_c_on() {
  tput setaf "${1:-3}"
}

_c_off() {
  tput sgr0
}

_usage_err() {
  cat >&2 <<EOUSAGE
apt-fixall - keep Debian hosts' packages up to date via SSH


Available modes of operation:

  a) apt-fixall --survey

  b) apt-fixall --survey-with-hostname

  c) apt-fixall PACKAGE_TO_UPGRADE [PACKAGE_TO_UPGRADE...]

  d) apt-fixall

Modes a) and b) list all packages found upgradable on all visited hosts.
Connection errors are NOT reported in these modes.

Mode c) non-interactively upgrades any and all packages in the supplied list on
all visited host.

Mode d) traverses all hosts and offers a manual, interactive package upgrade
workflow on each.


Current HOSTS_LIST_FILE: ${HOSTS_LIST_FILE}
Current MAX_PARALLEL_JOBS: ${MAX_PARALLEL_JOBS}

EOUSAGE
  exit 127
}

_host_begin() {
  printf '=== START [ '
  _c_on
  printf '%s' "${1}"
  _c_off
  printf ' ] ===\n'
}

_host_end() {
  printf '===  END  [ '
  _c_on
  printf '%s' "${1}"
  _c_off
  printf ' ] ===\n\n'
}


for arg in "${@}"
do
  case "${arg}" in
  --survey)
    if [[ ${#} -gt 1 ]]
    then
      _usage_err
    else
      fixall_mode=survey
    fi
  ;;
  --survey-with-hostname)
    if [[ ${#} -gt 1 ]]
    then
      _usage_err
    else
      fixall_mode=survey_with_hostname
    fi
  ;;
  [a-zA-Z0-9]*)
    fixall_mode=auto_upgrade
  ;;
  *)
    _usage_err
  ;;
  esac
done

: :: "apt-fixall mode: ${fixall_mode}"


if ! mapfile -t hosts_list < "${HOSTS_LIST_FILE}"
then
  printf 'apt-fixall: FATAL: Could not read host list from HOSTS_LIST_FILE (set to: %s )\n' "${HOSTS_LIST_FILE}" >&2
  exit 10
fi


# Filter for reachable hosts
if ! [[ $fixall_mode = survey* ]]
then
  : :: "non-survey mode triggers pre-flight ssh connection checks"
  for h in "${hosts_list[@]}"
  do
    if ! ssh -n -oLogLevel=QUIET -oBatchMode=yes -oConnectTimeout=3 "${h}" /bin/true
    then
      printf 'apt-fixall: ERROR: Host failed ssh connection check: %s\n' "${h}" >&2
      printf '=!= SKIP  [ '
      _c_on
      printf '%s' "${h}"
      _c_off
      printf ' ] =!=\n'
      ((error_count++))
    else
      : :: "host ${h} is considered a target"
      target_hosts+=("${h}")
    fi
  done
else
  target_hosts=("${hosts_list[@]}")
fi
unset h


case "${fixall_mode}" in
  survey)
    i=0
    for h in "${target_hosts[@]}"
    do
      (( i++ < MAX_PARALLEL_JOBS )) || wait -n
      (
        ssh -n -q -oBatchMode=yes -oConnectTimeout=3 "${h}" 'sudo --non-interactive -- apt-get -qq update; sudo --non-interactive apt-get -qq --simulate dist-upgrade' \
        || printf 'apt-fixall: ERROR: Package upgrade survey failed for host: %s\n' "${h}" >&2
      ) &
    done | awk '/^Inst/{ if(!pkgs[$2]++){print $2} }'
    wait
  ;;

  survey_with_hostname)
    i=0
    for h in "${target_hosts[@]}"
    do
      (( i++ < MAX_PARALLEL_JOBS )) || wait -n
      (
        while read -r op pkg _
        do
          [[ ${op} = Inst ]] && printf '%s: %s\n' "${h}" "${pkg}"
        done < <(ssh -n -q -oBatchMode=yes -oConnectTimeout=3 "${h}" 'sudo --non-interactive -- apt-get -qq update; sudo --non-interactive apt-get -qq --simulate dist-upgrade' \
                 || printf 'apt-fixall: ERROR: Package upgrade survey failed for host: %s\n' "${h}" >&2)
      ) &
    done
    wait
  ;;

  interactive)
    for h in "${target_hosts[@]}"
    do
      _host_begin "${h}"
      ssh -tt -q -oBatchMode=yes -oConnectTimeout=3 "${h}" \
        'printf "Local hostname: "; hostname --fqdn; set -x; sudo --non-interactive -- apt-get update -qq; sudo --non-interactive -- apt-get dist-upgrade; sudo --non-interactive -- apt-get --purge autoremove'
      _host_end "${h}"
    done
  ;;

  auto_upgrade)
    i=0
    for h in "${target_hosts[@]}"
    do
      # XXX TODO FIXME: Connection problems may lead to spurious "All packages up to date" bogus messages
      _host_begin "${h}"
      # Find out which packages on the host a dist-upgrade would actually upgrade
      unset pkgs_on_host
      declare -a pkgs_on_host
      mapfile -t pkgs_on_host < \
          <(ssh -n -tt -q -oBatchMode=yes -oConnectTimeout=3 "${h}" \
          'set -x; sudo --non-interactive apt-get update -qq; sudo --non-interactive -- apt-get -qq --simulate dist-upgrade' \
          | awk '/^Inst/{print $2}')
  
      # Diff the list of supplied/"whitelisted" packages to upgrade (on the
      # command line) with the list of available packages determined for
      # dist-upgrade above
      unset pkgs_to_upgrade pkgs_avail_but_not_whitelisted
      declare -a pkgs_to_upgrade pkgs_avail_but_not_whitelisted
      for p in "${pkgs_on_host[@]}"
      do
          _found=0
          for arg in "${@}"
          do
          if [[ ${p} = "${arg}" ]]
          then
              : ::: "hit: ${p} == ${arg}"
              pkgs_to_upgrade+=("${p}")
              _found=1
          fi
          done
          if [[ ${_found} -ne 1 ]]
          then
          : ::: "fallthru adding: ${p}"
          pkgs_avail_but_not_whitelisted+=("${p}")
          fi
      done

      # For all packages that were determined to be in both the set of upgradable
      # packages, as well as in the set of packages the user wants us to upgrade,
      # use a safe invocation for apt-get to upgrade that resulting list of
      # packages only.
      if [[ ${#pkgs_to_upgrade[@]} -gt 0 ]]
      then
          ssh -n -tt -q -oBatchMode=yes -oConnectTimeout=3 "${h}" \
          'printf "Local hostname: "; hostname --fqdn; set -x; NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive UCF_FORCE_CONFFOLD=true sudo --preserve-env=DEBIAN_FRONTEND,UCF_FORCE_CONFFOLD,NEEDRESTART_MODE --non-interactive -- apt-get -qq -o Dpkg::Options::=--force-confold -o Dpkg::Options::=--force-confdef install --only-upgrade '"${pkgs_to_upgrade[*]}"
      fi

      # After installation of "whitelisted" upgrades has finished, summarize if
      # there's additional upgrades pending on this host.
      if [[ ${#pkgs_on_host[@]} -gt 0 && ${#pkgs_to_upgrade[@]} -lt ${#pkgs_on_host[@]} ]]
      then
          _c_on 6
          printf '\t Available upgrades not explicitly requested to perform: %d \t( %s )\n' \
          "${#pkgs_avail_but_not_whitelisted[@]}" "${pkgs_avail_but_not_whitelisted[*]}"
          ((warn_count++))
          _c_off
      else
          _c_on 2
          printf '\t All packages up to date.\n'
          _c_off
      fi
      _host_end "${h}"
    done
  ;;

  *)
    echo ERROR >&2
    exit 127
  ;;
esac


[[ ${warn_count} -gt 0 ]] && printf 'apt-fixall: %d warning(s) reported.\n' "${warn_count}" >&2 && exit_status=2
[[ ${error_count} -gt 0 ]] && printf 'apt-fixall: %d error(s) reported.\n' "${error_count}" >&2 && exit_status=1

exit "${exit_status:-0}"
