#!/bin/sh
# shellcheck disable=SC1111 # deutsche Anführungszeichen
# bashclub-updates: automatische Updates und Neustart nach Bedarf.
#
#   bashclub-updates apply
#       Einstellungen aus /etc/bashclub/updates.conf übernehmen (auch beim
#       Start und mit systemctl restart bashclub-updates-apply): apt
#       (unattended-upgrades für Debian und apt.bashclub.org), Zeitpunkt der
#       Updates (apt-daily, apt-daily-upgrade) und des Neustarts
#       (bashclub-reboot.timer)
#   bashclub-updates reboot-check
#       Neustart, wenn er nötig ist (neuer Kernel laut needrestart oder
#       /run/reboot-required) und REBOOT es erlaubt; läuft aus
#       bashclub-reboot.timer (alle 15 min, bei window wirksam nur im
#       Fenster REBOOT_CALENDAR + REBOOT_WINDOW). Neu gestartet wird über
#       bashclub-be reboot (Reboot-Guard), das Herunterfahren bereitet den
#       Probe-Boot vor. Läuft gerade apt oder meldet ein Skript aus busy.d
#       Aktivität, wird es beim nächsten Lauf versucht.
#   bashclub-updates postpone <dauer>
#       Neustart verschieben (z. B. 2h, 1d), höchstens bis zur Frist
#   bashclub-updates now
#       Nötigen Neustart sofort ausführen (ohne Fenster und busy.d)
#   bashclub-updates status
#       Einstellungen, nächste Läufe, ob ein Neustart nötig ist, Frist
#
# Erweiterungen (gleicher Name in /etc ersetzt, Link auf /dev/null schaltet ab):
#   busy.d/   in /usr/lib/bashclub-updates und /etc/bashclub: Rückgabe ≠ 0
#             = gerade aktiv, letzte Ausgabezeile ist der Grund; der Neustart
#             wartet, bis die Frist (REBOOT_DEADLINE) abgelaufen ist
#   notify.d/ ebenda: Aufruf mit <ereignis> <text>; Ereignisse planned
#             (Neustart nötig), postponed, deadline-soon (Frist läuft in
#             REBOOT_DEADLINE_WARN ab), deadline (Frist abgelaufen),
#             reboot (jetzt); Umgebung BASHCLUB_UPDATES_REASONS,
#             BASHCLUB_UPDATES_DEADLINE (Unix-Zeit)
#
# Einstellungen: /etc/bashclub/updates.conf
set -eu

AUTO=yes
UPDATE_CALENDAR=hourly
REBOOT=window
REBOOT_CALENDAR="*-*-* 04:30:00"
REBOOT_WINDOW=2h
REBOOT_DEADLINE=7d
REBOOT_DEADLINE_WARN=24h
CONF=${BASHCLUB_UPDATES_CONF:-/etc/bashclub/updates.conf}
# shellcheck source=/dev/null
[ -r "$CONF" ] && . "$CONF"
ROOT=${BASHCLUB_UPDATES_ROOT:-}
APT_CONF=$ROOT/etc/apt/apt.conf.d/52bashclub-updates
UNITS=$ROOT/etc/systemd/system
REQUIRED=${BASHCLUB_UPDATES_REQUIRED:-/run/reboot-required}
STATE=${BASHCLUB_UPDATES_STATE:-/run/bashclub-updates}
# Über Neustarts hinweg: seit wann ein Neustart nötig ist, Verschiebung
LIB=${BASHCLUB_UPDATES_LIB:-/var/lib/bashclub-updates}
HOOK_DIRS=${BASHCLUB_UPDATES_HOOKS:-/usr/lib/bashclub-updates /etc/bashclub}

say() { printf '%s\n' "$*"; }
warn() { printf 'bashclub-updates: %s\n' "$*" >&2; }
log() {
	say "$*"
	logger -t bashclub-updates -p notice "$*" 2>/dev/null || true
}

check_conf() {
	case $AUTO in yes | no) ;; *) warn "AUTO=$AUTO: erwartet yes oder no"; exit 2 ;; esac
	case $REBOOT in window | asap | never) ;; *) warn "REBOOT=$REBOOT: erwartet window, asap oder never"; exit 2 ;; esac
	for d in "$REBOOT_WINDOW" "$REBOOT_DEADLINE" "$REBOOT_DEADLINE_WARN"; do
		seconds "$d" >/dev/null || { warn "ungültige Dauer: $d (z. B. 30m, 2h, 7d)"; exit 2; }
	done
	for c in "$UPDATE_CALENDAR" "$REBOOT_CALENDAR"; do
		if command -v systemd-analyze >/dev/null 2>&1 && ! systemd-analyze calendar "$c" >/dev/null 2>&1; then
			warn "ungültige Zeitangabe: $c (systemd OnCalendar, z. B. \"*-*-* 04:30:00\")"
			exit 2
		fi
	done
}

