diff --git a/classes/updatehub-image.bbclass b/classes/updatehub-image.bbclass index 60f3421..3336247 100644 --- a/classes/updatehub-image.bbclass +++ b/classes/updatehub-image.bbclass @@ -71,6 +71,14 @@ # The active and inactive image schema requires a backend to identify and choose the image to be # used for next boot. It supports: 'u-boot', 'grub' or 'grub-efi'. # +# UPDATEHUB_VALIDATION_TIMEOUT +# +# How long a freshly installed image has to validate itself before updatehub-rollback-guard +# reboots it so the bootloader can roll back. Defaults to '5min', accepts any systemd time span. +# Enabled automatically with the 'u-boot' active/inactive backend when 'systemd' is in +# DISTRO_FEATURES; there is no sysvinit equivalent. Keep it comfortably above the time the agent +# needs to reach the validation callback on the slowest supported hardware. +# # UPDATEHUB_INSTALL_MODE # # There are multiple installation modes supported. This is usually machine dependent as it depends diff --git a/classes/updatehub-runtime.bbclass b/classes/updatehub-runtime.bbclass index f079dbf..4ea4746 100644 --- a/classes/updatehub-runtime.bbclass +++ b/classes/updatehub-runtime.bbclass @@ -167,6 +167,12 @@ python () { raise bb.parse.SkipRecipe("'%s' in UPDATEHUB_ACTIVE_INACTIVE_BACKEND is not a valid active/inactive backend. Valid active/inactive backends are: %s" % (active_inactive_backend, ' '.join(valid_active_inactive_backends))) elif active_inactive_backend: d.appendVar('UPDATEHUB_RUNTIME_PACKAGES', ' updatehub-active-inactive-backend-%s' % active_inactive_backend) + + # Guards against updates that never validate (see UPDATEHUB_VALIDATION_TIMEOUT). + # Requires the u-boot backend (reads its boot counter) and systemd (it's a timer). + if active_inactive_backend == 'u-boot' and \ + bb.utils.contains('DISTRO_FEATURES', 'systemd', True, False, d): + d.appendVar('UPDATEHUB_RUNTIME_PACKAGES', ' updatehub-rollback-guard') } def sanitise_version(ver): diff --git a/recipes-core/updatehub/updatehub-rollback-guard.bb b/recipes-core/updatehub/updatehub-rollback-guard.bb new file mode 100644 index 0000000..d345a60 --- /dev/null +++ b/recipes-core/updatehub/updatehub-rollback-guard.bb @@ -0,0 +1,55 @@ +# Copyright 2026 (C) O.S. Systems Software LTDA. + +SUMMARY = "Roll back an update which fails to validate itself" +DESCRIPTION = "Reboots the system when a freshly installed image does not \ +validate itself within UPDATEHUB_VALIDATION_TIMEOUT, so U-Boot can count the \ +boot attempt and roll back to the previously working image." +LICENSE = "MIT" +LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" + +SRC_URI = " \ + file://${BPN} \ + file://${BPN}.service \ + file://${BPN}.timer \ +" + +S = "${WORKDIR}" + +UPDATEHUB_VALIDATION_TIMEOUT ?= "5min" + +# The timeout is baked into the installed files, so two machines configuring it +# differently must not share a package. +PACKAGE_ARCH = "${MACHINE_ARCH}" + +# Nothing is built; do not stage a cross toolchain for three text files. +INHIBIT_DEFAULT_DEPS = "1" + +# The grace period is a systemd timer. Fail loudly rather than install units +# which no init system will ever run. +REQUIRED_DISTRO_FEATURES = "systemd" + +inherit features_check systemd + +do_configure[noexec] = "1" +do_compile[noexec] = "1" + +# The timer is what gets enabled; it pulls in the service when it elapses. +SYSTEMD_SERVICE:${PN} = "${BPN}.timer" + +do_install() { + install -Dm 0755 ${WORKDIR}/${BPN} ${D}${bindir}/${BPN} + install -Dm 0644 ${WORKDIR}/${BPN}.service ${D}${systemd_system_unitdir}/${BPN}.service + install -Dm 0644 ${WORKDIR}/${BPN}.timer ${D}${systemd_system_unitdir}/${BPN}.timer + + sed -i -e 's,@VALIDATION_TIMEOUT@,${UPDATEHUB_VALIDATION_TIMEOUT},g' \ + -e 's,@BINDIR@,${bindir},g' \ + ${D}${bindir}/${BPN} \ + ${D}${systemd_system_unitdir}/${BPN}.service \ + ${D}${systemd_system_unitdir}/${BPN}.timer +} + +# systemd.bbclass doesn't follow the timer's Unit= to package the .service. +FILES:${PN} += "${systemd_system_unitdir}/${BPN}.service" + +# fw_printenv, to read the boot counter state from the U-Boot environment. +RDEPENDS:${PN} += "u-boot-fw-utils" diff --git a/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard b/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard new file mode 100644 index 0000000..0a5ea44 --- /dev/null +++ b/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard @@ -0,0 +1,49 @@ +#!/bin/sh +# -*- shell-script -*- +# +# Copyright 2026 (C) O.S. Systems Software LTDA. +# +# Force a reboot when a freshly installed image fails to validate itself. +# +# U-Boot only counts boot attempts (bootcount) while upgrade_available=1; a +# healthy boot clears that flag via updatehub-active-validated. A boot that +# fails services but leaves systemd running is not a hang -- systemd keeps +# petting the watchdog -- so bootcount never advances and a broken image can +# sit in the active slot forever. This script reboots once the timeout below +# elapses so U-Boot counts the attempt and can roll back to the other slot. +# +# No 'set -e': runs in an already-broken boot, so a failing diagnostic must +# never stop the script short of the reboot. + +TIMEOUT="@VALIDATION_TIMEOUT@" + +# BOOTCOUNT_ENV=0 means the counter isn't in the U-Boot environment: nothing to police. +BOOTCOUNT_ENV=1 +if [ -f /etc/default/updatehub-active ]; then + . /etc/default/updatehub-active +fi +[ "$BOOTCOUNT_ENV" = "1" ] || exit 0 + +# Log to kmsg too: a boot broken enough to need this often has no working journal. +log() { + logger -t updatehub "updatehub-rollback-guard: $*" 2>/dev/null + echo "updatehub-rollback-guard: $*" > /dev/kmsg 2>/dev/null +} + +# Single call: a second fw_printenv re-reads storage, which may be what's broken. +{ read -r upgrade_available; read -r bootcount; } </dev/null) +EOF + +# Not a probationary boot. +if [ "$upgrade_available" != "1" ]; then + exit 0 +fi + +log "image did not validate itself within $TIMEOUT (bootcount=${bootcount:-?}); rebooting so U-Boot counts the attempt and can roll back" + +# Not touching bootcount/updatehub_active: that's U-Boot's and the boot script's job. +# sysrq is the real fallback, for when PID 1 can't be reached. +systemctl --no-block reboot || echo b > /proc/sysrq-trigger + +exit 0 diff --git a/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard.service b/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard.service new file mode 100644 index 0000000..d247522 --- /dev/null +++ b/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard.service @@ -0,0 +1,9 @@ +[Unit] +Description=Roll back an update that failed to validate itself +# No deps: must run even when the data partition/agent/targets pulling them in have failed. +DefaultDependencies=no +IgnoreOnIsolate=yes + +[Service] +Type=oneshot +ExecStart=@BINDIR@/updatehub-rollback-guard diff --git a/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard.timer b/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard.timer new file mode 100644 index 0000000..d8af180 --- /dev/null +++ b/recipes-core/updatehub/updatehub-rollback-guard/updatehub-rollback-guard.timer @@ -0,0 +1,16 @@ +[Unit] +Description=Grace period before rolling back an unvalidated update +# Default deps order this after sysinit.target, which a broken-enough boot never reaches. +DefaultDependencies=no +IgnoreOnIsolate=yes +Conflicts=shutdown.target +Before=shutdown.target + +[Timer] +# From boot, not from timer start, so a slow/partial boot can't extend the grace period. +OnBootSec=@VALIDATION_TIMEOUT@ +Unit=updatehub-rollback-guard.service + +[Install] +# emergency/rescue.target isolate and don't pull in timers.target, so list them explicitly. +WantedBy=timers.target emergency.target rescue.target