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 e40048e0c..b4d41a49d 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -89,6 +89,10 @@ All notable changes to the project are documented in this file. - The CLI `copy` and `remove` commands now also accept files in `/var/lib`, `/var/tmp`, and `/tmp`. Files written there are world-readable. The `.cfg`extension is only added for files in `/cfg` +- 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 e9a0bd2eb..7456fd382 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,14 +52,15 @@ nav: - SNMP: snmp.md - TFTP Server: tftp.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 ceddad244..0b819c04a 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 8f58ed600..bffa3c359 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 ad1b70749..df247420a 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 d3b49ef7f..12ff3e983 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-09-18.yang" - "infix-system@2026-09-11.yang" + "infix-system@2026-09-22.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 c670fe27c..c3415ee11 100644 --- a/src/confd/yang/confd/infix-system.yang +++ b/src/confd/yang/confd/infix-system.yang @@ -38,10 +38,18 @@ module infix-system { contact "kernelkit@googlegroups.com"; description "Infix augments and deviations to ietf-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)."; + reference "internal"; + } + revision 2026-09-11 { description "Add support-collect RPC, returns a support archive inline."; 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-11.yang b/src/confd/yang/confd/infix-system@2026-09-22.yang similarity index 100% rename from src/confd/yang/confd/infix-system@2026-09-11.yang rename to src/confd/yang/confd/infix-system@2026-09-22.yang