# Dauer in Sekunden: <zahl>[s|m|h|d]
seconds() {
	case $1 in
	*[!0-9smhd]* | '' | [smhd]*) return 1 ;;
	*d) say $((${1%d} * 86400)) ;;
	*h) say $((${1%h} * 3600)) ;;
	*m) say $((${1%m} * 60)) ;;
	*s) say "${1%s}" ;;
	*) say "$1" ;;
	esac
}

now() { date +%s; }
when() { date -d "@$1" '+%Y-%m-%d %H:%M'; }

# Drop-in mit Zeitplan für einen Timer
schedule() { # <timer> <OnCalendar>…
	t=$1
	shift
	mkdir -p "$UNITS/$t.d"
	{
		printf '# bashclub-updates (aus %s)\n[Timer]\nOnCalendar=\n' "$CONF"
		for c in "$@"; do printf 'OnCalendar=%s\n' "$c"; done
		printf 'RandomizedDelaySec=0\n'
	} >"$UNITS/$t.d/50-bashclub-updates.conf"
}

apply() {
	check_conf
	# always: so oft, wie die Timer laufen (UPDATE_CALENDAR, auch stündlich);
	# "1" hieße höchstens einmal am Tag
	if [ "$AUTO" = yes ]; then on=always; else on=0; fi
	mkdir -p "${APT_CONF%/*}"
	cat >"$APT_CONF" <<C
// Von bashclub-updates aus $CONF – nicht von Hand ändern, sondern dort
// einstellen und bashclub-updates apply aufrufen.
APT::Periodic::Update-Package-Lists "$on";
APT::Periodic::Unattended-Upgrade "$on";
APT::Periodic::AutocleanInterval "7";
// Zusätzlich zu Debians Vorgaben (50unattended-upgrades): Debian-Updates und
// alles von apt.bashclub.org (Kernel mit ZFS, ZFSBootMenu, bashclub-Pakete)
Unattended-Upgrade::Origins-Pattern {
	"origin=Debian,codename=\${distro_codename}-updates";
	"site=apt.bashclub.org";
};
// Neustart nicht durch unattended-upgrades, sondern bashclub-reboot.timer
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Dpkg::Options {
	"--force-confdef";
	"--force-confold";
};
C
	# Paketlisten und Installation zur selben Zeit; apt-daily-upgrade wartet
	# auf apt-daily (After= in Debians Units)
	schedule apt-daily.timer "$UPDATE_CALENDAR"
	schedule apt-daily-upgrade.timer "$UPDATE_CALENDAR"
	case $REBOOT in
	# window: zum Fensterbeginn und alle 15 Minuten (wirkt nur im Fenster:
	# erneuter Versuch, wenn gerade jemand aktiv ist)
	window) schedule bashclub-reboot.timer "$REBOOT_CALENDAR" "*:0/15" ;;
	# asap: alle 15 Minuten prüfen; never: stündlich, nur melden
	asap) schedule bashclub-reboot.timer "*:0/15" ;;
	never) schedule bashclub-reboot.timer "hourly" ;;
	esac
	if [ -z "$ROOT" ] && [ -d /run/systemd/system ]; then
		systemctl daemon-reload
		systemctl try-restart apt-daily.timer apt-daily-upgrade.timer bashclub-reboot.timer || true
	fi
	say "Updates: $(describe)"
}

describe() {
	if [ "$AUTO" = yes ]; then auto="automatisch ($UPDATE_CALENDAR)"; else auto="nicht automatisch"; fi
	case $REBOOT in
	window) reboot="Neustart bei Bedarf ($REBOOT_CALENDAR, Fenster $REBOOT_WINDOW)" ;;
	asap) reboot="Neustart bei Bedarf sofort" ;;
	never) reboot="kein automatischer Neustart" ;;
	esac
	say "$auto, $reboot"
}

