From 7d42cf05711fc5c6c24331c2fb7fd7f75bfe8d21 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 11:11:13 +0200 Subject: [PATCH 01/13] rauc: wait for the service to own its bus name before slot-check The rauc service crashed in a loop at boot on some units, with "Failed to obtain name de.pengutronix.rauc on system bus". Finit raises service/rauc/running when it forks the service, before RAUC has asked for its bus name. The slot-check task fires on that condition and runs 'rauc status', dbus-daemon finds no owner and starts a second instance through the activation helper, and whichever instance asks last loses the name. When that is the supervised one, every restart meets the same stray and finit gives up. Patch RAUC to send READY=1 on NOTIFY_SOCKET once the name is acquired, declare the service notify:systemd, and let slot-check wait for service/rauc/ready. Drop the D-Bus activation file and its helper from the image as well: a client calling while the supervised service is restarting must get "no owner", not a stray instance. The RAUC patches are regenerated from the kkit-1.13 branch. Signed-off-by: Joachim Wiberg --- board/common/post-build.sh | 5 + board/common/rootfs/etc/finit.d/10-infix.conf | 2 +- .../rootfs/etc/finit.d/available/rauc.conf | 2 +- ...src-main-add-optional-syslog-support.patch | 124 +++++++++++------- ...readiness-once-the-bus-name-is-owned.patch | 80 +++++++++++ 5 files changed, 160 insertions(+), 53 deletions(-) create mode 100644 patches/rauc/1.13/0002-service-notify-readiness-once-the-bus-name-is-owned.patch 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/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 + From 21ebdf965a02ea9d35920c437ac79f9be202861c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 30 Sep 2026 18:42:08 +0200 Subject: [PATCH 02/13] doc: call the feature unattended software updates The docs and ChangeLog mixed "upgrades" and "updates" for the same feature. The CLI keeps its legacy 'upgrade' command, everything else is software updates, matching the update-url and check-update settings. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 6 +++--- doc/schedule.md | 12 ++++++------ doc/upgrade.md | 6 +++--- src/confd/yang/confd/infix-system-software.yang | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 897ada08d..b26d9921a 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -28,10 +28,10 @@ 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] - 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 @@ -154,7 +154,7 @@ 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 [v26.08.0][] - 2026-09-01 ------------------------- diff --git a/doc/schedule.md b/doc/schedule.md index 09ebb8734..f8daf491c 100644 --- a/doc/schedule.md +++ b/doc/schedule.md @@ -109,11 +109,11 @@ and a typo shows up at commit time instead of at the next occurrence. These features consume schedules today: -| 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 @@ -148,4 +148,4 @@ schedule itself is enabled. [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..7e8632452 100644 --- a/doc/upgrade.md +++ b/doc/upgrade.md @@ -198,7 +198,7 @@ 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 @@ -209,7 +209,7 @@ Two independent features share one update source: - **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 +- **Unattended software updates** (`unattended-update`) also download and install the new release, exactly as a manual `upgrade` would ### Update Source @@ -337,7 +337,7 @@ itself does not have to exist. > certificate validation fails and every occurrence is skipped. Plain > HTTP avoids that on an isolated network. -### Enabling Unattended Updates +### Enabling Unattended Software Updates Unattended updates are off by default and need a [schedule][6] to trigger them. The example below installs new releases during a nightly diff --git a/src/confd/yang/confd/infix-system-software.yang b/src/confd/yang/confd/infix-system-software.yang index fb837f196..820824ae9 100644 --- a/src/confd/yang/confd/infix-system-software.yang +++ b/src/confd/yang/confd/infix-system-software.yang @@ -160,7 +160,7 @@ submodule infix-system-software { container unattended-update { description - "Policy for automatic, unattended software upgrades. + "Policy for automatic, unattended software updates. When 'enabled' and 'schedule' references a schedule, the system checks the configured update-url for a newer release on each From 6c8762c545d19c10374945a56c927dbde7058ed5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 09:30:47 +0200 Subject: [PATCH 03/13] Shorten the out-of-date partition note The note from issue #1637 ran to a long single line in the login banner, 'show software' and the WebUI, and the banner had no blank line after it so the login prompt sat right below the text. Say "the primary partition is out of date (v26.08.0)" and put the 'upgrade' hint on its own line, with a blank line after the banner. Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/libexec/infix/slot-check | 6 +++--- src/statd/python/cli_pretty/cli_pretty.py | 4 ++-- src/webui/templates/pages/software.html | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 3ac5c9165..b72694d92 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2215,8 +2215,8 @@ 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}.")) def show_services(json): 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}} From 628396789cf98e43eb6328cfd077b327640c18de Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 11:38:23 +0200 Subject: [PATCH 04/13] confd: only enable the Wi-Fi modes the radio supports hostapd refused to start on a BPi-R64, whose MT7622 radio is 802.11n only, because the generated config set ieee80211ax=1 for every band, and ieee80211ac=1 on 5 GHz, without asking the hardware. The PHY probe already reads the HT and VHT bitmasks, extend it to say whether the radio supports HE, and write the 802.11ac and 802.11ax lines, and the vht_* and he_* settings that go with them, only when it does. Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/libexec/infix/iw.py | 14 ++++-- doc/ChangeLog.md | 3 ++ src/confd/src/hardware.c | 55 ++++++++++++++------- 3 files changed, 49 insertions(+), 23 deletions(-) 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/doc/ChangeLog.md b/doc/ChangeLog.md index b26d9921a..1621e0654 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -124,6 +124,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 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"); } From 4f8c7d05fd8ad9541fdaf41213f629f050a28f98 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 30 Sep 2026 18:42:31 +0200 Subject: [PATCH 05/13] confd: activate scheduled features on reference, enabled pauses Enabling unattended updates took two steps: set enabled, then reference a schedule. The enabled leaf on check-update and unattended-update defaulted to false, while scheduled-reboot had no such leaf, so the three features that run on a schedule had two different shapes. Move the enabled and schedule leaves into a scheduled-feature grouping in infix-schedule, with enabled defaulting to true like every other enabled leaf in our models. A feature is now active as soon as it references a schedule, and enabled only pauses it, keeping the reference and the feature's other settings. The scheduler's consumer table no longer needs per-row leaf names. A check-update that referenced a schedule but never set enabled starts checking after this upgrade. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 3 + doc/schedule.md | 6 +- doc/upgrade.md | 15 +-- src/confd/src/core.c | 4 +- src/confd/src/core.h | 2 - src/confd/src/schedule.c | 29 ++--- src/confd/yang/confd/infix-schedule.yang | 69 ++++++----- .../yang/confd/infix-system-software.yang | 116 ++++++------------ src/confd/yang/confd/infix-system.yang | 18 ++- 9 files changed, 113 insertions(+), 149 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 1621e0654..a31e94c20 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -32,6 +32,8 @@ All notable changes to the project are documented in this file. 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 Software Updates][unattended] +- 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 @@ -158,6 +160,7 @@ All notable changes to the project are documented in this file. [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-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 f8daf491c..9d1ce6e2c 100644 --- a/doc/schedule.md +++ b/doc/schedule.md @@ -116,9 +116,9 @@ These features consume schedules today: | [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. +above. A feature is active as soon as it references a schedule. To +pause it without losing its settings, set its `enabled` leaf to `false`; +disabling the schedule itself stops every feature that references it.
admin@example:/> configure
 admin@example:/config/> set system scheduled-reboot schedule nightly
diff --git a/doc/upgrade.md b/doc/upgrade.md
index 7e8632452..87910725b 100644
--- a/doc/upgrade.md
+++ b/doc/upgrade.md
@@ -339,14 +339,14 @@ itself does not have to exist.
 
 ### Enabling Unattended Software 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.
+Unattended software updates need a [schedule][6] to trigger them, and are
+active as soon as one is referenced.  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
@@ -354,9 +354,10 @@ 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
+- `schedule`: The [schedule][6] whose occurrences trigger an update.
+  Without one no updates are performed
+- `enabled`: Set to `false` to pause unattended updates, the schedule
+  reference and reboot policy are kept (default: `true`)
 - `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
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/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 820824ae9..a5830feed 100644
--- a/src/confd/yang/confd/infix-system-software.yang
+++ b/src/confd/yang/confd/infix-system-software.yang
@@ -30,7 +30,10 @@ 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.";
     reference "Internal";
   }
   revision 2026-06-17 {
@@ -99,111 +102,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 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.
-
-           The 'reboot' leaf governs whether that reboot happens
-           automatically or is left to the operator.";
-
-        leaf enabled {
-          type boolean;
-          default false;
-          description
-            "Enable automatic unattended upgrades.";
-        }
+        description "Unattended software updates.
 
-        leaf schedule {
-          type infix-schedule:schedule-ref;
-          description
-            "The schedule whose occurrences trigger an unattended upgrade.
-             Without a referenced schedule no upgrades are performed.";
-        }
+           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 decides whether that reboot is automatic.";
+
+        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.";
         }
       }
     }
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;
     }
   }
 

From dc422f2a0b6e890b7869c3fe8fb50f631c06b634 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Wed, 30 Sep 2026 18:53:11 +0200
Subject: [PATCH 06/13] statd: add operational data for scheduled software
 updates

Neither check-update nor unattended-update left any trace beyond the
log and a login notice, so after enabling them the only way to see
that they took effect was crontab -l from the shell.

The update scripts now record the outcome of each run in a state file
that yanger folds into /system-state/software/update: when the feed
was last queried, the newest release it offered and whether that is
newer than what boots next, and the time and version of the last
unattended install.  Whether an installed image awaits a reboot is
derived from the RAUC slot data, so it also covers manual upgrades.

'show software' prints the configured triggers next to this outcome,
and the WebUI software page gets a Software Updates card.  The slot
table on that page now uses the same date format as the card.

Signed-off-by: Joachim Wiberg 
---
 .../rootfs/etc/tmpfiles.d/os-schedule.conf    |   1 +
 .../rootfs/usr/libexec/infix/update-common    |  29 +++
 board/common/rootfs/usr/sbin/check-update     |   4 +-
 .../common/rootfs/usr/sbin/unattended-update  |   6 +-
 doc/schedule.md                               |   3 +-
 doc/upgrade.md                                |  22 +-
 src/bin/show/__init__.py                      |   5 +
 .../yang/confd/infix-system-software.yang     |  49 ++++-
 src/statd/python/cli_pretty/cli_pretty.py     |  67 ++++++
 src/statd/python/yanger/ietf_system.py        |  26 +++
 src/webui/internal/handlers/dashboard.go      | 203 +++++++++++++++++-
 src/webui/internal/handlers/dashboard_test.go | 107 +++++++++
 src/webui/internal/handlers/system.go         |  19 +-
 src/webui/templates/pages/dashboard.html      |  29 +++
 test/case/statd/system/cli/show-software      |   5 +
 test/case/statd/system/ietf-system.json       |   3 +
 test/case/statd/system/operational.json       |   3 +
 17 files changed, 557 insertions(+), 24 deletions(-)

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/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/schedule.md b/doc/schedule.md
index 9d1ce6e2c..d8ca3c2bd 100644
--- a/doc/schedule.md
+++ b/doc/schedule.md
@@ -140,7 +140,8 @@ admin@example:~$ crontab -l
 
 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.
+schedule itself is enabled.  For update checks and unattended updates,
+`show software` reports the trigger and the outcome of the last occurrence.
 
 > [!NOTE]
 > The crontab is generated and must not be edited by hand.  It is
diff --git a/doc/upgrade.md b/doc/upgrade.md
index 87910725b..c08592d47 100644
--- a/doc/upgrade.md
+++ b/doc/upgrade.md
@@ -405,8 +405,26 @@ unattended-update: Installed v26.08.1; reboot to activate the new image
 | `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.
+`show software` reports the configured triggers and the outcome of the
+last occurrence, next to the slot contents and boot order:
+
+
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   : nightly (daily 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 Software page of the WebUI. > [!TIP] > A system running a development build has no comparable version number 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/yang/confd/infix-system-software.yang b/src/confd/yang/confd/infix-system-software.yang index a5830feed..c45af4f17 100644 --- a/src/confd/yang/confd/infix-system-software.yang +++ b/src/confd/yang/confd/infix-system-software.yang @@ -33,7 +33,9 @@ submodule infix-system-software { Add allow-prerelease. Use the infix-schedule scheduled-feature grouping for check-update and unattended-update, enabled now defaults - to true."; + 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 { @@ -211,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/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index b72694d92..68ab3904f 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2218,6 +2218,73 @@ def show_software(json, name): 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. 'daily 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', '')[:3] for d in rec.get('byday', [])) + if days: + text += f" 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): if not json.get("ietf-system:system-state", "infix-system:services"): 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/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/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/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": { From c2a6b4bd773c9a6a3f36f8e7bf08de60ed2eae99 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 08:32:11 +0200 Subject: [PATCH 07/13] confd: provide nightly and weekly schedules in the factory config Enabling a scheduled feature meant first creating a schedule, so the "connect it to a schedule" step the model promises was really three or four commands of recurrence setup. Ship two schedules with the factory config, nightly at 03:00 and weekly on Sunday nights at 03:00. An unreferenced schedule generates nothing, so they are inert until a feature points at one. Signed-off-by: Joachim Wiberg --- .../etc/factory-config.cfg | 29 ++++++++++ .../bananapi,bpi-r3/etc/factory-config.cfg | 29 ++++++++++ .../etc/factory-config.cfg | 29 ++++++++++ .../etc/factory-config.cfg | 29 ++++++++++ .../bananapi,bpi-r4/etc/factory-config.cfg | 29 ++++++++++ .../bananapi,bpi-r64/etc/factory-config.cfg | 29 ++++++++++ .../etc/factory-config.cfg | 29 ++++++++++ .../etc/factory-config.cfg | 58 +++++++++++++++++++ .../raspberrypi,400/etc/factory-config.cfg | 29 ++++++++++ .../etc/factory-config.cfg | 29 ++++++++++ doc/ChangeLog.md | 2 + src/confd/share/factory.d/10-schedule.json | 25 ++++++++ src/confd/share/factory.d/Makefile.am | 4 +- 13 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 src/confd/share/factory.d/10-schedule.json 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/doc/ChangeLog.md b/doc/ChangeLog.md index a31e94c20..5f5f93275 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -32,6 +32,8 @@ All notable changes to the project are documented in this file. 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 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 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 From e0c8bd1ddd8fb477a039abaddd86847d159ba71e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 08:32:12 +0200 Subject: [PATCH 08/13] cli: name the pinned weekday in the schedule summary 'show software' described the factory weekly schedule as "weekly on sun at 03:00", repeating the frequency. When a schedule pins weekdays and has no interval, the days alone say it: "sunday at 03:00". Signed-off-by: Joachim Wiberg --- src/statd/python/cli_pretty/cli_pretty.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 68ab3904f..58f8bf653 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2222,16 +2222,16 @@ def show_software(json, name): def describe_recurrence(rec): - """Short form of an ietf-schedule recurrence, e.g. 'daily at 03:30'""" + """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', '')[:3] for d in rec.get('byday', [])) + days = ",".join(d.get('weekday', '') for d in rec.get('byday', [])) if days: - text += f" on {days}" + text = days if interval == 1 else f"{text} on {days}" hours = rec.get('byhour') if hours: From d7fab9ca24d7d458ae8e91ff9b9289241ec223a3 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 09:45:13 +0200 Subject: [PATCH 09/13] cli: let the version column in 'show software' fit the data A development version like v26.09.0-rc1-7-g444b9c575 is longer than the fixed column width, so the date ran straight into it. Widen the column to the longest version shown, keeping the old width as the minimum. Signed-off-by: Joachim Wiberg --- src/statd/python/cli_pretty/cli_pretty.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 58f8bf653..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() From 09cdb8e069810f513d45bd735aa08d58748c8df5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 09:37:40 +0200 Subject: [PATCH 10/13] webui: parse every page template in a test An unbalanced action in a page template only showed up when the daemon started, as a "server setup" error in the log, since the handler tests render minimal stand-in templates. Parse every page with the layouts and fragments the way server.New does, so the build catches it. Signed-off-by: Joachim Wiberg --- src/webui/main_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/webui/main_test.go 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) + } + } +} From 4ae696ad17b6256f091a5e594e8d710d0d4bea5e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 09:59:29 +0200 Subject: [PATCH 11/13] webui: drop the cached YANG files when the image changes The YANG tree editor showed the description text of a module from the previous image after an upgrade. The cache under /var/cache/webui/yang only checks that name@revision.yang exists, /var persists across upgrades, and a module's revision does not change every time its text does, least of all between release candidates. Stamp the cache with VERSION_ID from /etc/os-release and empty it when the running image is another one. The YANG files ship with the image, so that is the key that tracks them. Signed-off-by: Joachim Wiberg --- .../handlers/configure_advanced_test.go | 2 +- .../handlers/configure_firewall_test.go | 8 ++-- src/webui/internal/schema/refresh.go | 44 ++++++++++++++---- src/webui/internal/schema/refresh_test.go | 45 +++++++++++++++++++ src/webui/main.go | 16 ++++++- 5 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 src/webui/internal/schema/refresh_test.go 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/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 "" +} From 303abeb4e2aa8561953a03e8568471826deced2c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 10:04:12 +0200 Subject: [PATCH 12/13] webui: open the sidebar section a link leads into Following a "Configure" link from a status page into the tree editor left the sidebar with every section collapsed. The URL sync after an htmx navigation closes the other sections but refused to open Configure, to keep its enter request off the URL sync path. That request is guarded to fire once per page lifecycle, and a page in the Configure section needs the candidate it initialises, so open the section like any other. Signed-off-by: Joachim Wiberg --- src/webui/static/js/app.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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; } From b3630d23903fec783ec8aa638942c1cce46b8c9e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 08:32:13 +0200 Subject: [PATCH 13/13] doc: rework the unattended software updates section A user setting this up had to read about the update source and how to host a feed before reaching the example that enables the feature, and that example first created a schedule by hand. Lead with enabling, using the factory schedules and the 'configure system' context, then status, then the update source, and keep the feed hosting howto last. The scheduling page uses the factory nightly schedule in its example and creates a schedule under another name. Signed-off-by: Joachim Wiberg --- doc/schedule.md | 178 +++++++++++++++------------------ doc/upgrade.md | 261 +++++++++++++++++++++--------------------------- 2 files changed, 196 insertions(+), 243 deletions(-) diff --git a/doc/schedule.md b/doc/schedule.md index d8ca3c2bd..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,57 +49,54 @@ 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 | |----------------------------------|-------------------------------------| @@ -115,38 +104,37 @@ These features consume schedules today: | 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. A feature is active as soon as it references a schedule. To -pause it without losing its settings, set its `enabled` leaf to `false`; -disabling the schedule itself stops every feature that references 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. For update checks and unattended updates, -`show software` reports the trigger and the outcome of the last occurrence. +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-software-updates diff --git a/doc/upgrade.md b/doc/upgrade.md index c08592d47..68baaa157 100644 --- a/doc/upgrade.md +++ b/doc/upgrade.md @@ -200,60 +200,123 @@ command again, and (optionally) reboot. ## 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 software 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: + +``` +/releases/download//-.pkg +``` -Atom and RSS 2.0 both work. An Atom feed carries one `` per -release, each with a `` whose `href` ends in `/releases/tag/`: +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,109 +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 Software Updates - -Unattended software updates need a [schedule][6] to trigger them, and are -active as soon as one is referenced. 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 schedule nightly
-admin@example:/config/> set system software unattended-update reboot manual
-admin@example:/config/> leave
-
- -**Parameters:** - -- `schedule`: The [schedule][6] whose occurrences trigger an update. - Without one no updates are performed -- `enabled`: Set to `false` to pause unattended updates, the schedule - reference and reboot policy are kept (default: `true`) -- `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 the configured triggers and the outcome of the -last occurrence, next to the slot contents and boot order: - -
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   : nightly (daily 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 Software page of the WebUI. - -> [!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