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 24d75bb9f..255879358 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 @@ -207,7 +207,6 @@ "bridge": "br0" } } - ] }, "ietf-keystore:keystore": { @@ -376,6 +375,9 @@ }, "ietf-system:system": { "hostname": "acer-connect-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 370c27c23..dff3ccbd3 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 @@ -368,6 +368,9 @@ }, "ietf-system:system": { "hostname": "bpi-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 08dd3bcd1..728bba2e6 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 @@ -334,6 +334,9 @@ }, "ietf-system:system": { "hostname": "bpi-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 05b176573..89b3b31fb 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 @@ -292,6 +292,9 @@ }, "ietf-system:system": { "hostname": "bpi-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 6f6d9fe4d..f827fa453 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 @@ -284,6 +284,9 @@ }, "ietf-system:system": { "hostname": "bpi-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 95c23b9a5..235a16da0 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 @@ -326,6 +326,9 @@ }, "ietf-system:system": { "hostname": "bpi-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 b984bdf69..b996bd9ae 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 @@ -268,6 +268,9 @@ }, "ietf-system:system": { "hostname": "r2s-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 8ee44c5ee..55d29970f 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 @@ -245,6 +245,9 @@ }, "ietf-system:system": { "hostname": "rpi-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 cc5b13e43..50aee2240 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 @@ -259,6 +259,9 @@ }, "ietf-system:system": { "hostname": "rpi-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "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 04d3da85c..32375ab65 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 @@ -230,6 +230,9 @@ }, "ietf-system:system": { "hostname": "rpi-%m", + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + }, "ntp": { "enabled": true, "server": [ diff --git a/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf b/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf index 56e49080a..11494112b 100644 --- a/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf +++ b/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf @@ -1 +1,2 @@ f /run/os-update 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 new file mode 100644 index 000000000..e138318f2 --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/update-common @@ -0,0 +1,109 @@ +# Shared helpers for check-update and unattended-update. Sourced, not run; +# the caller sets TAG first. + +# 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. +update_read_url() { + copy running-config \ + -x '/ietf-system:system/infix-system:software/update-url' \ + 2>/dev/null \ + | jq -r '.. | objects | ."update-url"? // empty' +} + +# Read whether pre-releases may be installed; default false. +update_read_prerelease() { + val=$(copy running-config \ + -x '/ietf-system:system/infix-system:software/allow-prerelease' \ + 2>/dev/null \ + | jq -r '.. | objects | ."allow-prerelease"? // empty') + [ "$val" = true ] && printf 'true' || printf 'false' +} + +# Is $1 strictly newer than $2? +newer() { + [ "$1" = "$2" ] && return 1 + [ "$(printf '%s\n%s' "$1" "$2" | sort -V | tail -1)" = "$1" ] +} + +# Gather running and latest version info. +# Returns: 0 ok, 1 fatal (no os-release), 2 failed to query latest release tag, +# 3 no update-url configured. +update_probe() { + if [ ! -f /etc/os-release ]; then + logger -t "$TAG" "ERROR: /etc/os-release not found" + return 1 + fi + . /etc/os-release + + UPDATE_URL=$(update_read_url) + [ -z "$UPDATE_URL" ] && return 3 + ALLOW_PRERELEASE=$(update_read_prerelease) + + FEED=$(curl -sSfL --max-time 10 "$UPDATE_URL" 2>/dev/null) || return 2 + + # Every feed entry links to its release page, ".../releases/tag/", + # which is the only place the tag appears in machine-readable form. + hrefs=$(printf '%s' "$FEED" \ + | xmllint --xpath "//*[local-name()='entry']/*[local-name()='link']/@href" - 2>/dev/null \ + | tr ' ' '\n' | sed -n 's|^href="\(.*\)"$|\1|p') + + # RSS 2.0 has no href attribute, the URL is the element text. + [ -n "$hrefs" ] || hrefs=$(printf '%s' "$FEED" \ + | xmllint --xpath "//*[local-name()='item']/*[local-name()='link']/text()" - 2>/dev/null \ + | tr -d ' \t' | grep -v '^$') + + [ -n "$hrefs" ] || return 2 + + + if [ "$ALLOW_PRERELEASE" = true ]; then + LATEST_TAG=$(printf '%s\n' "$hrefs" | sed 's|.*/||' | head -1) + else + LATEST_TAG=$(printf '%s\n' "$hrefs" | sed 's|.*/||' \ + | grep -vE -- '-(rc|alpha|beta)' | head -1) + fi + [ -n "$LATEST_TAG" ] || return 2 + + RELEASE_BASE=$(printf '%s\n' "$hrefs" | grep -E "/${LATEST_TAG}\$" | head -1 \ + | sed "s|/releases/tag/${LATEST_TAG}\$||") + + return 0 +} + +# Version that will be running after the next reboot: the bundle installed in +# the slot RAUC boots from. This is the running version until something has +# been staged into the inactive slot, after which it is the staged one -- so a +# release installed but not yet rebooted into is not offered again. +# +# 'rauc status' needs --detailed, without it the slot_status object holding the +# version is left out entirely. Note that .booted is a bootname ("primary"), +# only .boot_primary is a slot name ("rootfs.0") that keys into .slots. +update_pending_version() { + rauc status --detailed --output-format=json 2>/dev/null \ + | jq -r '(.slots | add) as $s + | $s[.boot_primary].slot_status.bundle.version // empty' +} + +# Should the latest release be applied? Requires update_probe() to have run. +# Returns 0 if an update is available. +update_available() { + pending=$(update_pending_version) + # A slot never written by RAUC has no recorded version; fall back to the + # running one rather than skipping the upgrade. + [ -n "$pending" ] || pending=$VERSION + + newer "$LATEST_TAG" "$pending" +} + +# Print the release page URL of the latest release, for operator-facing logs. +update_release_url() { + [ -n "$RELEASE_BASE" ] || return 0 + printf '%s/releases/tag/%s' "$RELEASE_BASE" "$LATEST_TAG" +} + +# Print the download URL of this platform's RAUC bundle. +update_bundle_url() { + [ -n "$RELEASE_BASE" ] || return 0 + printf '%s/releases/download/%s/%s-%s.pkg' \ + "$RELEASE_BASE" "$LATEST_TAG" "$IMAGE_ID" "$LATEST_TAG" +} diff --git a/board/common/rootfs/usr/sbin/check-update b/board/common/rootfs/usr/sbin/check-update index f380bf392..2cce6658a 100755 --- a/board/common/rootfs/usr/sbin/check-update +++ b/board/common/rootfs/usr/sbin/check-update @@ -5,51 +5,28 @@ NOTIFY_FILE=/run/os-update TAG=os-update -# Source os-release for VERSION and IMAGE_ID -if [ ! -f /etc/os-release ]; then - logger -t "$TAG" "ERROR: /etc/os-release not found" +. /usr/libexec/infix/update-common + +update_probe +rc=$? +if [ $rc -eq 1 ]; then exit 1 fi -. /etc/os-release - -# Dev/dirty builds have no comparable semver — always show the latest release -IS_RELEASE=true -if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then - IS_RELEASE=false +if [ $rc -eq 2 ]; then + logger -p daemon.info -t "$TAG" "Update check skipped: failed to query latest release from ${UPDATE_URL}" + exit 0 fi - -# Read configured update-url from running config, fall back to upstream -UPDATE_URL=$(copy running-config \ - -x '/ietf-system:system/infix-system:software/check-update/update-url' \ - 2>/dev/null \ - | jq -r '.. | objects | ."update-url"? // empty') -UPDATE_URL=${UPDATE_URL:-"https://github.com/kernelkit/infix"} - -# Derive API URL from the configured update URL. -# Default (github.com): https://github.com/org/repo → https://api.github.com/repos/org/repo -REPO=$(echo "$UPDATE_URL" | sed 's|https://github.com/||; s|/*$||') -API_URL="https://api.github.com/repos/${REPO}/releases/latest" - -LATEST_TAG=$(curl -sSL --max-time 10 "$API_URL" 2>/dev/null \ - | jq -r '.tag_name // empty') -if [ -z "$LATEST_TAG" ]; then - logger -p daemon.info -t "$TAG" "Update check skipped: could not reach ${API_URL}" +if [ $rc -eq 3 ]; then + logger -p daemon.info -t "$TAG" "Update check skipped: no update-url configured" exit 0 fi -LATEST=${LATEST_TAG#v} - -# Compare: is $1 strictly newer than $2? -newer() { - [ "$1" = "$2" ] && return 1 - [ "$(printf '%s\n%s' "$1" "$2" | sort -V | tail -1)" = "$1" ] -} -if [ "$IS_RELEASE" = false ] || newer "$LATEST" "$VERSION"; then - RELEASE_URL="${UPDATE_URL}/releases/${LATEST_TAG}" +if update_available; then + RELEASE_URL=$(update_release_url) MSG="Software update available: ${LATEST_TAG}, running ${VERSION} (see ${RELEASE_URL})" logger -t "$TAG" "$MSG" printf '%s\n' "$MSG" > "$NOTIFY_FILE" else - logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST)" + logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST_TAG)" printf '' > "$NOTIFY_FILE" fi diff --git a/board/common/rootfs/usr/sbin/unattended-update b/board/common/rootfs/usr/sbin/unattended-update new file mode 100755 index 000000000..9059ea862 --- /dev/null +++ b/board/common/rootfs/usr/sbin/unattended-update @@ -0,0 +1,78 @@ +#!/bin/sh +# Download and install a newer release, unattended. Called by the scheduler. +# +# Installs to the inactive slot like a manual 'upgrade': RAUC flips the +# boot-order to activate on next reboot, leaving the old slot as fallback. +# The 'reboot' config policy decides whether that reboot is automatic. + +TAG=unattended-update +# Pre-created by tmpfiles.d, scheduled jobs run as 'admin' and cannot create +# files in /run themselves. +LOCKFILE=/run/unattended-update.lock + +. /usr/libexec/infix/update-common + +# Read the reboot policy (manual|immediate) from running-config; default manual. +read_reboot_policy() { + policy=$(copy running-config \ + -x '/ietf-system:system/infix-system:software/unattended-update/reboot' \ + 2>/dev/null \ + | jq -r '.. | objects | .reboot? // empty') + [ -n "$policy" ] && printf '%s' "$policy" || printf 'manual' +} + +# Single-instance guard -- also avoids racing a manual 'upgrade' or an +# overlapping tick if a previous run is still installing. Open the lock +# explicitly first: an unwritable lock must fail loudly here, not slip through +# to flock and get misreported as "already in progress". +if ! { true >> "$LOCKFILE"; } 2>/dev/null; then + logger -t "$TAG" "ERROR: cannot open lock $LOCKFILE" + exit 1 +fi +exec 9>"$LOCKFILE" +if ! flock -n 9; then + logger -t "$TAG" "Another update is already in progress, skipping" + exit 0 +fi + +update_probe +rc=$? +if [ $rc -eq 1 ]; then + exit 1 +fi +if [ $rc -eq 2 ]; then + logger -p daemon.info -t "$TAG" "Skipped: failed to query latest release from ${UPDATE_URL}" + exit 0 +fi +if [ $rc -eq 3 ]; then + logger -p daemon.info -t "$TAG" "Skipped: no update-url configured" + exit 0 +fi + +if ! update_available; then + logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST_TAG)" + exit 0 +fi + +BUNDLE_URL=$(update_bundle_url) +if [ -z "$BUNDLE_URL" ]; then + logger -t "$TAG" "Update ${LATEST_TAG} found, but no bundle URL could be resolved; skipping" + exit 1 +fi + +# RAUC streams the bundle from the URL, nothing is staged locally. +logger -t "$TAG" "Installing ${LATEST_TAG} from ${BUNDLE_URL} (running ${VERSION})" +if ! rauc install "$BUNDLE_URL"; then + logger -t "$TAG" "ERROR: installation of ${LATEST_TAG} failed" + exit 1 +fi + +POLICY=$(read_reboot_policy) +if [ "$POLICY" = immediate ]; then + logger -t "$TAG" "Installed ${LATEST_TAG}; reboot policy 'immediate', rebooting to activate" + sync + sleep 2 + /usr/sbin/reboot +else + logger -t "$TAG" "Installed ${LATEST_TAG}; reboot to activate the new image" +fi diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index d6d9fa15a..6409d365f 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -43,6 +43,10 @@ All notable changes to the project are documented in this file. editor, show mesh peers on the WiFi and interface status pages, and add an editor section for access point roaming (802.11k/r/v, band steering, OKC). +- Add support for unattended software upgrades, 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 ### Fixes diff --git a/doc/schedule.md b/doc/schedule.md new file mode 100644 index 000000000..09ebb8734 --- /dev/null +++ b/doc/schedule.md @@ -0,0 +1,151 @@ +# 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
+
+ +**Schedule parameters:** + +- `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 + + +## 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: + +| Frequency | Fires | +|------------|------------------------------------| +| `minutely` | Every minute | +| `hourly` | Every hour, on the hour | +| `daily` | Every day at midnight | +| `weekly` | Every week | +| `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. + +The remaining fields refine that period by pinning one field to specific +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 + +Each accepts a list, so `byhour 8` plus `byhour 20` fires twice a day. + +> [!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. + + +## 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: + +- **`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 + +`frequency yearly` with an `interval` above 1 ("every other year") is also +not expressible; 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. + +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` | + +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. + +
admin@example:/> configure
+admin@example:/config/> set system scheduled-reboot schedule nightly
+admin@example:/config/> leave
+
+ + +## 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: + +```sh +admin@example:~$ crontab -l +# Managed by infix-schedule +30 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. + +> [!NOTE] +> The crontab is generated and must not be edited by hand. It is +> rewritten from the configuration on every change. + +[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 diff --git a/doc/upgrade.md b/doc/upgrade.md index 2c7443164..0269a83d9 100644 --- a/doc/upgrade.md +++ b/doc/upgrade.md @@ -194,6 +194,220 @@ now the preferred boot source. To upgrade the remaining partition (`primary`), run the `upgrade URL` command again, and (optionally) reboot. +## Unattended 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]. + +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 + the new release, exactly as a manual `upgrade` would + +### Update Source + +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: + +
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
+
+ +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. + +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. + +A feed carries no asset list, so the per-platform bundle is fetched by +convention from: + +``` +/releases/download//-.pkg +``` + +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. + +### 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. + +Atom and RSS 2.0 both work. An Atom feed carries one `` per +release, each with a `` whose `href` ends in `/releases/tag/`: + +```xml + + + Example Infix releases + + v26.08.1 + 2026-08-20T10:00:00Z + + + + v26.05.0 + 2026-05-14T10:00:00Z + + + +``` + +An RSS 2.0 feed carries the same URLs, as the text of an `` element's +`` rather than an attribute: + +```xml + + + + + v26.08.1 + https://releases.example.com/infix/releases/tag/v26.08.1 + + + +``` + +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: + +``` +infix/ +├── releases.atom +└── releases + └── download + └── v26.08.1 + ├── infix-aarch64-v26.08.1.pkg + └── infix-x86_64-v26.08.1.pkg +``` + +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
+
+ +**Requirements:** + +- **Atom or RSS 2.0.** Atom is tried first, reading the `href` attribute + 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 +- **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` +- **One bundle per platform**, named `-.pkg`. A device + looks only for its own `IMAGE_ID`, so one feed can serve several + platforms +- **HTTP range requests.** RAUC streams the bundle instead of downloading + 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. + +> [!TIP] +> Serving the feed over HTTPS requires a correct clock on the device, or +> certificate validation fails and every occurrence is skipped. Plain +> HTTP avoids that on an isolated network. + +### Enabling Unattended Updates + +Unattended updates are off by default and need a [schedule][6] to trigger +them. The example below installs new releases during a nightly +maintenance window, leaving the reboot to the operator. + +
admin@example:/> configure
+admin@example:/config/> set system schedule nightly recurrence frequency daily
+admin@example:/config/> set system schedule nightly recurrence byhour 3
+admin@example:/config/> set system software unattended-update enabled true
+admin@example:/config/> set system software unattended-update schedule nightly
+admin@example:/config/> set system software unattended-update reboot manual
+admin@example:/config/> leave
+
+ +**Parameters:** + +- `enabled`: Enable unattended updates (default: `false`). Without a + referenced schedule no updates are performed either way +- `schedule`: The [schedule][6] whose occurrences trigger an update +- `reboot`: What to do after a successful install + - `manual` (default): Install and flip the boot-order, but do not + reboot. The new image activates the next time the operator reboots + - `immediate`: Reboot automatically to activate the new image at once + +### What Happens on Each Occurrence + +1. The feed is queried for the latest release. If it cannot be reached, + the occurrence is logged and skipped, and the job exits successfully +2. If the latest release is not newer than the running version, nothing + happens +3. Otherwise the platform bundle is installed to the *inactive* partition, + and the boot-order is flipped to activate it on the next boot. The + partition currently running is left untouched as a fallback +4. Depending on the `reboot` policy, the system either reboots or logs + that a reboot is needed + +A single-instance lock means occurrences never overlap: if an install is +still running when the next one fires, the new occurrence is skipped. + +> [!CAUTION] +> An unattended update does no additional checks beyond those of a manual +> `upgrade`: the previously running image remains on the other partition, +> and the bootloader falls back to it if the new image does not boot. +> Nothing verifies the new image beyond that, so see the caution under +> [Upgrading](#upgrading) about upgrading only one partition at a time. + +### Monitoring + +Operator-facing messages go to `/var/log/messages`, while skipped +occurrences are logged at `daemon.info`/`daemon.debug` in +`/var/log/syslog`: + +```sh +admin@example:~$ grep unattended-update /var/log/messages +unattended-update: Installing v26.08.1 from https://.../infix-aarch64-v26.08.1.pkg (running v26.05.0) +unattended-update: Installed v26.08.1; reboot to activate the new image +``` + +| Message | Meaning | +|----------------------------------------------|----------------------------------| +| `Installing from (running )`| Install started | +| `Installed ; reboot to activate …` | Success, `reboot manual` | +| `No update available (current: …, latest: …)`| Ran, nothing to do | +| `Skipped: failed to query latest release …` | Feed unreachable | +| `Another update is already in progress …` | Previous occurrence still running| + +`show software` reports installation state, slot contents and the boot +order, both during and after the install. + +> [!TIP] +> A system running a development build has no comparable version number +> and is always considered upgradable, so an unattended update on a dev +> build installs the latest release from the feed on the first occurrence. + ## Configuration Migration The example above illustrated an upgrade from Infix v25.01.0 to @@ -472,6 +686,7 @@ Continued configuration is done as with any unit after factory reset. [3]: boot.md#system-boot [4]: management.md#console-port [5]: scripting.md#-backup-configuration-using-sysrepocfg-and-scp +[6]: schedule.md [^1]: In failure config, Infix puts all Ethernet ports as individual interfaces. With direct access, one can connect with e.g., SSH, diff --git a/mkdocs.yml b/mkdocs.yml index 550a8b3f3..e2d1a750f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,14 +50,15 @@ nav: - NTP Server: ntp.md - PTP (IEEE 1588/802.1AS): ptp.md - System: + - Access Control (NACM): nacm.md - Boot Procedure: boot.md - Configuration: system.md - - Access Control (NACM): nacm.md - Hardware Info & Status: hardware.md - - Management: management.md - Keystore: keystore.md - - Syslog Support: syslog.md + - Management: management.md + - Scheduling: schedule.md - Support Data: support.md + - Syslog Support: syslog.md - Upgrade: upgrade.md - Scripting: - Introduction: scripting.md diff --git a/src/confd/configure.ac b/src/confd/configure.ac index 7747ea530..3afba3d37 100644 --- a/src/confd/configure.ac +++ b/src/confd/configure.ac @@ -1,6 +1,6 @@ AC_PREREQ(2.61) # confd version is same as system YANG model version, step on breaking changes -AC_INIT([confd], [1.9], [https://github.com/kernelkit/infix/issues]) +AC_INIT([confd], [1.10], [https://github.com/kernelkit/infix/issues]) AM_INIT_AUTOMAKE(1.11 foreign subdir-objects) AM_SILENT_RULES(yes) @@ -23,6 +23,7 @@ AC_CONFIG_FILES([ share/migrate/1.7/Makefile share/migrate/1.8/Makefile share/migrate/1.9/Makefile + share/migrate/1.10/Makefile yang/Makefile yang/confd/Makefile yang/test-mode/Makefile diff --git a/src/confd/share/factory.d/10-software.json b/src/confd/share/factory.d/10-software.json new file mode 100644 index 000000000..c31791604 --- /dev/null +++ b/src/confd/share/factory.d/10-software.json @@ -0,0 +1,7 @@ +{ + "ietf-system:system": { + "infix-system:software": { + "update-url": "https://github.com/kernelkit/infix/releases.atom" + } + } +} diff --git a/src/confd/share/factory.d/Makefile.am b/src/confd/share/factory.d/Makefile.am index 4256ab4c3..3a2e11ad0 100644 --- a/src/confd/share/factory.d/Makefile.am +++ b/src/confd/share/factory.d/Makefile.am @@ -1,4 +1,5 @@ factorydir = $(pkgdatadir)/factory.d dist_factory_DATA = 10-keystore.json 10-nacm.json \ 10-netconf-server.json \ - 10-infix-services.json 10-system.json + 10-infix-services.json 10-software.json \ + 10-system.json diff --git a/src/confd/share/migrate/1.10/10-software-update-url.sh b/src/confd/share/migrate/1.10/10-software-update-url.sh new file mode 100755 index 000000000..5ac0b291f --- /dev/null +++ b/src/confd/share/migrate/1.10/10-software-update-url.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# Move software/check-update/update-url to the shared software/update-url, +# convert it from a repository URL to an RSS/Atom release feed, and ensure +# every config that has a software container names a feed. +# +# The update source was lifted out of the check-update container so that +# check-update and unattended-update share a single setting, and the latest +# version is now read from a release feed instead of the GitHub REST API. +# +# update-url has no model default any more: the release channel a product +# ships with belongs in its factory-config, not in the YANG. It is mandatory +# instead, so a config that enabled check-update without ever setting the URL +# -- previously fine, the default covered it -- would no longer validate. +# Such configs inherit the feed from this unit's factory-config, which is the +# same value the model default used to supply on a stock build, and the +# vendor's own channel on a br2-external. +# +# Configs with no software container at all are left untouched; the container +# carries presence, so its absence stays valid. + +file=$1 +temp=${file}.tmp +factory=${FACTORY_CONFIG:-/etc/factory-config.cfg} + +# The feed this unit was built with, empty if factory-config does not name one. +url=$(jq -r 'getpath(["ietf-system:system", "infix-system:software", "update-url"]) + // empty' "$factory" 2>/dev/null) + +jq --arg url "$url" ' + ["ietf-system:system", "infix-system:software", "check-update", "update-url"] as $old + | ["ietf-system:system", "infix-system:software", "update-url"] as $new + | ["ietf-system:system", "infix-system:software"] as $sw + | (if getpath($old) != null + then setpath($new; getpath($old)) | delpaths([$old]) + else . end) + | (getpath($new) as $u + | if ($u | type) == "string" and (($u | endswith(".atom")) | not) + then setpath($new; ($u | sub("/+$"; "")) + "/releases.atom") + else . end) + | (if getpath($sw) != null and getpath($new) == null and $url != "" + then setpath($new; $url) + else . end) +' "$file" > "$temp" && mv "$temp" "$file" diff --git a/src/confd/share/migrate/1.10/Makefile.am b/src/confd/share/migrate/1.10/Makefile.am new file mode 100644 index 000000000..07782eedc --- /dev/null +++ b/src/confd/share/migrate/1.10/Makefile.am @@ -0,0 +1,2 @@ +migratedir = $(pkgdatadir)/migrate/1.10 +dist_migrate_DATA = 10-software-update-url.sh diff --git a/src/confd/share/migrate/Makefile.am b/src/confd/share/migrate/Makefile.am index 2abea24e0..755ac16a4 100644 --- a/src/confd/share/migrate/Makefile.am +++ b/src/confd/share/migrate/Makefile.am @@ -1,2 +1,2 @@ -SUBDIRS = 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 +SUBDIRS = 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 1.10 migratedir = $(pkgdatadir)/migrate diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 99fd04c14..208ce7bf5 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -563,6 +563,44 @@ static confd_dependency_t dep_radio_components(struct lyd_node **diff, struct ly return result; } +static confd_dependency_t dep_schedule_consumers(struct lyd_node **diff, struct lyd_node *config) +{ + confd_dependency_t result = CONFD_DEP_DONE; + const struct cron_consumer *consumer; + size_t i; + + for (i = 0; (consumer = schedule_consumer(i)); i++) { + struct lyd_node *node; + const char *name; + char xpath[256]; + + node = lydx_get_xpathf(*diff, "%s", consumer->path); + if (!node) + continue; + + /* A diff that only toggles 'enabled' keeps the ref in config. */ + name = lydx_get_cattr(node, consumer->sched_leaf); + if (!name) { + node = lydx_get_xpathf(config, "%s", consumer->path); + name = node ? lydx_get_cattr(node, consumer->sched_leaf) : NULL; + } + if (!name) + continue; + + snprintf(xpath, sizeof(xpath), + "/ietf-system:system/infix-schedule:schedules/schedule[name='%s']", name); + result = add_dependencies(diff, xpath, name); + if (result == CONFD_DEP_ERROR) { + ERROR("Failed to add schedule %s to diff for %s", name, consumer->path); + return result; + } + + DEBUG("Added schedule %s to diff for %s", name, consumer->path); + } + + return result; +} + static confd_dependency_t handle_dependencies(struct lyd_node **diff, struct lyd_node *config) { confd_dependency_t result; @@ -587,6 +625,10 @@ static confd_dependency_t handle_dependencies(struct lyd_node **diff, struct lyd if (result == CONFD_DEP_ERROR) return result; + result = dep_schedule_consumers(diff, config); + if (result == CONFD_DEP_ERROR) + return result; + return result; } diff --git a/src/confd/src/core.h b/src/confd/src/core.h index 38c80873e..4efd5111e 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -218,14 +218,13 @@ int hostnamefmt (struct confd *confd, const char *fmt, char *hostnm, size_t int system_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); /* schedule.c */ -/* A feature registers cron consumer to run a command on a schedule. */ struct cron_consumer { - const char *path; /* xpath of the container holding the schedule-ref leaf */ - const char *sched_leaf; /* name of the schedule-ref leaf within 'path' */ - const char *enabled_leaf; /* boolean leaf in 'path' that gates the job; NULL = active whenever a schedule is referenced */ - const char *command; /* what crond runs on each occurrence */ + const char *path; + const char *sched_leaf; + const char *enabled_leaf; + const char *command; }; -int schedule_consumer_register(const struct cron_consumer *consumer); +const struct cron_consumer *schedule_consumer(size_t i); int schedule_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); /* containers.c */ diff --git a/src/confd/src/schedule.c b/src/confd/src/schedule.c index 8efd8ecb0..1ea3b3095 100644 --- a/src/confd/src/schedule.c +++ b/src/confd/src/schedule.c @@ -15,25 +15,36 @@ #define CRONTAB_FILE CRONTAB_DIR "/admin" #define CRONTAB_NEXT CRONTAB_DIR "/admin.next" -/* Features register a consumer to run a command on a schedule. */ -static const struct cron_consumer **consumers; -static size_t consumer_count; - -int schedule_consumer_register(const struct cron_consumer *consumer) +/* + * 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. + */ +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/unattended-update", + .sched_leaf = "schedule", + .enabled_leaf = "enabled", + .command = "/usr/sbin/unattended-update", + }, { + .path = "/ietf-system:system/infix-system:scheduled-reboot", + .sched_leaf = "schedule", + .enabled_leaf = NULL, + .command = "/usr/sbin/reboot", + }, +}; + +const struct cron_consumer *schedule_consumer(size_t i) { - const struct cron_consumer **vec; + if (i >= NELEMS(consumers)) + return NULL; - if (!consumer || !consumer->path || !consumer->sched_leaf || !consumer->command) - return -1; - - vec = realloc(consumers, (consumer_count + 1) * sizeof(*vec)); - if (!vec) { - ERROR("schedule: out of memory registering %s", consumer->path); - return -1; - } - consumers = vec; - consumers[consumer_count++] = consumer; - return 0; + return &consumers[i]; } /* @@ -244,8 +255,8 @@ static int gen_schedules(struct lyd_node *config) if (!config) goto out; - for (i = 0; i < consumer_count; i++) { - const struct cron_consumer *c = consumers[i]; + for (i = 0; i < NELEMS(consumers); i++) { + const struct cron_consumer *c = &consumers[i]; struct lyd_node *node; const char *name; char expr[128]; diff --git a/src/confd/src/system-software.c b/src/confd/src/system-software.c index 50a265491..47c735480 100644 --- a/src/confd/src/system-software.c +++ b/src/confd/src/system-software.c @@ -89,14 +89,6 @@ static int infix_system_sw_set_boot_order(sr_session_ctx_t *session, uint32_t su return SR_ERR_OK; } -/* Scheduler consumer for check-update. */ -static const struct cron_consumer check_update_consumer = { - .path = "/ietf-system:system/infix-system:software/check-update", - .sched_leaf = "schedule", - .enabled_leaf = "enabled", - .command = "/usr/sbin/check-update", -}; - int system_sw_rpc_init(struct confd *confd) { int rc = 0; @@ -106,8 +98,6 @@ int system_sw_rpc_init(struct confd *confd) REGISTER_RPC(confd->session, "/infix-system:set-boot-order", infix_system_sw_set_boot_order, NULL, &confd->sub); - schedule_consumer_register(&check_update_consumer); - fail: return rc; } diff --git a/src/confd/src/system.c b/src/confd/src/system.c index b2491dd14..77cae7375 100644 --- a/src/confd/src/system.c +++ b/src/confd/src/system.c @@ -1894,14 +1894,6 @@ int system_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd return SR_ERR_OK; } -/* Scheduler consumer for scheduled-reboot. */ -static const struct cron_consumer reboot_consumer = { - .path = "/ietf-system:system/infix-system:scheduled-reboot", - .sched_leaf = "schedule", - .enabled_leaf = NULL, - .command = "/usr/sbin/reboot", -}; - int system_rpc_init(struct confd *confd) { int rc; @@ -1913,8 +1905,6 @@ int system_rpc_init(struct confd *confd) REGISTER_RPC(confd->session, "/ietf-system:system-shutdown", rpc_exec, "poweroff", &confd->sub); REGISTER_RPC(confd->session, "/ietf-system:set-current-datetime", rpc_set_datetime, NULL, &confd->sub); - schedule_consumer_register(&reboot_consumer); - return SR_ERR_OK; fail: ERROR("init failed: %s", sr_strerror(rc)); diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 62ba37b9d..0fbdd36f2 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -43,7 +43,7 @@ MODULES=( "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" "infix-services@2026-06-17.yang" - "infix-system@2026-09-08.yang" + "infix-system@2026-09-11.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" "infix-ethernet-interface@2026-05-21.yang" @@ -58,5 +58,5 @@ MODULES=( "ieee802-dot1as-gptp@2025-12-10.yang" "infix-ptp@2026-04-07.yang" "ietf-schedule@2026-03-10.yang -e icalendar-recurrence" - "infix-schedule@2026-06-17.yang" + "infix-schedule@2026-09-07.yang" ) diff --git a/src/confd/yang/confd/infix-schedule.yang b/src/confd/yang/confd/infix-schedule.yang index 0eecfbd13..8c120bd34 100644 --- a/src/confd/yang/confd/infix-schedule.yang +++ b/src/confd/yang/confd/infix-schedule.yang @@ -14,6 +14,12 @@ module infix-schedule { contact "kernelkit@googlegroups.com"; description "Infix deviations and augments to ietf-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 "internal"; + } revision 2026-06-17 { description "Initial revision - system scheduling. @@ -131,9 +137,15 @@ module infix-schedule { action of their own; features trigger off a schedule by pointing a schedule-ref leaf at its name."; leaf name { - type string; + type string { + length "1..64"; + pattern '[a-zA-Z0-9][a-zA-Z0-9_.-]*'; + } description - "Unique name identifying this schedule."; + "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."; } leaf enabled { type boolean; diff --git a/src/confd/yang/confd/infix-schedule@2026-06-17.yang b/src/confd/yang/confd/infix-schedule@2026-09-07.yang similarity index 100% rename from src/confd/yang/confd/infix-schedule@2026-06-17.yang rename to src/confd/yang/confd/infix-schedule@2026-09-07.yang diff --git a/src/confd/yang/confd/infix-system-software.yang b/src/confd/yang/confd/infix-system-software.yang index 29afb29f7..fb837f196 100644 --- a/src/confd/yang/confd/infix-system-software.yang +++ b/src/confd/yang/confd/infix-system-software.yang @@ -24,6 +24,15 @@ submodule infix-system-software { contact "kernelkit@googlegroups.com"; description "Software status and upgrade."; + revision 2026-09-07 { + description "Add unattended-update config, triggered from a referenced + schedule. Lift update-url to the shared software container + 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."; + reference "Internal"; + } revision 2026-06-17 { description "Add check-update config, triggered from a referenced schedule"; reference "Internal"; @@ -90,16 +99,49 @@ 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."; + 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."; + } + + 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."; + } + container check-update { description "Policy for automatic software update checks. When 'enabled' and 'schedule' references a schedule, the system - checks the configured URL for a newer release on each occurrence - and logs a notification if one is found."; + checks the configured update-url for a newer release on each + occurrence and logs a notification if one is found."; leaf enabled { type boolean; @@ -114,14 +156,54 @@ submodule infix-system-software { "The schedule whose occurrences trigger an update check. Without a referenced schedule no checks are performed."; } + } - leaf update-url { - type string; - default "https://github.com/kernelkit/infix"; + container unattended-update { + description + "Policy for automatic, unattended software upgrades. + + 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."; + } + + leaf schedule { + type infix-schedule:schedule-ref; + description + "The schedule whose occurrences trigger an unattended upgrade. + Without a referenced schedule no upgrades are performed."; + } + + 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."; + } + enum immediate { + description + "Reboot automatically after a successful install to activate + the new image at once."; + } + } + default manual; description - "Base URL of the update source. The check script appends - /releases/latest and follows the redirect to determine the - latest release tag. Override for customer-specific channels."; + "What to do once a bundle has been installed to the inactive + slot."; } } } diff --git a/src/confd/yang/confd/infix-system-software@2026-06-17.yang b/src/confd/yang/confd/infix-system-software@2026-09-07.yang similarity index 100% rename from src/confd/yang/confd/infix-system-software@2026-06-17.yang rename to src/confd/yang/confd/infix-system-software@2026-09-07.yang diff --git a/src/confd/yang/confd/infix-system.yang b/src/confd/yang/confd/infix-system.yang index c077b0fae..0790b86a5 100644 --- a/src/confd/yang/confd/infix-system.yang +++ b/src/confd/yang/confd/infix-system.yang @@ -32,6 +32,12 @@ module infix-system { contact "kernelkit@googlegroups.com"; description "Infix augments and deviations to ietf-system."; + revision 2026-09-11 { + description "Add unattended-update, a shared software/update-url naming an + RSS/Atom release feed, and allow-prerelease (see the + infix-system-software submodule)."; + reference "internal"; + } revision 2026-09-08 { description "Add /system/advanced, for low-level system customization: - rc.ds: user scripts run once at boot, extracted from the diff --git a/src/confd/yang/confd/infix-system@2026-09-08.yang b/src/confd/yang/confd/infix-system@2026-09-11.yang similarity index 100% rename from src/confd/yang/confd/infix-system@2026-09-08.yang rename to src/confd/yang/confd/infix-system@2026-09-11.yang