# Gründe für einen Neustart (leer: keiner nötig)
needed() {
	if command -v needrestart >/dev/null 2>&1; then
		ksta=$(needrestart -b -r l 2>/dev/null | sed -n 's/^NEEDRESTART-KSTA: //p')
		case $ksta in 2 | 3)
			say "Kernel $(needrestart -b -r l 2>/dev/null | sed -n 's/^NEEDRESTART-KCUR: //p') → $(needrestart -b -r l 2>/dev/null | sed -n 's/^NEEDRESTART-KEXP: //p')" ;;
		esac
	fi
	if [ -e "$REQUIRED" ]; then
		say "reboot-required$( [ -s "$REQUIRED.pkgs" ] && printf ' (%s)' "$(sort -u "$REQUIRED.pkgs" | tr '\n' ' ' | sed 's/ $//')")"
	fi
}

# Skripte aus <name>.d in HOOK_DIRS, gleicher Name weiter hinten ersetzt
hooks() {
	names=$(for d in $HOOK_DIRS; do ls "$d/$1" 2>/dev/null; done | sort -u)
	for n in $names; do
		f=""
		for d in $HOOK_DIRS; do
			if [ -e "$d/$1/$n" ] || [ -L "$d/$1/$n" ]; then f=$d/$1/$n; fi
		done
		if [ -f "$f" ] && [ -x "$f" ]; then say "$f"; fi
	done
}

# Gründe aus busy.d (leer: niemand aktiv)
busy_hooks() {
	for f in $(hooks busy.d); do
		if ! out=$(timeout 30 "$f" 2>&1 </dev/null); then
			say "${f##*/}: $(printf '%s\n' "$out" | tail -n 1)"
		fi
	done
}

# Ankündigung: Journal und notify.d; Fehler der Skripte halten nichts auf
notify() { # <ereignis> <text>
	log "$2"
	for f in $(hooks notify.d); do
		BASHCLUB_UPDATES_REASONS=$reasons BASHCLUB_UPDATES_DEADLINE=${deadline:-} \
			timeout 30 "$f" "$1" "$2" </dev/null >/dev/null 2>&1 || warn "${f##*/}: Fehler bei $1"
	done
}

# Liegt jetzt im Fenster [REBOOT_CALENDAR, + REBOOT_WINDOW)?
in_window() {
	w=$(seconds "$REBOOT_WINDOW")
	t=$(now)
	start=$(systemd-analyze calendar --base-time="@$((t - w))" "$REBOOT_CALENDAR" 2>/dev/null |
		sed -n 's/^ *Next elapse: [A-Za-z]* //p' | head -n 1)
	[ -n "$start" ] && [ "$(date -d "$start" +%s)" -le "$t" ]
}

# Seit wann ein Neustart nötig ist (merkt sich den ersten Lauf) und Frist
track_needed() {
	mkdir -p "$LIB"
	[ -s "$LIB/needed-since" ] || now >"$LIB/needed-since"
	since=$(cat "$LIB/needed-since")
	deadline=$((since + $(seconds "$REBOOT_DEADLINE")))
}

# Gründe, gerade nicht neu zu starten (apt läuft)
busy() {
	# unattended-upgrades läuft in apt-daily-upgrade.service
	for unit in apt-daily.service apt-daily-upgrade.service; do
		if systemctl is-active -q "$unit" 2>/dev/null; then say "$unit läuft"; fi
	done
	if command -v flock >/dev/null 2>&1 && [ -e /var/lib/dpkg/lock-frontend ] && ! flock -n /var/lib/dpkg/lock-frontend true 2>/dev/null; then
		say "dpkg läuft"
	fi
}

