diff --git a/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg b/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg index 0303c391a..d8d42b8f6 100644 --- a/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg +++ b/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg @@ -346,6 +346,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "server": [ { diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg index b5e2b2b33..799bb7aef 100644 --- a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg @@ -339,6 +339,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "server": [ { diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg index 1779d4559..a4b6256cb 100644 --- a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg @@ -305,6 +305,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "server": [ { diff --git a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg index 564521b82..78c9a69bd 100644 --- a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg @@ -263,6 +263,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "server": [ { diff --git a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg index 2e8a58683..d5fa27042 100644 --- a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg @@ -255,6 +255,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "server": [ { diff --git a/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg index a18ed0c72..af616e23c 100644 --- a/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg @@ -297,6 +297,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "server": [ { diff --git a/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg b/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg index 20fd7dde2..ea01bc806 100644 --- a/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg +++ b/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg @@ -239,6 +239,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "server": [ { diff --git a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg index 4e682bcee..33998c5d0 100644 --- a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg +++ b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg @@ -216,6 +216,64 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "enabled": true, "server": [ diff --git a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg index 2fe691a7d..0094635ba 100644 --- a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg +++ b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg @@ -230,6 +230,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "enabled": true, "server": [ diff --git a/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg b/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg index a9b87f266..75cca7917 100644 --- a/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg +++ b/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg @@ -201,6 +201,35 @@ "infix-system:software": { "update-url": "https://github.com/kernelkit/infix/releases.atom" }, + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [ + 3 + ] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [ + { + "weekday": "sunday" + } + ], + "byhour": [ + 3 + ] + } + } + ] + }, "ntp": { "enabled": true, "server": [ diff --git a/board/common/post-build.sh b/board/common/post-build.sh index eef3e9492..5a94fe885 100755 --- a/board/common/post-build.sh +++ b/board/common/post-build.sh @@ -48,6 +48,11 @@ fi mkdir -p "$TARGET_DIR/etc/hostname.d" cp "$TARGET_DIR/etc/hostname" "$TARGET_DIR/etc/hostname.d/10-default" +# Finit supervises the rauc service, so D-Bus must not start a second +# instance when a client calls before it owns its name. +rm -f "$TARGET_DIR/usr/share/dbus-1/system-services/de.pengutronix.rauc.service" \ + "$TARGET_DIR/usr/libexec/rauc-service.sh" + # This is a symlink to /usr/lib/os-release, so we remove this to keep # original Buildroot information. ixmsg "Creating /etc/os-release" diff --git a/board/common/rootfs/etc/finit.d/10-infix.conf b/board/common/rootfs/etc/finit.d/10-infix.conf index de5e9765c..de09e10ae 100644 --- a/board/common/rootfs/etc/finit.d/10-infix.conf +++ b/board/common/rootfs/etc/finit.d/10-infix.conf @@ -2,6 +2,6 @@ task name:ixinit [S] \ /usr/libexec/finit/runparts -bp /usr/libexec/infix/init.d \ -- Probing system -task name:slot-check [2345] \ +task name:slot-check [2345] \ /usr/libexec/infix/slot-check \ -- Checking software partitions diff --git a/board/common/rootfs/etc/finit.d/available/rauc.conf b/board/common/rootfs/etc/finit.d/available/rauc.conf index f64890633..3fad97cdf 100644 --- a/board/common/rootfs/etc/finit.d/available/rauc.conf +++ b/board/common/rootfs/etc/finit.d/available/rauc.conf @@ -1,5 +1,5 @@ set G_MESSAGES_DEBUG=nocolor -service [2345] \ +service [2345] notify:systemd \ env:-/etc/default/rauc \ rauc service $RAUC_ARGS -- Software update service diff --git a/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf b/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf index 11494112b..44c50f698 100644 --- a/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf +++ b/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf @@ -1,2 +1,3 @@ f /run/os-update 0666 admin admin +f /run/software-update.json 0666 admin admin f /run/unattended-update.lock 0666 admin admin diff --git a/board/common/rootfs/usr/libexec/infix/iw.py b/board/common/rootfs/usr/libexec/infix/iw.py index 55dc08977..ff6335148 100755 --- a/board/common/rootfs/usr/libexec/infix/iw.py +++ b/board/common/rootfs/usr/libexec/infix/iw.py @@ -548,8 +548,9 @@ def parse_link(ifname): def parse_phy_caps(phy_name): """ - Parse 'iw phy info' for HT and VHT capability bitmasks. - Returns: {ht_cap: int, vht_cap: int} + Parse 'iw phy info' for HT and VHT capability bitmasks, + and whether the PHY supports HE (802.11ax). + Returns: {ht_cap: int, vht_cap: int, he: bool} iw phy info output format: Capabilities: 0x1ef @@ -562,10 +563,11 @@ def parse_phy_caps(phy_name): if not output: output = run_iw(actual_phy, 'info') if not output: - return {'ht_cap': 0, 'vht_cap': 0} + return {'ht_cap': 0, 'vht_cap': 0, 'he': False} ht_cap = 0 vht_cap = 0 + he = False for line in output.splitlines(): stripped = line.strip() @@ -580,7 +582,11 @@ def parse_phy_caps(phy_name): if vht_match: vht_cap = int(vht_match.group(1), 16) - return {'ht_cap': ht_cap, 'vht_cap': vht_cap} + # HE support: "HE Iftypes: AP, ..." under a band + if stripped.startswith('HE Iftypes:'): + he = True + + return {'ht_cap': ht_cap, 'vht_cap': vht_cap, 'he': he} def parse_mesh_param(ifname): diff --git a/board/common/rootfs/usr/libexec/infix/slot-check b/board/common/rootfs/usr/libexec/infix/slot-check index bed29658c..043da1a92 100755 --- a/board/common/rootfs/usr/libexec/infix/slot-check +++ b/board/common/rootfs/usr/libexec/infix/slot-check @@ -8,7 +8,7 @@ rauc status --detailed --output-format=json 2>/dev/null | jq -r ' | select(.slot_status.bundle.version != $booted) | "\(.bootname) \(.slot_status.bundle.version // "unknown") \($booted)"' | while read -r name version booted; do - msg="NOTE: the $name partition has $version, this is $booted. Use 'upgrade' to update it." - logger -t slot-check -p user.notice "$msg" - printf "\n%s\n" "$msg" | tee -a /etc/banner /etc/issue /etc/issue.net >/dev/null + logger -t slot-check -p user.notice "the $name partition is out of date ($version), this is $booted" + printf "\nNote: the %s partition is out of date (%s)\n Use 'upgrade' to update it to %s.\n\n" \ + "$name" "$version" "$booted" | tee -a /etc/banner /etc/issue /etc/issue.net >/dev/null done diff --git a/board/common/rootfs/usr/libexec/infix/update-common b/board/common/rootfs/usr/libexec/infix/update-common index e138318f2..6b76ec511 100644 --- a/board/common/rootfs/usr/libexec/infix/update-common +++ b/board/common/rootfs/usr/libexec/infix/update-common @@ -1,6 +1,16 @@ # Shared helpers for check-update and unattended-update. Sourced, not run; # the caller sets TAG first. +# Outcome of the last run, read by yanger for /system-state/software/update. +STATE_FILE=/run/software-update.json + +# Apply a jq filter to the state file, extra args go to jq (--arg name value). +# An empty or damaged file starts over from {}. +update_state() { + filter=$1; shift + new=$(jq -n "$@" "(input? // {}) | $filter" "$STATE_FILE") && printf '%s\n' "$new" > "$STATE_FILE" +} + # Read the shared update-url (an RSS/Atom release feed) from running-config. # The leaf is mandatory and factory-config supplies it, so an empty result # means the software container is absent -- no update source is configured. @@ -95,6 +105,25 @@ update_available() { newer "$LATEST_TAG" "$pending" } +# Query the feed and record the outcome. Same return values as +# update_probe(); on success UPDATE_AVAILABLE tells if the latest release +# should be applied. +update_check() { + update_probe + rc=$? + case $rc in + 0) + update_available && UPDATE_AVAILABLE=true || UPDATE_AVAILABLE=false + update_state '. + {"last-check": (now | todate), latest: $tag, "release-url": $url, available: $avail}' \ + --arg tag "$LATEST_TAG" --arg url "$(update_release_url)" --argjson avail "$UPDATE_AVAILABLE" + ;; + 2) + update_state '.["last-check"] = (now | todate) | del(.latest, .["release-url"], .available)' + ;; + esac + return $rc +} + # Print the release page URL of the latest release, for operator-facing logs. update_release_url() { [ -n "$RELEASE_BASE" ] || return 0 diff --git a/board/common/rootfs/usr/sbin/check-update b/board/common/rootfs/usr/sbin/check-update index 2cce6658a..f9a6b72bb 100755 --- a/board/common/rootfs/usr/sbin/check-update +++ b/board/common/rootfs/usr/sbin/check-update @@ -7,7 +7,7 @@ TAG=os-update . /usr/libexec/infix/update-common -update_probe +update_check rc=$? if [ $rc -eq 1 ]; then exit 1 @@ -21,7 +21,7 @@ if [ $rc -eq 3 ]; then exit 0 fi -if update_available; then +if [ "$UPDATE_AVAILABLE" = true ]; then RELEASE_URL=$(update_release_url) MSG="Software update available: ${LATEST_TAG}, running ${VERSION} (see ${RELEASE_URL})" logger -t "$TAG" "$MSG" diff --git a/board/common/rootfs/usr/sbin/unattended-update b/board/common/rootfs/usr/sbin/unattended-update index 9059ea862..afed905b9 100755 --- a/board/common/rootfs/usr/sbin/unattended-update +++ b/board/common/rootfs/usr/sbin/unattended-update @@ -35,7 +35,7 @@ if ! flock -n 9; then exit 0 fi -update_probe +update_check rc=$? if [ $rc -eq 1 ]; then exit 1 @@ -49,7 +49,7 @@ if [ $rc -eq 3 ]; then exit 0 fi -if ! update_available; then +if [ "$UPDATE_AVAILABLE" != true ]; then logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST_TAG)" exit 0 fi @@ -66,6 +66,8 @@ if ! rauc install "$BUNDLE_URL"; then logger -t "$TAG" "ERROR: installation of ${LATEST_TAG} failed" exit 1 fi +update_state '. + {"last-install": (now | todate), installed: $tag, available: false}' \ + --arg tag "$LATEST_TAG" POLICY=$(read_reboot_policy) if [ "$POLICY" = immediate ]; then diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 897ada08d..5f5f93275 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -28,10 +28,14 @@ All notable changes to the project are documented in this file. Access is limited to users in the NACM `admin` group - WebUI: the support bundle is collected with the new RPC, as the logged-in user -- Add support for unattended software upgrades, letting a unit track an RSS/Atom +- Add support for unattended software updates, letting a unit track an RSS/Atom release feed on a schedule and install a newer release to the inactive partition on its own, then either reboot to activate it or leave it staged for - the next reboot, see [Unattended Updates][unattended] + the next reboot, see [Unattended Software Updates][unattended] +- The factory configuration now provides two schedules, `nightly` and `weekly`, + ready to be referenced by any scheduled feature +- Features that run on a schedule are now active as soon as they reference + one, see [Scheduling][schedule] for details - Add Novarq Tactical-1000 support: LAN9696 (Laguna) switch with 24 GbE copper ports, four SFP+ cages, and a management port, booting Infix from eMMC with the usual A/B slots, see the [board README][tactical] for details @@ -124,6 +128,9 @@ All notable changes to the project are documented in this file. - A startup-config that fails to load, or hangs, now resets the unit and the next boot goes straight to failure-config, see [Broken startup-config][brokencfg] +- Fix Wi-Fi access point failing to start on radios without 802.11ax or + 802.11ac, e.g. the BPi-R64, the hostapd modes now follow what the radio + supports - First boot after a factory reset: - Fix status LED still blinking after the first boot - Fix resize looping forever on a disk with a `var` partition but no @@ -154,7 +161,8 @@ All notable changes to the project are documented in this file. [tftp]: https://www.kernelkit.org/infix/latest/tftp/ [tactical]: https://github.com/kernelkit/infix/blob/main/board/aarch64/novarq-tactical-1000/README.md [netboot]: https://www.kernelkit.org/infix/latest/netboot/ -[unattended]: https://www.kernelkit.org/infix/latest/upgrade/#unattended-updates +[unattended]: https://www.kernelkit.org/infix/latest/upgrade/#unattended-software-updates +[schedule]: https://www.kernelkit.org/infix/latest/schedule/ [v26.08.0][] - 2026-09-01 ------------------------- diff --git a/doc/schedule.md b/doc/schedule.md index 09ebb8734..64a7fa1e2 100644 --- a/doc/schedule.md +++ b/doc/schedule.md @@ -1,52 +1,44 @@ # Scheduling -Some system functions run periodically instead of on demand. One such -type of recurrence is scheduled activities, like checking for new software -updates or rebooting in a nightly maintenance window. - -The recurrence itself lives in one place: a *named schedule*, which any -number of features can point at. A schedule has no action of its own, it -only says *when* something should happen, and the feature referencing it -decides *what* happens. Two features can share the same schedule. - -YANG support is defined in [infix-schedule][1], which augments -`ietf-system` with a `schedules` container and builds on the iCalendar -recurrence grouping from [ietf-schedule][2] (RFC 9922). - - -## Creating a Schedule - -Every schedule has a name, which is how features refer to it, and a -recurrence rule, which decides when it fires. The example below fires -every night at 03:30. - -
admin@example:/> configure
-admin@example:/config/> edit system schedule nightly
-admin@example:/config/system/schedule/nightly/> set description "Nightly maintenance window"
-admin@example:/config/system/schedule/nightly/> set recurrence frequency daily
-admin@example:/config/system/schedule/nightly/> set recurrence byhour 3
-admin@example:/config/system/schedule/nightly/> set recurrence byminute 30
-admin@example:/config/system/schedule/nightly/> leave
+Some things happen on a timer rather than on demand: checking for new
+software, installing it, or rebooting in a maintenance window.  The timer
+is a *named schedule*, which says when something happens.  Features point
+at a schedule by name and decide what happens, so one schedule can drive
+several features.
+
+
+## Schedules
+
+A schedule has a name and a recurrence rule.  A fresh unit has two, from
+the factory configuration.  Written as CLI commands, they look like this:
+
+
admin@example:/> configure system
+admin@example:/config/system/> edit schedule nightly
+admin@example:/config/system/…/nightly/> set description "Every night at 03:00"
+admin@example:/config/system/…/nightly/> set recurrence frequency daily
+admin@example:/config/system/…/nightly/> set recurrence byhour 3
+admin@example:/config/system/…/nightly/> end
+admin@example:/config/system/> edit schedule weekly
+admin@example:/config/system/…/weekly/> set description "Sunday nights at 03:00"
+admin@example:/config/system/…/weekly/> set recurrence frequency weekly
+admin@example:/config/system/…/weekly/> set recurrence byday sunday
+admin@example:/config/system/…/weekly/> set recurrence byhour 3
+admin@example:/config/system/…/weekly/> leave
 
-**Schedule parameters:** +The name is how features refer to the schedule. It is 1 to 64 +characters, starts with a letter or digit, and otherwise uses letters, +digits, `_`, `.` and `-`. The description is a free-form note. A +schedule without a recurrence rule is refused at commit time. -- `name`: Unique identifier, 1-64 characters, starting with a letter or - digit and otherwise limited to letters, digits, `_`, `.` and `-`. The - name is used verbatim by features referencing it -- `enabled`: Turn the schedule on or off (default: `true`). When off, - everything that uses it stops running, but the schedule is kept -- `description`: Optional human-readable note on the schedule's purpose -- `recurrence`: The recurrence rule. A schedule without one is rejected - at commit time +To stop a schedule, and everything that uses it, set `enabled false`. +The schedule stays in the configuration for when it is needed again. ## Recurrence Rules -The recurrence rule decides when a schedule fires. It is evaluated in the -system's local time. - -`frequency` is mandatory and selects the base period: +A recurrence rule is evaluated in the system's local time. The +mandatory `frequency` picks the base period: | Frequency | Fires | |------------|------------------------------------| @@ -57,95 +49,92 @@ system's local time. | `monthly` | The 1st of every month at midnight | | `yearly` | January 1st at midnight | -`interval` (default `1`) stretches the base period: `frequency hourly` -with `interval 6` fires every six hours. +`interval` stretches the period, so `frequency hourly` with `interval 6` +fires every six hours. The default is 1. -The remaining fields refine that period by pinning one field to specific -values: +The `by*` fields pin one part of the period to given values: -- `byminute`: Minutes within the hour, 0-59 -- `byhour`: Hours of the day, 0-23 -- `byday`: Days of the week, by `weekday` name (`monday` … `sunday`) -- `bymonthday`: Days of the month, 1-31 -- `byyearmonth`: Months of the year, 1-12 +- `byminute`: minutes within the hour, 0-59 +- `byhour`: hours of the day, 0-23 +- `byday`: days of the week, by name (`monday` … `sunday`) +- `bymonthday`: days of the month, 1-31 +- `byyearmonth`: months of the year, 1-12 -Each accepts a list, so `byhour 8` plus `byhour 20` fires twice a day. +Each takes a list, so `byhour 8` and `byhour 20` together fire twice a +day. This is how the factory `nightly` schedule becomes 03:00 rather +than midnight, and how `weekly` lands on Sunday. -> [!TIP] -> Set `frequency` to the coarsest period you want, then refine it with the -> `by*` fields. A weekly window on Sunday mornings is `frequency weekly` -> with `byday sunday` and `byhour 4`. Writing the same window as -> `frequency daily` would fire every morning. +Pick the coarsest frequency that fits, then narrow it. A window every +Sunday morning is `frequency weekly` with `byday sunday` and `byhour 4`. +With `frequency daily` the same `byday` and `byhour` would fire every +morning. ## Limitations -Each schedule is translated into a five-field cron expression, and the -YANG model is pruned to the subset cron can express. Everything below is -rejected at commit time, so a schedule never fires on the wrong days: +A schedule is turned into a five-field cron expression, and the model is +cut down to what cron can express. The following are refused at commit +time: -- **`secondly` frequency.** Cron has no seconds field; the finest - supported resolution is `minutely` -- **Combining `bymonthday` and `byday`.** Cron fires on the *union* of - day-of-month and day-of-week, where RFC 5545 specifies their - intersection, so the combination is refused -- **Negative values.** "The last Monday of the month" (`byday` with a - direction) and "the last day of the month" (`bymonthday -1`) have no - cron equivalent -- **Start and end bounds.** There is no start anchor, no `until` date and - no occurrence count. A schedule recurs until it is disabled -- **Per-schedule time zones**, day-of-year, week-of-year and set-position +- `secondly` frequency, since cron has no seconds field. The finest + resolution is `minutely` +- `bymonthday` together with `byday`. Cron fires on the union of the two + where RFC 5545 specifies their intersection +- negative values, such as the last Monday of the month (`byday` with a + direction) or the last day of the month (`bymonthday -1`) +- start and end bounds. There is no start date, no `until` date and no + occurrence count, so a schedule recurs until it is disabled +- per-schedule time zones, day of year, week of year and set position -`frequency yearly` with an `interval` above 1 ("every other year") is also -not expressible; the interval is ignored in that case. +`frequency yearly` with an `interval` above 1, as in every other year, +cannot be expressed either. The interval is ignored in that case. ## Using a Schedule -A schedule does nothing on its own, it takes effect when a feature -references it through a leaf of type `schedule-ref`. The reference is -validated, so a schedule cannot be deleted while something still uses it, -and a typo shows up at commit time instead of at the next occurrence. +A feature uses a schedule through a leaf of type `schedule-ref`. The +reference is validated, so a schedule in use cannot be deleted, and a +typo is caught at commit time rather than at the next occurrence. -These features consume schedules today: +These features run on a schedule: -| Feature | Configuration path | -|----------------------------|-------------------------------------| -| Reboot on a schedule | `system scheduled-reboot` | -| Update checks | `system software check-update` | -| [Unattended updates][3] | `system software unattended-update` | +| Feature | Configuration path | +|----------------------------------|-------------------------------------| +| Reboot on a schedule | `system scheduled-reboot` | +| Update checks | `system software check-update` | +| [Unattended software updates][3] | `system software unattended-update` | -The example below reboots the system on the `nightly` schedule created -above. Note that `scheduled-reboot` has no `enabled` leaf. It is active -as soon as it references a schedule; remove the reference or disable the -schedule to stop it. +A feature is active as soon as it references a schedule. This reboots +the system every night at 03:00: -
admin@example:/> configure
-admin@example:/config/> set system scheduled-reboot schedule nightly
-admin@example:/config/> leave
+
admin@example:/> configure system
+admin@example:/config/system/> set scheduled-reboot schedule nightly
+admin@example:/config/system/> leave
 
+To pause one feature and keep its settings, set its `enabled` leaf to +`false`. Disabling the schedule itself stops every feature using it. + ## Verifying -To confirm a schedule took effect, look at the generated crontab from the -shell. Active schedules become cron jobs owned by the `admin` user, and -the cron daemon runs only while at least one job is active: +Active schedules become cron jobs owned by the `admin` user, and the cron +daemon runs only while there is at least one. The generated crontab is +visible from the shell: ```sh admin@example:~$ crontab -l # Managed by infix-schedule -30 3 * * * /usr/sbin/reboot +0 3 * * * /usr/sbin/reboot ``` -An empty crontab means nothing is scheduled. Check that the consuming -feature is enabled, that it names the schedule correctly, and that the -schedule itself is enabled. +An empty crontab means nothing is scheduled. Check that the feature is +enabled, that it names the schedule correctly, and that the schedule is +enabled. For update checks and unattended updates, `show software` shows +the trigger and the outcome of the last occurrence. > [!NOTE] -> The crontab is generated and must not be edited by hand. It is -> rewritten from the configuration on every change. +> The crontab is generated from the configuration on every change. Do +> not edit it by hand. -[1]: https://github.com/kernelkit/infix/blob/main/src/confd/yang/confd/infix-schedule.yang -[2]: https://www.rfc-editor.org/rfc/rfc9922 -[3]: upgrade.md#unattended-updates +[3]: upgrade.md#unattended-software-updates diff --git a/doc/upgrade.md b/doc/upgrade.md index 6cb418e25..68baaa157 100644 --- a/doc/upgrade.md +++ b/doc/upgrade.md @@ -198,62 +198,125 @@ now the preferred boot source. To upgrade the remaining partition (`primary`), run the `upgrade URL` command again, and (optionally) reboot. -## Unattended Updates +## Unattended Software Updates -The upgrade above is operator-driven: you pick a bundle, run `upgrade`, -and reboot. This is a function the system can perform on its own, using -a [schedule][6]. +The system can check for new releases and install them on its own, on a +[schedule][6]. Two features share one update source: -Two independent features share one update source: +- `check-update` logs a notice when a newer release is available, shown + at the next login and on the WebUI dashboard +- `unattended-update` also installs it, exactly like a manual `upgrade` -- **Update checks** (`check-update`) look for a newer release and log a - notification, shown on the next login. Nothing is downloaded or - installed -- **Unattended updates** (`unattended-update`) also download and install - the new release, exactly as a manual `upgrade` would +### Enabling -### Update Source +The factory configuration names the release feed to follow and provides +two schedules: `nightly` at 03:00 and `weekly` on Sunday nights at 03:00. +Point a feature at a schedule to enable it: + +
admin@example:/> configure system
+admin@example:/config/system/> set software check-update schedule nightly
+admin@example:/config/system/> set software unattended-update schedule weekly
+admin@example:/config/system/> leave
+
-Both features read the same `update-url`, which points at an RSS/Atom feed -of releases. The setting is mandatory, and the factory configuration names -the release channel a unit ships with, so a device always has a source to -check. Point it somewhere else to follow a fork or a customer-specific -channel: +An installed release is activated on the next reboot, which by default is +left to the operator. To reboot right after a successful install: -
admin@example:/> configure
-admin@example:/config/> set system software update-url https://github.com/kernelkit/infix/releases.atom
-admin@example:/config/> set system software allow-prerelease false
-admin@example:/config/> leave
+
admin@example:/> configure system
+admin@example:/config/system/> set software unattended-update reboot immediate
+admin@example:/config/system/> leave
 
-The newest entry the feed offers decides the latest version. Each entry -must link to its release page as `/releases/tag/`, and that is -where the version tag comes from. +Set `enabled false` on a feature to pause it without losing its settings. +A configuration that predates the factory schedules, or needs another +maintenance window, can [create its own][6]. -Feeds commonly list release candidates alongside finished releases. By -default those are ignored, so only a final release is ever installed; set -`allow-prerelease` to `true` to consider them. +> [!CAUTION] +> An unattended update does exactly what a manual `upgrade` does: the new +> release goes to the inactive partition and the boot order is flipped, +> leaving the running partition as fallback should the new one fail to +> boot. Nothing else verifies the new release, see the caution under +> [Upgrading](#upgrading) about upgrading one partition at a time. -A feed carries no asset list, so the per-platform bundle is fetched by -convention from: +### Status -``` -/releases/download//-.pkg +`show software` lists the triggers and the outcome of the last run: + +
admin@example:/> show software
+Boot order : primary secondary net
+
+NAME       STATE     VERSION                DATE
+primary    booted    v26.08.1               2026-09-28T03:02:41Z
+secondary  inactive  v26.08.0               2026-08-30T03:02:12Z
+
+Software updates
+  Source       : https://github.com/kernelkit/infix/releases.atom
+  Check        : nightly (daily at 03:00)
+  Unattended   : weekly (sunday at 03:00), reboot manual
+  Last check   : 2026-09-30T03:00:12Z, latest v26.08.1, up to date
+  Last install : 2026-09-28T03:02:41Z, installed v26.08.1, reboot pending
+
+ +The same data is available under `/system-state/software/update` in the +operational datastore, and on the WebUI dashboard. The log +has the details: + +```sh +admin@example:~$ grep unattended-update /var/log/messages +unattended-update: Installing v26.08.1 from https://.../infix-aarch64-v26.08.1.pkg (running v26.05.0) +unattended-update: Installed v26.08.1; reboot to activate the new image ``` -where `` is the running system's `IMAGE_ID`, e.g. -`infix-aarch64`. RAUC streams the bundle straight from that URL. Nothing -is staged on disk first, so the update needs no free space for the image, -but the server must support HTTP range requests. +| Message | Meaning | +|----------------------------------------------|----------------------------------| +| `Installing from (running )`| Install started | +| `Installed ; reboot to activate …` | Success, `reboot manual` | +| `No update available (current: …, latest: …)`| Ran, nothing to do | +| `Skipped: failed to query latest release …` | Feed unreachable | +| `Another update is already in progress …` | Previous occurrence still running| + +> [!TIP] +> A development build has no comparable version number and always counts +> as upgradable, so an unattended update on a dev build installs the +> latest release from the feed on the first occurrence. + +### Update Source + +`update-url` names an RSS/Atom feed of releases. The factory +configuration points it at the project's releases on GitHub: + +
admin@example:/> configure system
+admin@example:/config/system/> set software update-url https://github.com/kernelkit/infix/releases.atom
+admin@example:/config/system/> leave
+
+ +Change it to follow a fork, or a feed of your own, see +[Hosting Your Own Feed](#hosting-your-own-feed). + +The newest entry in the feed decides the latest version. Release +candidates and other pre-releases are skipped unless `allow-prerelease` +is set to `true`. + +On each occurrence the feed is fetched, and a release newer than the one +that boots next is installed by streaming its bundle straight from the +server. Nothing is staged on disk, so no free space is needed, but the +server must support HTTP range requests. Only one update runs at a time, +an occurrence that fires while an install is still running is skipped. ### Hosting Your Own Feed -Any static web server will do. The feed and the bundles are plain files, -and the device fetches the feed, then the `.pkg` whose URL it derives from -the feed. +Any static web server will do. Atom and RSS 2.0 both work, and each +entry links to its release page as `/releases/tag/`. That URL +gives both the version tag and the base URL, from which the bundle for +each platform is fetched by convention: -Atom and RSS 2.0 both work. An Atom feed carries one `` per -release, each with a `` whose `href` ends in `/releases/tag/`: +``` +/releases/download//-.pkg +``` + +where `` is the running system's `IMAGE_ID`, e.g. +`infix-aarch64`. An Atom feed has one `` per release, each with a +`` whose `href` is the release page: ```xml @@ -272,8 +335,8 @@ release, each with a `` whose `href` ends in `/releases/tag/`: ``` -An RSS 2.0 feed carries the same URLs, as the text of an `` element's -`` rather than an attribute: +An RSS 2.0 feed carries the same URL as the text of each `` +element's ``: ```xml @@ -287,10 +350,9 @@ An RSS 2.0 feed carries the same URLs, as the text of an `` element's ``` -Everything before `/releases/tag/` in that URL becomes the base URL, so -the example above resolves bundles under -`https://releases.example.com/infix/releases/download//`. Lay the -files out to match, naming the feed whatever `update-url` points at: +The example resolves bundles under +`https://releases.example.com/infix/releases/download//`, so lay the +files out to match and name the feed whatever `update-url` points at: ``` infix/ @@ -304,9 +366,9 @@ infix/ Then point the device at the feed: -
admin@example:/> configure
-admin@example:/config/> set system software update-url https://releases.example.com/infix/releases.atom
-admin@example:/config/> leave
+
admin@example:/> configure system
+admin@example:/config/system/> set software update-url https://releases.example.com/infix/releases.atom
+admin@example:/config/system/> leave
 
**Requirements:** @@ -315,12 +377,10 @@ admin@example:/config/> leave of each entry's `link`. When that finds nothing, the URLs are read from the text of each RSS item's `link` instead - **Newest entry first.** Selection follows feed order, so the first - entry that passes the pre-release filter wins. A feed listing releases - oldest-first offers the oldest release + entry that passes the pre-release filter wins - **The tag is the last path segment** of the release URL, and it goes verbatim into the bundle filename. A tag containing `-rc`, `-alpha` or - `-beta` counts as a pre-release, which is skipped unless - `allow-prerelease` is `true` + `-beta` counts as a pre-release - **One bundle per platform**, named `-.pkg`. A device looks only for its own `IMAGE_ID`, so one feed can serve several platforms @@ -328,90 +388,14 @@ admin@example:/config/> leave it whole, so a server that ignores `Range` fails the install. BusyBox `httpd` and nginx both work; Python's `http.server` does not -The `/releases/tag/` URL only provides the base for the download URL -and a human-readable link in the update-check notification. The page -itself does not have to exist. +The release page itself does not have to exist, its URL only provides the +tag, the download base, and a link in the update notice. > [!TIP] > Serving the feed over HTTPS requires a correct clock on the device, or > certificate validation fails and every occurrence is skipped. Plain > HTTP avoids that on an isolated network. -### Enabling Unattended Updates - -Unattended updates are off by default and need a [schedule][6] to trigger -them. The example below installs new releases during a nightly -maintenance window, leaving the reboot to the operator. - -
admin@example:/> configure
-admin@example:/config/> set system schedule nightly recurrence frequency daily
-admin@example:/config/> set system schedule nightly recurrence byhour 3
-admin@example:/config/> set system software unattended-update enabled true
-admin@example:/config/> set system software unattended-update schedule nightly
-admin@example:/config/> set system software unattended-update reboot manual
-admin@example:/config/> leave
-
- -**Parameters:** - -- `enabled`: Enable unattended updates (default: `false`). Without a - referenced schedule no updates are performed either way -- `schedule`: The [schedule][6] whose occurrences trigger an update -- `reboot`: What to do after a successful install - - `manual` (default): Install and flip the boot-order, but do not - reboot. The new image activates the next time the operator reboots - - `immediate`: Reboot automatically to activate the new image at once - -### What Happens on Each Occurrence - -1. The feed is queried for the latest release. If it cannot be reached, - the occurrence is logged and skipped, and the job exits successfully -2. If the latest release is not newer than the running version, nothing - happens -3. Otherwise the platform bundle is installed to the *inactive* partition, - and the boot-order is flipped to activate it on the next boot. The - partition currently running is left untouched as a fallback -4. Depending on the `reboot` policy, the system either reboots or logs - that a reboot is needed - -A single-instance lock means occurrences never overlap: if an install is -still running when the next one fires, the new occurrence is skipped. - -> [!CAUTION] -> An unattended update does no additional checks beyond those of a manual -> `upgrade`: the previously running image remains on the other partition, -> and the bootloader falls back to it if the new image does not boot. -> Nothing verifies the new image beyond that, so see the caution under -> [Upgrading](#upgrading) about upgrading only one partition at a time. - -### Monitoring - -Operator-facing messages go to `/var/log/messages`, while skipped -occurrences are logged at `daemon.info`/`daemon.debug` in -`/var/log/syslog`: - -```sh -admin@example:~$ grep unattended-update /var/log/messages -unattended-update: Installing v26.08.1 from https://.../infix-aarch64-v26.08.1.pkg (running v26.05.0) -unattended-update: Installed v26.08.1; reboot to activate the new image -``` - -| Message | Meaning | -|----------------------------------------------|----------------------------------| -| `Installing from (running )`| Install started | -| `Installed ; reboot to activate …` | Success, `reboot manual` | -| `No update available (current: …, latest: …)`| Ran, nothing to do | -| `Skipped: failed to query latest release …` | Feed unreachable | -| `Another update is already in progress …` | Previous occurrence still running| - -`show software` reports installation state, slot contents and the boot -order, both during and after the install. - -> [!TIP] -> A system running a development build has no comparable version number -> and is always considered upgradable, so an unattended update on a dev -> build installs the latest release from the feed on the first occurrence. - ## Configuration Migration The example above illustrated an upgrade from Infix v25.01.0 to diff --git a/patches/rauc/1.13/0001-src-main-add-optional-syslog-support.patch b/patches/rauc/1.13/0001-src-main-add-optional-syslog-support.patch index ed85c89d3..ef9ae80a6 100644 --- a/patches/rauc/1.13/0001-src-main-add-optional-syslog-support.patch +++ b/patches/rauc/1.13/0001-src-main-add-optional-syslog-support.patch @@ -1,35 +1,50 @@ -From c4e4ad9d69b3bc62ee53f8088d6a192d288c6645 Mon Sep 17 00:00:00 2001 +From 28341bdaaa99fc9af4df0f74626a33b7da82a3d7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 23 Nov 2023 18:49:36 +0100 -Subject: [PATCH 2/2] src/main: add optional syslog support -Organization: Addiva Elektronik +Subject: [PATCH 1/2] src/main: add optional syslog support +Organization: Wires -Instead of having to redirect (colored) logs to stdout/stderr, this -patch adds support for logging directly to syslog with approximate -GLogLevel to syslog level mapping. +This patch adds an optional syslog backend alternative to the default +stdout/stderr logging. -By default all LOG_NOTICE, g_message(), and higher log messages are -logged to LOG_LOCAL0 facility. This should of course be configurable -but is not at this stage. +The syslog backend is enabled with `--syslog` on the command line. It +logs directly to the local syslog daemon, via the POSIX syslog() API, +using a default 'daemon' facility. This facility can be changed if an +optional argument is given, e.g., `--syslog=local0`. Useful for basic +message filtering to a dedicated log file. Signed-off-by: Joachim Wiberg --- - src/main.c | 45 ++++++++++++++++++++++++++++++++++++++++++++- - 1 file changed, 44 insertions(+), 1 deletion(-) + docs/reference.rst | 1 + + src/main.c | 64 ++++++++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 65 insertions(+) - -diff -urN rauc-1.13.orig/src/main.c rauc-1.13/src/main.c ---- rauc-1.13.orig/src/main.c 2025-03-04 14:55:59.671534612 +0100 -+++ rauc-1.13/src/main.c 2025-03-04 14:57:13.022772424 +0100 -@@ -10,6 +10,7 @@ +diff --git a/docs/reference.rst b/docs/reference.rst +index 0e91a599..5504661c 100644 +--- a/docs/reference.rst ++++ b/docs/reference.rst +@@ -1293,6 +1293,7 @@ Command Line Tool + --keyring=PEMFILE keyring file + --mount=PATH mount prefix + -d, --debug enable debug output ++ -s, --syslog=[facility] enable syslog output, optional facility, default: daemon + --version display version + -h, --help display help and exit + +diff --git a/src/main.c b/src/main.c +index b41e09ec..e54f626f 100644 +--- a/src/main.c ++++ b/src/main.c +@@ -10,6 +10,8 @@ #include #include #include ++#define SYSLOG_NAMES +#include #include #include -@@ -2460,6 +2461,38 @@ +@@ -2674,6 +2676,67 @@ static gboolean collect_config_values(const gchar *option_name, const gchar *val return TRUE; } @@ -52,52 +67,59 @@ diff -urN rauc-1.13.orig/src/main.c rauc-1.13/src/main.c + if (level & G_LOG_LEVEL_DEBUG) + return LOG_DEBUG; + -+ /* Fallback to INFO for unknown levels */ -+ return LOG_INFO; ++ return LOG_INFO; /* Fallback to INFO for unknown levels */ +} + +static void syslog_handler(const gchar *domain, GLogLevelFlags level, const gchar *message, gpointer arg) +{ -+ /* unused */ -+ (void)domain; -+ (void)arg; ++ int prio = log_level(level); + -+ syslog(log_level(level), "%s", message); ++ if (g_strcmp0(domain, G_LOG_DOMAIN)) ++ syslog(prio, "%s: %s", domain, message); ++ else ++ syslog(prio, "%s", message); ++} ++ ++static gboolean syslog_option_cb(const gchar *option_name, const gchar *value, ++ gpointer data, GError **error) ++{ ++ int facility = LOG_DAEMON; ++ ++ if (value) { ++ gboolean found = FALSE; ++ ++ for (const CODE *f = facilitynames; f->c_name != NULL; f++) { ++ if (g_ascii_strcasecmp(value, f->c_name) == 0) { ++ facility = f->c_val; ++ found = TRUE; ++ break; ++ } ++ } ++ ++ if (!found) { ++ g_set_error(error, G_OPTION_ERROR, G_OPTION_ERROR_BAD_VALUE, ++ "Invalid syslog facility: %s", value); ++ return FALSE; ++ } ++ } ++ ++ openlog(G_LOG_DOMAIN, LOG_PID | LOG_NOWAIT, facility); ++ g_log_set_default_handler(syslog_handler, NULL); ++ ++ return TRUE; +} + - typedef enum { - UNKNOWN = 0, - INSTALL, -@@ -2676,7 +2709,7 @@ - static void cmdline_handler(int argc, char **argv) { -- gboolean help = FALSE, debug = FALSE, version = FALSE; -+ gboolean help = FALSE, debug = FALSE, use_syslog = FALSE, version = FALSE; - g_autofree gchar *confpath = NULL, *keyring = NULL, *mount = NULL; - char *cmdarg = NULL; - g_autoptr(GOptionContext) context = NULL; -@@ -2690,6 +2723,7 @@ + gboolean help = FALSE, debug = FALSE, version = FALSE; +@@ -2690,6 +2753,7 @@ static void cmdline_handler(int argc, char **argv) {"intermediate", '\0', G_OPTION_FLAG_HIDDEN, G_OPTION_ARG_FILENAME_ARRAY, &intermediate, "intermediate CA file or PKCS#11 URL", "PEMFILE|PKCS11-URL"}, {"mount", '\0', 0, G_OPTION_ARG_FILENAME, &mount, "mount prefix", "PATH"}, {"debug", 'd', 0, G_OPTION_ARG_NONE, &debug, "enable debug output", NULL}, -+ {"syslog", 's', 0, G_OPTION_ARG_NONE, &use_syslog, "use syslog instead of stdout", NULL}, ++ {"syslog", 's', G_OPTION_FLAG_OPTIONAL_ARG, G_OPTION_ARG_CALLBACK, syslog_option_cb, "enable syslog output, optional facility, default: daemon", "[facility]"}, {"version", '\0', 0, G_OPTION_ARG_NONE, &version, "display version", NULL}, {"help", 'h', 0, G_OPTION_ARG_NONE, &help, "display help and exit", NULL}, {0} -@@ -2816,6 +2850,15 @@ - ); - } - -+ if (use_syslog) { -+ GLogLevelFlags levels = G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION; -+ const char *ident = "rauc"; -+ -+ /* XXX: facility should be configurable */ -+ openlog(ident, LOG_PID | LOG_NOWAIT, LOG_LOCAL0); -+ g_log_set_handler(ident, levels, syslog_handler, NULL); -+ } -+ - /* get first parameter without dashes */ - for (gint i = 1; i <= argc; i++) { - if (argv[i] && !g_str_has_prefix(argv[i], "-")) { +-- +2.43.0 + diff --git a/patches/rauc/1.13/0002-service-notify-readiness-once-the-bus-name-is-owned.patch b/patches/rauc/1.13/0002-service-notify-readiness-once-the-bus-name-is-owned.patch new file mode 100644 index 000000000..cde54dfd6 --- /dev/null +++ b/patches/rauc/1.13/0002-service-notify-readiness-once-the-bus-name-is-owned.patch @@ -0,0 +1,80 @@ +From 4ae5297fed47fd7b499e656a6e0af37683632975 Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Thu, 1 Oct 2026 11:20:00 +0200 +Subject: [PATCH 2/2] service: notify readiness once the bus name is owned +Organization: Wires + +A supervisor only sees the process start, not when the service can take +calls. A client asking the bus in that window makes dbus-daemon activate +a second instance, which then owns the name, and the supervised one dies. + +Send READY=1 on NOTIFY_SOCKET, the sd_notify(3) protocol, when the name +has been acquired, so a supervisor can hold back clients. + +Signed-off-by: Joachim Wiberg +--- + src/service.c | 35 +++++++++++++++++++++++++++++++++++ + 1 file changed, 35 insertions(+) + +diff --git a/src/service.c b/src/service.c +index 1f0aa551..42e1599a 100644 +--- a/src/service.c ++++ b/src/service.c +@@ -1,7 +1,13 @@ ++#include + #include + #include + #include ++#include + #include ++#include ++#include ++#include ++#include + + #include "artifacts.h" + #include "bundle.h" +@@ -602,11 +608,40 @@ static void r_on_bus_acquired(GDBusConnection *connection, + return; + } + ++static void r_notify_ready(void) ++{ ++ struct sockaddr_un sa = { .sun_family = AF_UNIX }; ++ const gchar *path = g_getenv("NOTIFY_SOCKET"); ++ gsize len; ++ int sd; ++ ++ if (!path || !*path) ++ return; ++ ++ len = strlen(path); ++ if (len >= sizeof(sa.sun_path)) ++ return; ++ ++ memcpy(sa.sun_path, path, len); ++ if (sa.sun_path[0] == '@') ++ sa.sun_path[0] = '\0'; ++ ++ sd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0); ++ if (sd < 0) ++ return; ++ ++ if (sendto(sd, "READY=1\n", 8, MSG_NOSIGNAL, (struct sockaddr *)&sa, ++ offsetof(struct sockaddr_un, sun_path) + len) < 0) ++ g_debug("failed to notify readiness: %s", g_strerror(errno)); ++ close(sd); ++} ++ + static void r_on_name_acquired(GDBusConnection *connection, + const gchar *name, + gpointer user_data) + { + g_debug("name '%s' acquired", name); ++ r_notify_ready(); + + if (r_context()->config->autoinstall_path) + auto_install(r_context()->config->autoinstall_path); +-- +2.43.0 + diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index fb418fc89..cbea5096f 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -245,6 +245,11 @@ def software(args: List[str]) -> None: print(json.dumps(data, indent=2)) return if len(args) == 0 or not args[0]: # Treat "" as no arg. + # Trigger settings for the software updates section. + for xpath in ("/ietf-system:system/infix-system:software", + "/ietf-system:system/infix-schedule:schedules"): + cfg = get_json(xpath, "running-config", quiet=True) + data.setdefault("ietf-system:system", {}).update(cfg.get("ietf-system:system", {})) cli_pretty(data, "show-software") elif len(args) == 1: name = args[0] diff --git a/src/confd/share/factory.d/10-schedule.json b/src/confd/share/factory.d/10-schedule.json new file mode 100644 index 000000000..ff41cd42c --- /dev/null +++ b/src/confd/share/factory.d/10-schedule.json @@ -0,0 +1,25 @@ +{ + "ietf-system:system": { + "infix-schedule:schedules": { + "schedule": [ + { + "name": "nightly", + "description": "Every night at 03:00", + "recurrence": { + "frequency": "ietf-schedule:daily", + "byhour": [3] + } + }, + { + "name": "weekly", + "description": "Sunday nights at 03:00", + "recurrence": { + "frequency": "ietf-schedule:weekly", + "byday": [{ "weekday": "sunday" }], + "byhour": [3] + } + } + ] + } + } +} diff --git a/src/confd/share/factory.d/Makefile.am b/src/confd/share/factory.d/Makefile.am index 3a5c9d60b..b1bfd2e4e 100644 --- a/src/confd/share/factory.d/Makefile.am +++ b/src/confd/share/factory.d/Makefile.am @@ -1,7 +1,7 @@ factorydir = $(pkgdatadir)/factory.d dist_factory_DATA = 10-keystore.json 10-nacm.json \ - 10-infix-services.json 10-software.json \ - 10-system.json + 10-infix-services.json 10-schedule.json \ + 10-software.json 10-system.json if !NETCONF_SUBSYSTEM dist_factory_DATA += 10-netconf-server.json diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 92bfafd1f..7ebf70d51 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -592,10 +592,10 @@ static confd_dependency_t dep_schedule_consumers(struct lyd_node **diff, struct continue; /* A diff that only toggles 'enabled' keeps the ref in config. */ - name = lydx_get_cattr(node, consumer->sched_leaf); + name = lydx_get_cattr(node, "schedule"); if (!name) { node = lydx_get_xpathf(config, "%s", consumer->path); - name = node ? lydx_get_cattr(node, consumer->sched_leaf) : NULL; + name = node ? lydx_get_cattr(node, "schedule") : NULL; } if (!name) continue; diff --git a/src/confd/src/core.h b/src/confd/src/core.h index ab244c0ca..707c6f72a 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -234,8 +234,6 @@ int system_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd /* schedule.c */ struct cron_consumer { const char *path; - const char *sched_leaf; - const char *enabled_leaf; const char *command; }; const struct cron_consumer *schedule_consumer(size_t i); diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index f2dbb3387..d883b2c39 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -601,10 +601,11 @@ static const char *wifi_ht40_dir(int ch) } /* - * Read HT/VHT capability bitmasks from hardware via iw.py. + * Read HT/VHT capability bitmasks and HE support from hardware via iw.py. * Returns 0 on success, -1 on failure. */ -static int wifi_read_phy_caps(const char *radio_name, unsigned int *ht_cap, unsigned int *vht_cap) +static int wifi_read_phy_caps(const char *radio_name, unsigned int *ht_cap, + unsigned int *vht_cap, bool *he) { json_error_t jerr; json_t *root, *jht, *jvht; @@ -614,6 +615,7 @@ static int wifi_read_phy_caps(const char *radio_name, unsigned int *ht_cap, unsi *ht_cap = 0; *vht_cap = 0; + *he = false; pp = popenf("r", "/usr/libexec/infix/iw.py caps %s", radio_name); if (!pp) @@ -624,7 +626,7 @@ static int wifi_read_phy_caps(const char *radio_name, unsigned int *ht_cap, unsi buf[len] = '\0'; /* - * Parse JSON output: {"ht_cap": NNN, "vht_cap": NNN} + * Parse JSON output: {"ht_cap": NNN, "vht_cap": NNN, "he": bool} * Use jansson since hardware.c already includes it. */ root = json_loads(buf, 0, &jerr); @@ -638,6 +640,7 @@ static int wifi_read_phy_caps(const char *radio_name, unsigned int *ht_cap, unsi *ht_cap = (unsigned int)json_integer_value(jht); if (json_is_integer(jvht)) *vht_cap = (unsigned int)json_integer_value(jvht); + *he = json_is_true(json_object_get(root, "he")); json_decref(root); return 0; @@ -871,7 +874,7 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, char ht_capab[512], vht_capab[512]; int chwidth = 0; /* 0=20/40, 1=80, 2=160 */ int ch = 0; - bool legacy_rates; + bool legacy_rates, he = false; country = lydx_get_cattr(radio_node, "country-code"); band = lydx_get_cattr(radio_node, "band"); @@ -881,8 +884,9 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, if (channel && strcmp(channel, "auto")) ch = atoi(channel); - /* Read HT/VHT hardware capabilities from PHY */ - wifi_read_phy_caps(radio_name, &ht_cap, &vht_cap); + /* Read HT/VHT/HE hardware capabilities from PHY, hostapd refuses + * to start with a mode the driver does not support. */ + wifi_read_phy_caps(radio_name, &ht_cap, &vht_cap, &he); if (country) fprintf(hostapd, "country_code=%s\n", country); @@ -955,11 +959,14 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, if (!strcmp(band, "2.4GHz")) { fprintf(hostapd, "ieee80211n=1\n"); - fprintf(hostapd, "ieee80211ax=1\n"); + if (he) + fprintf(hostapd, "ieee80211ax=1\n"); } else if (!strcmp(band, "5GHz")) { fprintf(hostapd, "ieee80211n=1\n"); - fprintf(hostapd, "ieee80211ac=1\n"); - fprintf(hostapd, "ieee80211ax=1\n"); + if (vht_cap) + fprintf(hostapd, "ieee80211ac=1\n"); + if (he) + fprintf(hostapd, "ieee80211ax=1\n"); } else if (!strcmp(band, "6GHz")) { /* 6GHz is HE-only, no HT/VHT */ fprintf(hostapd, "ieee80211ax=1\n"); @@ -1003,8 +1010,10 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, wifi_build_ht_capab(ht_capab, sizeof(ht_capab), ht_cap, NULL, 0); fprintf(hostapd, "ht_capab=%s\n", ht_capab); if (strcmp(band, "2.4GHz")) { - fprintf(hostapd, "vht_oper_chwidth=0\n"); - fprintf(hostapd, "he_oper_chwidth=0\n"); + if (vht_cap) + fprintf(hostapd, "vht_oper_chwidth=0\n"); + if (he) + fprintf(hostapd, "he_oper_chwidth=0\n"); } } else if (!strcmp(width, "40MHz")) { chwidth = 0; @@ -1012,8 +1021,10 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, ch ? wifi_ht40_dir(ch) : "[HT40+]", 1); fprintf(hostapd, "ht_capab=%s\n", ht_capab); if (strcmp(band, "2.4GHz")) { - fprintf(hostapd, "vht_oper_chwidth=0\n"); - fprintf(hostapd, "he_oper_chwidth=0\n"); + if (vht_cap) + fprintf(hostapd, "vht_oper_chwidth=0\n"); + if (he) + fprintf(hostapd, "he_oper_chwidth=0\n"); } } else if (!strcmp(width, "80MHz") && ch) { int center = wifi_center_chan_80(ch); @@ -1024,10 +1035,12 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, fprintf(hostapd, "ht_capab=%s\n", ht_capab); fprintf(hostapd, "vht_capab=%s\n", vht_capab); fprintf(hostapd, "vht_oper_chwidth=1\n"); - fprintf(hostapd, "he_oper_chwidth=1\n"); + if (he) + fprintf(hostapd, "he_oper_chwidth=1\n"); if (center) { fprintf(hostapd, "vht_oper_centr_freq_seg0_idx=%d\n", center); - fprintf(hostapd, "he_oper_centr_freq_seg0_idx=%d\n", center); + if (he) + fprintf(hostapd, "he_oper_centr_freq_seg0_idx=%d\n", center); } } else if (!strcmp(width, "160MHz") && ch) { int center = wifi_center_chan_160(ch); @@ -1038,16 +1051,20 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, fprintf(hostapd, "ht_capab=%s\n", ht_capab); fprintf(hostapd, "vht_capab=%s\n", vht_capab); fprintf(hostapd, "vht_oper_chwidth=2\n"); - fprintf(hostapd, "he_oper_chwidth=2\n"); + if (he) + fprintf(hostapd, "he_oper_chwidth=2\n"); if (center) { fprintf(hostapd, "vht_oper_centr_freq_seg0_idx=%d\n", center); - fprintf(hostapd, "he_oper_centr_freq_seg0_idx=%d\n", center); + if (he) + fprintf(hostapd, "he_oper_centr_freq_seg0_idx=%d\n", center); } } } - fprintf(hostapd, "he_su_beamformer=1\n"); - fprintf(hostapd, "he_su_beamformee=1\n"); + if (he) { + fprintf(hostapd, "he_su_beamformer=1\n"); + fprintf(hostapd, "he_su_beamformee=1\n"); + } } fprintf(hostapd, "\n"); } diff --git a/src/confd/src/schedule.c b/src/confd/src/schedule.c index 1ea3b3095..7bb8ec619 100644 --- a/src/confd/src/schedule.c +++ b/src/confd/src/schedule.c @@ -16,26 +16,21 @@ #define CRONTAB_NEXT CRONTAB_DIR "/admin.next" /* - * Every feature that can run on a schedule. A feature owns a container with - * a schedule-ref leaf; this is what the reference means, i.e. the command to - * run on each occurrence. Add a row here to put a new feature on a schedule. + * Every feature that can run on a schedule. A feature owns a container that + * uses the infix-schedule scheduled-feature grouping; this is what the + * schedule reference means, i.e. the command to run on each occurrence. + * Add a row here to put a new feature on a schedule. */ static const struct cron_consumer consumers[] = { { - .path = "/ietf-system:system/infix-system:software/check-update", - .sched_leaf = "schedule", - .enabled_leaf = "enabled", - .command = "/usr/sbin/check-update", + .path = "/ietf-system:system/infix-system:software/check-update", + .command = "/usr/sbin/check-update", }, { - .path = "/ietf-system:system/infix-system:software/unattended-update", - .sched_leaf = "schedule", - .enabled_leaf = "enabled", - .command = "/usr/sbin/unattended-update", + .path = "/ietf-system:system/infix-system:software/unattended-update", + .command = "/usr/sbin/unattended-update", }, { - .path = "/ietf-system:system/infix-system:scheduled-reboot", - .sched_leaf = "schedule", - .enabled_leaf = NULL, - .command = "/usr/sbin/reboot", + .path = "/ietf-system:system/infix-system:scheduled-reboot", + .command = "/usr/sbin/reboot", }, }; @@ -265,10 +260,10 @@ static int gen_schedules(struct lyd_node *config) if (!node) continue; - if (c->enabled_leaf && !lydx_is_enabled(node, c->enabled_leaf)) + if (!lydx_is_enabled(node, "enabled")) continue; - name = lydx_get_cattr(node, c->sched_leaf); + name = lydx_get_cattr(node, "schedule"); if (!name) continue; diff --git a/src/confd/yang/confd/infix-schedule.yang b/src/confd/yang/confd/infix-schedule.yang index 8c120bd34..7626d4b56 100644 --- a/src/confd/yang/confd/infix-schedule.yang +++ b/src/confd/yang/confd/infix-schedule.yang @@ -17,7 +17,9 @@ module infix-schedule { revision 2026-09-07 { description "Constrain schedule name to a bounded identifier so features can - reference it verbatim (e.g. in a resolved XPath)."; + reference it verbatim (e.g. in a resolved XPath). + Add the scheduled-feature grouping: the enabled and schedule leaves + every feature that runs on a schedule uses."; reference "internal"; } revision 2026-06-17 { @@ -35,11 +37,29 @@ module infix-schedule { path "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + "/infix-schedule:name"; } - description - "References a named schedule by name. A feature owns its trigger by - pointing a leaf of this type at a schedule; the reference is validated, - so a schedule cannot be deleted while something still uses it, and - several features may share a single recurrence."; + description "Reference to a named schedule. + + The reference is validated, so a schedule cannot be deleted while + something still uses it. Several features may share one schedule."; + } + + grouping scheduled-feature { + description "Trigger for a feature that runs on a schedule. + + The feature is active as soon as it references a schedule. Setting + 'enabled' to false pauses it, keeping the reference and the feature's + other settings."; + + leaf enabled { + type boolean; + default true; + description "Pause the feature by setting this to false."; + } + + leaf schedule { + type schedule-ref; + description "The schedule whose occurrences trigger the feature."; + } } /* @@ -125,50 +145,43 @@ module infix-schedule { } augment "/sys:system" { - description - "Scheduling configuration under ietf-system."; + description "Scheduling configuration under ietf-system."; container schedules { - description - "Container for all configured schedules."; + description "Container for all configured schedules."; list schedule { key "name"; description - "A named, reusable recurrence (time-spec). Schedules carry no - action of their own; features trigger off a schedule by pointing - a schedule-ref leaf at its name."; + "A named, reusable recurrence (time-spec). + + Schedules carry no action of their own; features trigger off + a schedule by pointing a schedule-ref leaf at its name."; leaf name { type string { length "1..64"; pattern '[a-zA-Z0-9][a-zA-Z0-9_.-]*'; } - description - "Unique name identifying this schedule. Restricted to a bounded - identifier (letters, digits, '_', '.', '-') so features can use - it verbatim, e.g. in the XPath the scheduler builds to resolve a - schedule-ref."; + description "Unique name identifying this schedule."; } leaf enabled { type boolean; - default "true"; - description - "Turn this schedule on or off. When off, anything that uses - it stops running, but the schedule itself is kept."; + default true; + description "Pause the schedule by setting this to false. + + When paused, anything that uses it stops running."; } leaf description { type string; - description - "Optional human-readable description of this schedule's purpose."; + description "Optional human-readable description."; } must "recurrence" { error-message "A recurrence rule is required for each schedule."; } container recurrence { if-feature "schedule:icalendar-recurrence"; - description - "Recurrence rule controlling when the schedule fires. + description "Recurrence rule controlling when the schedule fires. - Uses the standard iCalendar recurrence grouping; nodes with no - five-field cron equivalent are removed via deviations in this + Uses the standard iCalendar recurrence grouping, nodes with no + five-field cron equivalent are removed by deviations in this module."; uses schedule:icalendar-recurrence { refine frequency { diff --git a/src/confd/yang/confd/infix-system-software.yang b/src/confd/yang/confd/infix-system-software.yang index fb837f196..c45af4f17 100644 --- a/src/confd/yang/confd/infix-system-software.yang +++ b/src/confd/yang/confd/infix-system-software.yang @@ -30,7 +30,12 @@ submodule infix-system-software { so check-update and unattended-update use one update source, make it an RSS/Atom release feed, and make it mandatory, supplied by factory-config. - Add allow-prerelease."; + Add allow-prerelease. + Use the infix-schedule scheduled-feature grouping for + check-update and unattended-update, enabled now defaults + to true. + Add the update container to the operational data: outcome + of the last update check or unattended update."; reference "Internal"; } revision 2026-06-17 { @@ -99,111 +104,66 @@ submodule infix-system-software { } augment "/sys:system" { container software { - presence - "Software management configured. The container carries presence - because 'update-url' is mandatory, and an augment into another - module may not add a mandatory node unconditionally."; - description - "Software management configuration."; + /* An augment may not add a mandatory node (update-url) unconditionally. */ + presence "Software management configured."; + description "Software management configuration."; leaf update-url { type string; mandatory true; - description - "RSS/Atom feed listing available releases, shared by check-update - and unattended-update. The newest entry the feed offers decides - the latest version; each entry is expected to link to its release - page as '/releases/tag/', from which the tag is read. - - The feed carries no asset list, so the per-platform bundle is - fetched by convention from - '/releases/download//-.pkg', and - installed by streaming it straight from that URL. - - Set from factory-config, which names the release channel the - product ships with. Override for a fork or a customer-specific - channel."; + description "RSS/Atom feed of releases to follow. + + The newest entry decides the latest version. Each entry links to + its release page as '/releases/tag/', and the bundle + for this platform is fetched from + '/releases/download//-.pkg'. + + Set by the factory configuration, change it to follow a fork or + a feed of your own."; } leaf allow-prerelease { type boolean; default false; - description - "Consider pre-releases (release candidates, alpha and beta builds) - when determining the latest version. Feeds commonly list them - alongside finished releases; by default they are ignored, so only - a final release is ever installed."; + description "Consider pre-releases when determining the latest version. + + Release candidates, alpha and beta builds are listed alongside + finished releases in most feeds. By default they are ignored, so + only a final release is ever installed."; } container check-update { - description - "Policy for automatic software update checks. - - When 'enabled' and 'schedule' references a schedule, the system - checks the configured update-url for a newer release on each - occurrence and logs a notification if one is found."; + description "Automatic software update checks. - leaf enabled { - type boolean; - default false; - description - "Enable automatic update checks."; - } + Each occurrence of the referenced schedule checks update-url for + a newer release and logs a notification if there is one."; - leaf schedule { - type infix-schedule:schedule-ref; - description - "The schedule whose occurrences trigger an update check. - Without a referenced schedule no checks are performed."; - } + uses infix-schedule:scheduled-feature; } container unattended-update { - description - "Policy for automatic, unattended software upgrades. + description "Unattended software updates. - When 'enabled' and 'schedule' references a schedule, the system - checks the configured update-url for a newer release on each - occurrence and, if one is found, downloads and installs the - per-platform bundle to the inactive slot exactly as a manual - 'upgrade' would: the boot-order is flipped to activate the new - image on the next reboot, and the previously running slot is - left intact as a fallback. + Each occurrence of the referenced schedule checks update-url for + a newer release and installs it to the inactive slot exactly as + a manual 'upgrade' would: the boot-order is flipped to activate + it on the next reboot, and the running slot is kept as fallback. - The 'reboot' leaf governs whether that reboot happens - automatically or is left to the operator."; + The 'reboot' leaf decides whether that reboot is automatic."; - leaf enabled { - type boolean; - default false; - description - "Enable automatic unattended upgrades."; - } - - leaf schedule { - type infix-schedule:schedule-ref; - description - "The schedule whose occurrences trigger an unattended upgrade. - Without a referenced schedule no upgrades are performed."; - } + uses infix-schedule:scheduled-feature; leaf reboot { type enumeration { enum manual { - description - "Install and flip the boot-order, but do not reboot. The - new image activates the next time the operator reboots."; + description "Leave the reboot to the operator."; } enum immediate { - description - "Reboot automatically after a successful install to activate - the new image at once."; + description "Reboot right after a successful install."; } } default manual; - description - "What to do once a bundle has been installed to the inactive - slot."; + description "What to do once a new release has been installed."; } } } @@ -253,6 +213,51 @@ submodule infix-system-software { // } } + container update { + description "Outcome of the last update check or unattended update."; + + leaf last-check { + type yang:date-and-time; + description "When the release feed was last queried."; + } + + leaf latest { + type string; + description "Newest release the feed offered at the last check. + + Pre-releases are left out unless allow-prerelease is set. Absent + when the feed could not be read."; + } + + leaf available { + type boolean; + description "True when latest is newer than the version that boots next."; + } + + leaf release-url { + type string; + description "Release page of the latest release."; + } + + leaf last-install { + type yang:date-and-time; + description "When an unattended update last installed a bundle."; + } + + leaf installed { + type string; + description "Version installed by the last unattended update."; + } + + leaf reboot-pending { + type boolean; + description "True when an installed update awaits a reboot. + + The slot that boots next holds another version than the running + one."; + } + } + list slot { key "name"; description diff --git a/src/confd/yang/confd/infix-system.yang b/src/confd/yang/confd/infix-system.yang index 5d902ac9c..0d13d2f4a 100644 --- a/src/confd/yang/confd/infix-system.yang +++ b/src/confd/yang/confd/infix-system.yang @@ -40,8 +40,9 @@ module infix-system { revision 2026-09-22 { description "Add unattended-update, a shared software/update-url naming an - RSS/Atom release feed, and allow-prerelease (see the - infix-system-software submodule)."; + RSS/Atom release feed, and allow-prerelease (see submodule). + Use the infix-schedule scheduled-feature grouping for + scheduled-reboot, adding enabled."; reference "internal"; } @@ -367,16 +368,11 @@ module infix-system { description "Scheduled reboot of the system."; container scheduled-reboot { - description - "Reboot the system on the occurrences of a referenced schedule. - Without a referenced schedule the system is never rebooted on a - schedule."; + description "Reboot the system on the occurrences of a referenced schedule. - leaf schedule { - type infix-schedule:schedule-ref; - description - "The schedule whose occurrences trigger a system reboot."; - } + Without a referenced schedule the system is never rebooted."; + + uses infix-schedule:scheduled-feature; } } diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 3ac5c9165..bf8f787f3 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2202,12 +2202,13 @@ def show_software(json, name): print(f"Boot order : {order}") print("") + rootfs = [s for s in map(Software, reversed(slots)) if s.is_rootfs()] + PadSoftware.version = max([PadSoftware.version] + [len(s.version) + 2 for s in rootfs]) hdr = (f"{'NAME':<{PadSoftware.name}}" f"{'STATE':<{PadSoftware.state}}" f"{'VERSION':<{PadSoftware.version}}" f"{'DATE':<{PadSoftware.date}}") print(Decore.invert(hdr)) - rootfs = [s for s in map(Software, reversed(slots)) if s.is_rootfs()] for slot in rootfs: slot.print() @@ -2215,8 +2216,75 @@ def show_software(json, name): others = [s for s in rootfs if s.version != booted.version] if booted else [] for slot in others: print() - print(Decore.yellow(f"Note: the {slot.name} partition has {slot.version or 'unknown'}," - f" this is {booted.version}. Use 'upgrade' to update it.")) + print(Decore.yellow(f"Note: the {slot.name} partition is out of date ({slot.version or 'unknown'})")) + print(Decore.yellow(f" Use 'upgrade' to update it to {booted.version}.")) + + show_software_update(json, software.get("update", {})) + + +def describe_recurrence(rec): + """Short form of an ietf-schedule recurrence, e.g. 'sunday at 03:30'""" + units = {"minutely": "minute", "hourly": "hour", "daily": "day", + "weekly": "week", "monthly": "month", "yearly": "year"} + freq = rec.get('frequency', '').split(':')[-1] + interval = rec.get('interval', 1) + text = freq if interval == 1 else f"every {interval} {units.get(freq, freq)}s" + + days = ",".join(d.get('weekday', '') for d in rec.get('byday', [])) + if days: + text = days if interval == 1 else f"{text} on {days}" + + hours = rec.get('byhour') + if hours: + minutes = rec.get('byminute', [0]) + text += " at " + ",".join(f"{h:02}:{m:02}" for h in hours for m in minutes) + + return text + + +def show_software_update(json, state): + """Scheduled update checks and unattended updates: triggers from the + running configuration, outcome of the last run from operational data""" + cfg = get_json_data({}, json, 'ietf-system:system', 'infix-system:software') + schedules = get_json_data([], json, 'ietf-system:system', + 'infix-schedule:schedules', 'schedule') + + def trigger(feature): + name = feature.get('schedule') + if not name: + return "not configured" + if not feature.get('enabled', True): + return f"{name}, paused" + sched = next((s for s in schedules if s.get('name') == name), {}) + text = f"{name} ({describe_recurrence(sched.get('recurrence', {}))})" + if not sched.get('enabled', True): + text += ", schedule disabled" + return text + + unattended = cfg.get('unattended-update', {}) + print() + print("Software updates") + print(f" Source : {cfg.get('update-url', 'not configured')}") + print(f" Check : {trigger(cfg.get('check-update', {}))}") + text = trigger(unattended) + if unattended.get('schedule'): + text += f", reboot {unattended.get('reboot', 'manual')}" + print(f" Unattended : {text}") + + if state.get('last-check'): + latest = state.get('latest') + if not latest: + result = "feed unreachable" + elif state.get('available'): + result = f"{latest} available" + else: + result = f"latest {latest}, up to date" + print(f" Last check : {state['last-check']}, {result}") + if state.get('last-install'): + result = f"installed {state.get('installed', 'unknown')}" + if state.get('reboot-pending'): + result += ", reboot pending" + print(f" Last install : {state['last-install']}, {result}") def show_services(json): diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index f82af8222..e94a21e15 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -226,8 +226,30 @@ def add_services(out): insert(out, "infix-system:services", "service", services) +def software_update(data): + """Outcome of the last update check or unattended update, recorded by + the update scripts, plus whether an installed image awaits a reboot.""" + keys = ("last-check", "latest", "available", "release-url", + "last-install", "installed") + state = HOST.read_json("/run/software-update.json", {}) + update = {k: state[k] for k in keys if k in state} + + if data: + slots = {n: s for entry in data.get("slots", []) for n, s in entry.items()} + + def version(slot): + return slot.get("slot_status", {}).get("bundle", {}).get("version") + + running = next((version(s) for s in slots.values() + if s.get("bootname") == data.get("booted")), None) + pending = version(slots.get(data.get("boot_primary"), {})) + update["reboot-pending"] = bool(running and pending and running != pending) + + return update + def add_software(out): software = {} + data = None try: data = HOST.run_json(["rauc", "status", "--detailed", "--output-format=json"], {}) software["compatible"] = data.get("compatible", "") @@ -254,6 +276,10 @@ def add_software(out): installer["progress"] = progress software["installer"] = installer + update = software_update(data) + if update: + software["update"] = update + insert(out, "infix-system:software", software) def add_hostname(out): diff --git a/src/webui/internal/handlers/configure_advanced_test.go b/src/webui/internal/handlers/configure_advanced_test.go index c0ab3afbe..40e9c7eca 100644 --- a/src/webui/internal/handlers/configure_advanced_test.go +++ b/src/webui/internal/handlers/configure_advanced_test.go @@ -69,7 +69,7 @@ func newAdvHandler(t *testing.T, rc restconf.Fetcher) *ConfigureAdvancedHandler return &ConfigureAdvancedHandler{ Template: minimalCfgAdvTmpl, RC: rc, - Schema: schema.NewCache(rc, t.TempDir()), + Schema: schema.NewCache(rc, t.TempDir(), ""), } } diff --git a/src/webui/internal/handlers/configure_firewall_test.go b/src/webui/internal/handlers/configure_firewall_test.go index 4d4b44dbe..916d284f6 100644 --- a/src/webui/internal/handlers/configure_firewall_test.go +++ b/src/webui/internal/handlers/configure_firewall_test.go @@ -49,7 +49,7 @@ func TestConfigureFirewallOverview_AddressSets(t *testing.T) { h := &ConfigureFirewallHandler{ Template: minimalCfgFwTmpl, RC: mock, - Schema: schema.NewCache(mock, t.TempDir()), + Schema: schema.NewCache(mock, t.TempDir(), ""), } req := httptest.NewRequest(http.MethodGet, "/configure/firewall", nil) @@ -122,7 +122,7 @@ func TestConfigureFirewallSaveZoneAllowsInterfacesWithAddressSets(t *testing.T) h := &ConfigureFirewallHandler{ Template: minimalCfgFwTmpl, RC: mock, - Schema: schema.NewCache(mock, t.TempDir()), + Schema: schema.NewCache(mock, t.TempDir(), ""), } form := url.Values{ @@ -192,7 +192,7 @@ func TestConfigureFirewallSaveZoneClearsAllServices(t *testing.T) { h := &ConfigureFirewallHandler{ Template: minimalCfgFwTmpl, RC: mock, - Schema: schema.NewCache(mock, t.TempDir()), + Schema: schema.NewCache(mock, t.TempDir(), ""), } form := url.Values{ @@ -242,7 +242,7 @@ func TestConfigureFirewallResetZoneServicesOnlyDeletesServices(t *testing.T) { h := &ConfigureFirewallHandler{ Template: minimalCfgFwTmpl, RC: mock, - Schema: schema.NewCache(mock, t.TempDir()), + Schema: schema.NewCache(mock, t.TempDir(), ""), } req := httptest.NewRequest(http.MethodDelete, "/configure/firewall/zones/public/services", nil) diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index e2a0e8a36..7a8d558b2 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -11,8 +11,10 @@ import ( "math" "net" "net/http" + "net/url" "os" "os/exec" + "path" "regexp" "strconv" "strings" @@ -141,6 +143,7 @@ type clock struct { type software struct { Booted string `json:"booted"` Slot []softwareSlot `json:"slot"` + Update swUpdateState `json:"update"` } type softwareSlot struct { @@ -148,6 +151,108 @@ type softwareSlot struct { Version string `json:"version"` } +// RESTCONF JSON structures for the parts of ietf-system:system the +// dashboard shows. + +type systemConfigWrapper struct { + System systemConfig `json:"ietf-system:system"` +} + +type systemConfig struct { + Hostname string `json:"hostname"` + Contact string `json:"contact"` + Location string `json:"location"` + Software swConfig `json:"infix-system:software"` + Schedules struct { + Schedule []scheduleEntry `json:"schedule"` + } `json:"infix-schedule:schedules"` +} + +type swConfig struct { + UpdateURL string `json:"update-url"` + CheckUpdate swScheduled `json:"check-update"` + Unattended swUnattended `json:"unattended-update"` +} + +// swScheduled is the infix-schedule scheduled-feature grouping. Enabled +// defaults to true and is left out of the response unless set. +type swScheduled struct { + Enabled *bool `json:"enabled"` + Schedule string `json:"schedule"` +} + +type swUnattended struct { + swScheduled + Reboot string `json:"reboot"` +} + +// RESTCONF JSON structures for the infix-schedule:schedules configuration. + +type scheduleEntry struct { + Name string `json:"name"` + Enabled *bool `json:"enabled"` + Recurrence struct { + Frequency string `json:"frequency"` + Interval int `json:"interval"` + ByHour []int `json:"byhour"` + ByMinute []int `json:"byminute"` + ByDay []struct { + Weekday string `json:"weekday"` + } `json:"byday"` + } `json:"recurrence"` +} + +// describeRecurrence is the short form of an ietf-schedule recurrence, +// e.g. "daily at 03:00" or "every 2 weeks on monday at 04:15". +func describeRecurrence(s scheduleEntry) string { + units := map[string]string{"minutely": "minute", "hourly": "hour", "daily": "day", + "weekly": "week", "monthly": "month", "yearly": "year"} + rec := s.Recurrence + freq := rec.Frequency + if i := strings.LastIndex(freq, ":"); i >= 0 { + freq = freq[i+1:] + } + interval := rec.Interval + if interval == 0 { + interval = 1 + } + text := freq + if interval > 1 { + unit := units[freq] + if unit == "" { + unit = freq + } + text = fmt.Sprintf("every %d %ss", interval, unit) + } + + var days []string + for _, d := range rec.ByDay { + days = append(days, d.Weekday) + } + if len(days) > 0 { + if interval == 1 { + text = strings.Join(days, ",") + } else { + text += " on " + strings.Join(days, ",") + } + } + + if len(rec.ByHour) > 0 { + minutes := rec.ByMinute + if len(minutes) == 0 { + minutes = []int{0} + } + var times []string + for _, h := range rec.ByHour { + for _, m := range minutes { + times = append(times, fmt.Sprintf("%02d:%02d", h, m)) + } + } + text += " at " + strings.Join(times, ",") + } + return text +} + type resourceUsage struct { Memory memoryInfo `json:"memory"` LoadAverage loadAverage `json:"load-average"` @@ -261,7 +366,9 @@ type dashboardData struct { UpdateAvailable bool UpdateMessage string // verbatim CLI/login-banner notice UpdateURL string // release URL extracted from the notice - Error string + // Software Updates card. + Update *updateEntry + Error string } // gatewayEntry is a default route's next-hop. @@ -357,15 +464,9 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { // The RESTCONF client's own 10 s timeout still bounds each call. ctx := context.WithoutCancel(r.Context()) var ( - state systemStateWrapper - hw hardwareWrapper - sysConf struct { - System struct { - Hostname string `json:"hostname"` - Contact string `json:"contact"` - Location string `json:"location"` - } `json:"ietf-system:system"` - } + state systemStateWrapper + hw hardwareWrapper + sysConf systemConfigWrapper ifaces interfacesWrapper routes ribWrapper stateErr, hwErr, confErr error @@ -383,7 +484,9 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { }() go func() { defer wg.Done() - confErr = h.RC.Get(ctx, "/data/ietf-system:system", &sysConf) + // Running, not /data: statd serves operational data for the system + // container and that answer hides running config it does not emit. + confErr = h.RC.Get(ctx, "/ds/ietf-datastores:running/ietf-system:system", &sysConf) }() // Connectivity/Addresses cards are best-effort: a failure here logs but // doesn't fault the whole dashboard, so the card simply renders empty. @@ -517,6 +620,9 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { data.Hostname = sysConf.System.Hostname data.Contact = sysConf.System.Contact data.Location = sysConf.System.Location + u := newUpdateEntry(sysConf.System.Software, sysConf.System.Schedules.Schedule, + state.SystemState.Software.Update) + data.Update = &u } // Connectivity & Addresses cards (best-effort, independent of the above). @@ -655,6 +761,81 @@ func validZone(s string) bool { } // softwareVersion returns the version string for the booted software slot. +// updateEntry is the Software Updates card: triggers from the +// configuration, outcome of the last run from the operational data. +// Rendered even when nothing is configured, so the feature is visible. +type updateEntry struct { + swUpdateState + Source string + SourceShort string // file name of Source, for the card + Check string // schedule name, "paused" or "not configured" + Unattended string +} + +// shortURL is the last path element of a URL, or its host when there is +// none, so a feed URL fits on one line of the card. The card links the +// full URL and shows it on hover. +func shortURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + if base := path.Base(u.Path); base != "." && base != "/" { + return base + } + return u.Host +} + +// scheduledText describes a scheduled feature's trigger, with the +// recurrence of the schedule it references when that is known. +func scheduledText(s swScheduled, schedules []scheduleEntry) string { + if s.Schedule == "" { + return "not configured" + } + if s.Enabled != nil && !*s.Enabled { + return s.Schedule + " (paused)" + } + for _, sched := range schedules { + if sched.Name != s.Schedule { + continue + } + text := s.Schedule + " (" + describeRecurrence(sched) + ")" + if sched.Enabled != nil && !*sched.Enabled { + text += ", schedule disabled" + } + return text + } + return s.Schedule +} + +// swTime trims a yang:date-and-time to "YYYY-MM-DD HH:MM:SS". +func swTime(t string) string { + if len(t) > 19 { + t = t[:19] + } + return strings.Replace(t, "T", " ", 1) +} + +func newUpdateEntry(cfg swConfig, schedules []scheduleEntry, state swUpdateState) updateEntry { + unattended := scheduledText(cfg.Unattended.swScheduled, schedules) + if cfg.Unattended.Schedule != "" { + reboot := cfg.Unattended.Reboot + if reboot == "" { + reboot = "manual" + } + unattended += ", reboot " + reboot + } + state.LastCheck = swTime(state.LastCheck) + state.LastInstall = swTime(state.LastInstall) + return updateEntry{ + swUpdateState: state, + Source: cfg.UpdateURL, + SourceShort: shortURL(cfg.UpdateURL), + Check: scheduledText(cfg.CheckUpdate, schedules), + Unattended: unattended, + } +} + func softwareVersion(sw software) string { for _, slot := range sw.Slot { if slot.Name == sw.Booted { diff --git a/src/webui/internal/handlers/dashboard_test.go b/src/webui/internal/handlers/dashboard_test.go index 152acb315..0bd248a44 100644 --- a/src/webui/internal/handlers/dashboard_test.go +++ b/src/webui/internal/handlers/dashboard_test.go @@ -3,6 +3,7 @@ package handlers import ( + "encoding/json" "html/template" "net/http" "net/http/httptest" @@ -82,3 +83,109 @@ func TestDashboardIndex_HTMXPartial(t *testing.T) { t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) } } + +func TestUpdateEntry(t *testing.T) { + off := false + cfg := swConfig{ + UpdateURL: "https://github.com/kernelkit/infix/releases.atom", + CheckUpdate: swScheduled{Schedule: "nightly"}, + Unattended: swUnattended{swScheduled: swScheduled{Schedule: "nightly", Enabled: &off}}, + } + state := swUpdateState{ + LastCheck: "2026-09-30T03:00:12Z", Latest: "v26.09.0", Available: true, + LastInstall: "2026-09-28T03:02:41Z", Installed: "v26.08.1", RebootPending: true, + } + + var nightly scheduleEntry + nightly.Name = "nightly" + nightly.Recurrence.Frequency = "ietf-schedule:daily" + nightly.Recurrence.ByHour = []int{3} + schedules := []scheduleEntry{nightly} + + e := newUpdateEntry(cfg, schedules, state) + if e.Check != "nightly (daily at 03:00)" { + t.Errorf("Check = %q", e.Check) + } + if e.Unattended != "nightly (paused), reboot manual" { + t.Errorf("Unattended = %q", e.Unattended) + } + if e.LastCheck != "2026-09-30 03:00:12" { + t.Errorf("LastCheck = %q", e.LastCheck) + } + if !e.Available || !e.RebootPending { + t.Errorf("flags lost: %+v", e) + } + + e = newUpdateEntry(swConfig{}, nil, swUpdateState{}) + if e.Source != "" || e.Check != "not configured" || e.Unattended != "not configured" { + t.Errorf("unconfigured entry = %+v", e) + } +} + +func TestDescribeRecurrence(t *testing.T) { + var s scheduleEntry + s.Recurrence.Frequency = "ietf-schedule:weekly" + s.Recurrence.ByDay = append(s.Recurrence.ByDay, struct { + Weekday string `json:"weekday"` + }{"sunday"}) + s.Recurrence.ByHour = []int{3} + if got := describeRecurrence(s); got != "sunday at 03:00" { + t.Errorf("weekly = %q", got) + } + + s.Recurrence.Interval = 2 + s.Recurrence.ByMinute = []int{15} + if got := describeRecurrence(s); got != "every 2 weeks on sunday at 03:15" { + t.Errorf("biweekly = %q", got) + } + + var h scheduleEntry + h.Recurrence.Frequency = "ietf-schedule:hourly" + if got := describeRecurrence(h); got != "hourly" { + t.Errorf("hourly = %q", got) + } +} + +func TestSystemConfigDecode(t *testing.T) { + body := `{"ietf-system:system": { + "hostname": "example", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom", + "check-update": {"schedule": "nightly"}, + "unattended-update": {"schedule": "weekly", "reboot": "immediate"} + }, + "infix-schedule:schedules": {"schedule": [ + {"name": "nightly", "recurrence": {"frequency": "ietf-schedule:daily", "byhour": [3]}}, + {"name": "weekly", "enabled": true, "recurrence": {"frequency": "ietf-schedule:weekly", + "byday": [{"weekday": "sunday"}]}} + ]} + }}` + var cfg systemConfigWrapper + if err := json.Unmarshal([]byte(body), &cfg); err != nil { + t.Fatal(err) + } + e := newUpdateEntry(cfg.System.Software, cfg.System.Schedules.Schedule, swUpdateState{}) + if e.Source != "https://github.com/kernelkit/infix/releases.atom" { + t.Errorf("Source = %q", e.Source) + } + if e.Check != "nightly (daily at 03:00)" { + t.Errorf("Check = %q", e.Check) + } + if e.Unattended != "weekly (sunday), reboot immediate" { + t.Errorf("Unattended = %q", e.Unattended) + } +} + +func TestShortURL(t *testing.T) { + cases := map[string]string{ + "https://github.com/kernelkit/infix/releases.atom": "releases.atom", + "http://releases.example.com/releases.atom": "releases.atom", + "http://10.0.0.1/": "10.0.0.1", + "not a url": "not a url", + } + for in, want := range cases { + if got := shortURL(in); got != want { + t.Errorf("shortURL(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 6c1c5a528..36236a6f8 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -464,6 +464,19 @@ type swState struct { BootOrder []string `json:"boot-order"` Installer swInstallerState `json:"installer"` Slots []swSlot `json:"slot"` + Update swUpdateState `json:"update"` +} + +// swUpdateState is the outcome of the last scheduled update check or +// unattended update, /system-state/software/update. +type swUpdateState struct { + LastCheck string `json:"last-check"` + Latest string `json:"latest"` + Available bool `json:"available"` + ReleaseURL string `json:"release-url"` + LastInstall string `json:"last-install"` + Installed string `json:"installed"` + RebootPending bool `json:"reboot-pending"` } type swInstallerState struct { @@ -606,15 +619,11 @@ func (h *SystemHandler) Software(w http.ResponseWriter, r *http.Request) { if name == "" { name = s.Name } - date := s.Installed.Datetime - if len(date) > 19 { - date = date[:19] - } data.Slots = append(data.Slots, slotEntry{ Name: name, State: s.State, Version: s.Bundle.Version, - InstallDate: date, + InstallDate: swTime(s.Installed.Datetime), Booted: s.BootName == sw.SystemState.Software.Booted, }) } diff --git a/src/webui/internal/schema/refresh.go b/src/webui/internal/schema/refresh.go index 86dfdb87d..31279d460 100644 --- a/src/webui/internal/schema/refresh.go +++ b/src/webui/internal/schema/refresh.go @@ -5,6 +5,8 @@ import ( "fmt" "log" "os" + "path/filepath" + "strings" "sync" "infix/webui/internal/restconf" @@ -13,17 +15,40 @@ import ( // Cache holds a lazily-loaded schema Manager and refreshes it at startup. // All methods are safe for concurrent use. type Cache struct { - mu sync.RWMutex - manager *Manager - syncing bool // guarded by mu - dir string - rc restconf.Fetcher + mu sync.RWMutex + manager *Manager + syncing bool // guarded by mu + dir string + version string // image version the cached files belong to + rc restconf.Fetcher } -// NewCache creates a Cache. +// NewCache creates a Cache for the YANG files of the given image version, +// empty when unknown. // Call LoadFromCacheBackground at startup, then RefreshBackground after login. -func NewCache(rc restconf.Fetcher, dir string) *Cache { - return &Cache{rc: rc, dir: dir} +func NewCache(rc restconf.Fetcher, dir, version string) *Cache { + return &Cache{rc: rc, dir: dir, version: version} +} + +// dropStale empties the cache when another image version wrote it. The +// YANG files ship with the image, and a module's revision does not always +// change when its text does, so the version is the only reliable key. +func (c *Cache) dropStale() error { + if c.version == "" { + return nil + } + stamp := filepath.Join(c.dir, ".version") + if b, err := os.ReadFile(stamp); err == nil && strings.TrimSpace(string(b)) == c.version { + return nil + } + if err := os.RemoveAll(c.dir); err != nil { + return err + } + if err := os.MkdirAll(c.dir, 0750); err != nil { + return err + } + log.Printf("schema: cache in %s is for another image, dropped", c.dir) + return os.WriteFile(stamp, []byte(c.version+"\n"), 0640) } // LoadFromCache parses whatever .yang files are already in the cache @@ -31,6 +56,9 @@ func NewCache(rc restconf.Fetcher, dir string) *Cache { // This is fast — suitable for server startup. If the directory is empty // or has too few files to form a useful schema, the Manager is left nil. func (c *Cache) LoadFromCache() error { + if err := c.dropStale(); err != nil { + return fmt.Errorf("schema: cache version check: %w", err) + } entries, err := os.ReadDir(c.dir) if err != nil { if os.IsNotExist(err) { diff --git a/src/webui/internal/schema/refresh_test.go b/src/webui/internal/schema/refresh_test.go new file mode 100644 index 000000000..3723bbc7e --- /dev/null +++ b/src/webui/internal/schema/refresh_test.go @@ -0,0 +1,45 @@ +package schema + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadFromCacheDropsOtherImage(t *testing.T) { + dir := t.TempDir() + module := filepath.Join(dir, "example@2026-01-01.yang") + if err := os.WriteFile(module, []byte("module example { namespace x; prefix x; }"), 0640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".version"), []byte("v1\n"), 0640); err != nil { + t.Fatal(err) + } + + // Same image: the file stays. + if err := NewCache(nil, dir, "v1").LoadFromCache(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(module); err != nil { + t.Fatalf("file dropped for the same image: %v", err) + } + + // New image: the cache is emptied and re-stamped. + if err := NewCache(nil, dir, "v2").LoadFromCache(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(module); !os.IsNotExist(err) { + t.Fatalf("file kept across images: %v", err) + } + if b, _ := os.ReadFile(filepath.Join(dir, ".version")); string(b) != "v2\n" { + t.Errorf("stamp = %q", b) + } + + // Unknown version: nothing is touched. + if err := NewCache(nil, dir, "").LoadFromCache(); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(filepath.Join(dir, ".version")); string(b) != "v2\n" { + t.Errorf("stamp changed with unknown version: %q", b) + } +} diff --git a/src/webui/main.go b/src/webui/main.go index 6f2529437..e4b052e2b 100644 --- a/src/webui/main.go +++ b/src/webui/main.go @@ -40,7 +40,7 @@ func main() { rc := restconf.NewClient(*restconfURL, *insecureTLS) - schemaCache := schema.NewCache(rc, *yangCacheDir) + schemaCache := schema.NewCache(rc, *yangCacheDir, osVersionID()) schemaCache.LoadFromCacheBackground() // fast, no HTTP — uses whatever is already on disk tmplFS, err := fs.Sub(templateFS, "templates") @@ -84,3 +84,17 @@ func envBool(key string) bool { return false } } + +// osVersionID is VERSION_ID from /etc/os-release, empty when unavailable. +func osVersionID() string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + if v, ok := strings.CutPrefix(line, "VERSION_ID="); ok { + return strings.Trim(v, "\"") + } + } + return "" +} diff --git a/src/webui/main_test.go b/src/webui/main_test.go new file mode 100644 index 000000000..8842af68f --- /dev/null +++ b/src/webui/main_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "html/template" + "io/fs" + "strings" + "testing" + + "infix/webui/internal/handlers" + "infix/webui/internal/schema" +) + +// Every page must parse together with the layouts and fragments, as +// server.New loads them, so an unbalanced action fails here rather than +// at daemon start. +func TestTemplatesParse(t *testing.T) { + templates, err := fs.Sub(templateFS, "templates") + if err != nil { + t.Fatal(err) + } + pages, err := fs.Glob(templates, "pages/*.html") + if err != nil || len(pages) == 0 { + t.Fatalf("no pages found: %v", err) + } + funcs := handlers.IfaceTemplateFuncs() + funcs["stripPrefix"] = schema.StripModulePrefix + for _, page := range pages { + patterns := []string{"layouts/*.html", "fragments/*.html", page} + if strings.HasSuffix(page, "/login.html") { + patterns = []string{"layouts/icons.html", page} + } + if _, err := template.New("").Funcs(funcs).ParseFS(templates, patterns...); err != nil { + t.Errorf("%s: %v", page, err) + } + } +} diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index c1230b72c..b946bb495 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -938,9 +938,9 @@ if (activeTopGroup) { document.querySelectorAll('details.nav-group-top').forEach(function(d) { if (d === activeTopGroup) { - // Don't auto-open Configure: its toggle handler fires enterConfigure(), - // which must only happen on explicit user interaction, not on URL sync. - if (d.id !== 'nav-configure' && !d.open) d.open = true; + // Opening Configure fires enterConfigure() from its toggle handler, + // once per page lifecycle, which a link into a configure page needs. + if (!d.open) d.open = true; } else { if (d.open) d.open = false; } diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html index dafd87d88..8e8361204 100644 --- a/src/webui/templates/pages/dashboard.html +++ b/src/webui/templates/pages/dashboard.html @@ -37,6 +37,35 @@ + {{if .Update}} +
+
Software Updates + Configure → +
+
+ + + + + {{if .Update.LastCheck}} + + {{end}} + {{if .Update.LastInstall}} + + {{end}} +
Source{{if .Update.Source}}{{.Update.SourceShort}}{{else}}not configured{{end}}
Check{{.Update.Check}}
Unattended{{.Update.Unattended}}
Last check{{.Update.LastCheck}} + {{if not .Update.Latest}}feed unreachable + {{else if .Update.Available}}{{.Update.Latest}} available + {{else}}latest {{.Update.Latest}}, up to date{{end}}
Last install{{.Update.LastInstall}}, {{.Update.Installed}} + {{if .Update.RebootPending}}reboot pending{{end}}
+
+
+ {{end}} +
Runtime
diff --git a/src/webui/templates/pages/software.html b/src/webui/templates/pages/software.html index ccc7fb4cf..09cd6ed55 100644 --- a/src/webui/templates/pages/software.html +++ b/src/webui/templates/pages/software.html @@ -12,7 +12,7 @@ {{end}} {{range .OtherSlots}} -
The {{.Name}} partition has {{if .Version}}{{.Version}}{{else}}an unknown version{{end}}, this is {{$.BootedVersion}}. Install the same software again to update it.
+
The {{.Name}} partition is out of date ({{if .Version}}{{.Version}}{{else}}unknown{{end}}). Install {{$.BootedVersion}} again to update it.
{{end}} {{$sseURL := "/software/progress"}}{{if .AutoReboot}}{{$sseURL = "/software/progress?auto-reboot=1"}}{{end}} diff --git a/test/case/statd/system/cli/show-software b/test/case/statd/system/cli/show-software index e97750843..2b1763821 100644 --- a/test/case/statd/system/cli/show-software +++ b/test/case/statd/system/cli/show-software @@ -3,3 +3,8 @@ Boot order : primary secondary net NAME STATE VERSION DATE  primary booted 94cd526 2025-01-15T10:00:56Z secondary inactive 94cd526 2025-01-15T10:00:56Z + +Software updates + Source : not configured + Check : not configured + Unattended : not configured diff --git a/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index e8575f7f5..e9f63c938 100644 --- a/test/case/statd/system/ietf-system.json +++ b/test/case/statd/system/ietf-system.json @@ -80,6 +80,9 @@ "progress": { "percentage": 0 } + }, + "update": { + "reboot-pending": false } }, "infix-system:ntp": { diff --git a/test/case/statd/system/operational.json b/test/case/statd/system/operational.json index f3067f7f2..0c918cff7 100644 --- a/test/case/statd/system/operational.json +++ b/test/case/statd/system/operational.json @@ -458,6 +458,9 @@ "state": "inactive" } ], + "update": { + "reboot-pending": false + }, "variant": "" }, "platform": {