reboot_check() {
	check_conf
	reasons=$(needed | paste -sd ';' -)
	mkdir -p "$STATE"
	if [ -z "$reasons" ]; then
		rm -f "$STATE/reboot-needed" "$LIB/needed-since" "$LIB/notified" "$LIB/deadline-soon-notified" "$LIB/deadline-notified"
		return 0
	fi
	printf '%s\n' "$reasons" >"$STATE/reboot-needed"
	if [ "$REBOOT" = never ]; then
		say "Neustart nötig ($reasons), REBOOT=never: nur gemeldet"
		return 0
	fi
	track_needed
	t=$(now)
	# Jede Ankündigung nur einmal (neue Gründe: neue Ankündigung)
	if [ "$(cat "$LIB/notified" 2>/dev/null)" != "$reasons" ]; then
		notify planned "Neustart nötig ($reasons), spätestens $(when "$deadline")"
		printf '%s\n' "$reasons" >"$LIB/notified"
	fi
	# Vorwarnung: danach zählen Verschieben und busy.d nicht mehr, der
	# Neustart kann dann auch in die Arbeitszeit fallen
	if [ "$t" -lt "$deadline" ] && [ "$t" -ge $((deadline - $(seconds "$REBOOT_DEADLINE_WARN"))) ] &&
		[ ! -e "$LIB/deadline-soon-notified" ]; then
		notify deadline-soon "Frist für den Neustart läuft am $(when "$deadline") ab ($reasons): danach wird neu gestartet, auch wenn jemand aktiv ist"
		: >"$LIB/deadline-soon-notified"
	fi
	overdue=no
	if [ "$t" -ge "$deadline" ]; then
		overdue=yes
		if [ ! -e "$LIB/deadline-notified" ]; then
			notify deadline "Frist für den Neustart abgelaufen ($reasons): Verschieben und busy.d gelten nicht mehr"
			: >"$LIB/deadline-notified"
		fi
	fi
	if [ "$REBOOT" = window ] && ! in_window; then
		return 0
	fi
	blocked=$(busy | paste -sd ';' -)
	if [ -n "$blocked" ]; then
		say "Neustart nötig ($reasons), später: $blocked"
		return 0
	fi
	if [ "$overdue" = no ]; then
		till=$(cat "$LIB/postponed-until" 2>/dev/null || echo 0)
		if [ "$t" -lt "$till" ]; then
			say "Neustart nötig ($reasons), verschoben bis $(when "$till")"
			return 0
		fi
		blocked=$(busy_hooks | paste -sd ';' -)
		if [ -n "$blocked" ]; then
			say "Neustart nötig ($reasons), später: $blocked"
			return 0
		fi
	fi
	do_reboot
}

do_reboot() {
	# Zustand in LIB räumt der erste Lauf nach dem Neustart weg; lehnt der
	# Reboot-Guard ab, bleiben Frist und Ankündigung bestehen
	notify reboot "Neustart jetzt: $reasons"
	exec bashclub-be reboot
}

postpone() {
	check_conf
	d=$(seconds "${1:-}") || { warn "Aufruf: bashclub-updates postpone <dauer> (z. B. 2h, 1d)"; exit 2; }
	reasons=$(needed | paste -sd ';' -)
	till=$(($(now) + d))
	if [ -n "$reasons" ]; then
		track_needed
		if [ "$till" -gt "$deadline" ]; then
			warn "nur bis zur Frist: $(when "$deadline")"
			till=$deadline
		fi
	fi
	mkdir -p "$LIB"
	say "$till" >"$LIB/postponed-until"
	notify postponed "Neustart verschoben bis $(when "$till")${reasons:+ ($reasons)}"
}

now_reboot() {
	check_conf
	reasons=$(needed | paste -sd ';' -)
	if [ -z "$reasons" ]; then
		say "Kein Neustart nötig."
		return 0
	fi
	blocked=$(busy | paste -sd ';' -)
	if [ -n "$blocked" ]; then
		warn "nicht jetzt: $blocked"
		exit 1
	fi
	deadline=""
	do_reboot
}

status() {
	say "Einstellungen: $(describe) ($CONF)"
	systemctl list-timers --no-pager apt-daily-upgrade.timer bashclub-reboot.timer 2>/dev/null | head -3 || true
	reasons=$(needed | paste -sd ';' -)
	say "Neustart nötig: ${reasons:-nein}"
	if [ -n "$reasons" ] && [ -s "$LIB/needed-since" ]; then
		say "Frist: $(when $(($(cat "$LIB/needed-since") + $(seconds "$REBOOT_DEADLINE"))))"
	fi
	till=$(cat "$LIB/postponed-until" 2>/dev/null || echo 0)
	if [ "$till" -gt "$(now)" ]; then say "Verschoben bis: $(when "$till")"; fi
	blocked=$(busy_hooks | paste -sd ';' -)
	say "Aktiv (busy.d): ${blocked:-nichts}"
}

case "${1:-}" in
apply) apply ;;
reboot-check) reboot_check ;;
postpone) postpone "${2:-}" ;;
now) now_reboot ;;
status) status ;;
-h | --help) sed -n '3,/^set -eu/{/^set -eu/d;s/^# \{0,1\}//;p}' "$0" ;;
*) echo "Aufruf: bashclub-updates apply | reboot-check | postpone <dauer> | now | status" >&2; exit 2 ;;
esac
