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..4565c9bc3 --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/update-common @@ -0,0 +1,96 @@ +# 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. +update_read_url() { + url=$(copy running-config \ + -x '/ietf-system:system/infix-system:software/update-url' \ + 2>/dev/null \ + | jq -r '.. | objects | ."update-url"? // empty') + [ -n "$url" ] && printf '%s' "$url" \ + || printf 'https://github.com/kernelkit/infix/releases.atom' +} + +# 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. +update_probe() { + if [ ! -f /etc/os-release ]; then + logger -t "$TAG" "ERROR: /etc/os-release not found" + return 1 + fi + . /etc/os-release + + # Dev/dirty builds have no comparable semver -- always treat as upgradable. + IS_RELEASE=true + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then + IS_RELEASE=false + fi + + UPDATE_URL=$(update_read_url) + 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 + LATEST=${LATEST_TAG#v} + + RELEASE_BASE=$(printf '%s\n' "$hrefs" | grep -E "/${LATEST_TAG}\$" | head -1 \ + | sed "s|/releases/tag/${LATEST_TAG}\$||") + + return 0 +} + +# Should the latest release be applied over the running version? +# Requires update_probe() to have run. Returns 0 if an update is available. +update_available() { + [ "$IS_RELEASE" = false ] && return 0 + newer "$LATEST" "$VERSION" +} + +# 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..0629bed60 100755 --- a/board/common/rootfs/usr/sbin/check-update +++ b/board/common/rootfs/usr/sbin/check-update @@ -5,47 +5,20 @@ 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" - exit 1 -fi -. /etc/os-release +. /usr/libexec/infix/update-common -# 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 +update_probe +rc=$? +if [ $rc -eq 1 ]; then + exit 1 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 2 ]; then + logger -p daemon.info -t "$TAG" "Update check skipped: failed to query latest release from ${UPDATE_URL}" 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" diff --git a/board/common/rootfs/usr/sbin/unattended-update b/board/common/rootfs/usr/sbin/unattended-update new file mode 100755 index 000000000..f4b85e82d --- /dev/null +++ b/board/common/rootfs/usr/sbin/unattended-update @@ -0,0 +1,74 @@ +#!/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 ! update_available; then + logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST)" + 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 cb0094027..a1000cd22 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -32,6 +32,10 @@ All notable changes to the project are documented in this file. value it sets - The CLI `configure` command takes an optional path to start in a sub-context directly, e.g., `configure system authentication` +- 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 [v26.08.0][] - 2026-09-01 ------------------------- diff --git a/doc/schedule.md b/doc/schedule.md new file mode 100644 index 000000000..96115aab4 --- /dev/null +++ b/doc/schedule.md @@ -0,0 +1,149 @@ +# Scheduling + +Some Infix features run on a recurring calendar instead of on demand. +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 + +A schedule needs a name and a recurrence rule. 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 + +Schedules run 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 + +Infix turns each schedule 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 + +Features reference a schedule 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 + +Active schedules become cron jobs owned by the `admin` user. Infix starts +the cron daemon when at least one job is active and stops it when none +are. To confirm a schedule took effect, look at the generated crontab +from the shell: + +```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..7bc786cd2 100644 --- a/doc/upgrade.md +++ b/doc/upgrade.md @@ -194,6 +194,218 @@ 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. Infix can also perform that same install on its own, on a +recurring [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. It defaults to the Infix project's release feed: + +
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. Override `update-url` to follow a fork +or a customer-specific release channel. + +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 + +Infix uses the `/releases/tag/` URL only to derive the base and as 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. + +> [!IMPORTANT] +> Rollback safety is identical to 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. Infix does no health check +> on 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 +684,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 b372c903c..5983a15b9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,7 @@ nav: - Access Control (NACM): nacm.md - Hardware Info & Status: hardware.md - Management: management.md + - Scheduling: schedule.md - Keystore: keystore.md - Syslog Support: syslog.md - Support Data: support.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/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..3589f9d2f --- /dev/null +++ b/src/confd/share/migrate/1.10/10-software-update-url.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Move software/check-update/update-url to the shared software/update-url, and +# convert it from a repository URL to an RSS/Atom release 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. +# Configs that never set it are left untouched; the new default already names +# the feed. + +file=$1 +temp=${file}.tmp + +jq ' + ["ietf-system:system", "infix-system:software", "check-update", "update-url"] as $old + | ["ietf-system:system", "infix-system:software", "update-url"] as $new + | (if getpath($old) != null + then setpath($new; getpath($old)) | delpaths([$old]) + else . end) + | (getpath($new) as $url + | if ($url | type) == "string" and (($url | endswith(".atom")) | not) + then setpath($new; ($url | sub("/+$"; "")) + "/releases.atom") + 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 2c31725db..4e35a775e 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -511,6 +511,56 @@ 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; + struct lyd_node *schedules, *sched; + + schedules = lydx_get_xpathf(config, "/ietf-system:system/infix-schedule:schedules"); + if (!schedules) + return CONFD_DEP_DONE; + + LYX_LIST_FOR_EACH(lyd_child(schedules), sched, "schedule") { + const char *name = lydx_get_cattr(sched, "name"); + struct ly_set *users; + int touched; + char xpath[256]; + uint32_t i; + + if (!name) + continue; + + /* A removed consumer is only in the diff, with its old reference. */ + users = lydx_find_xpathf(*diff, "/ietf-system:system//*[schedule='%s']", name); + touched = users && users->count > 0; + ly_set_free(users, NULL); + + /* One that only toggled 'enabled' keeps its reference in config. */ + users = lydx_find_xpathf(config, "/ietf-system:system//*[schedule='%s']", name); + for (i = 0; users && i < users->count && !touched; i++) { + char *upath = lyd_path(users->dnodes[i], LYD_PATH_STD, NULL, 0); + + if (upath && lydx_get_xpathf(*diff, "%s", upath)) + touched = 1; + free(upath); + } + ly_set_free(users, NULL); + + if (!touched) + 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", name); + return result; + } + } + + return result; +} + static confd_dependency_t handle_dependencies(struct lyd_node **diff, struct lyd_node *config) { confd_dependency_t result; @@ -535,6 +585,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/system-software.c b/src/confd/src/system-software.c index 50a265491..2c813b821 100644 --- a/src/confd/src/system-software.c +++ b/src/confd/src/system-software.c @@ -97,6 +97,14 @@ static const struct cron_consumer check_update_consumer = { .command = "/usr/sbin/check-update", }; +/* Scheduler consumer for unattended-update. */ +static const struct cron_consumer unattended_update_consumer = { + .path = "/ietf-system:system/infix-system:software/unattended-update", + .sched_leaf = "schedule", + .enabled_leaf = "enabled", + .command = "/usr/sbin/unattended-update", +}; + int system_sw_rpc_init(struct confd *confd) { int rc = 0; @@ -107,6 +115,7 @@ int system_sw_rpc_init(struct confd *confd) infix_system_sw_set_boot_order, NULL, &confd->sub); schedule_consumer_register(&check_update_consumer); + schedule_consumer_register(&unattended_update_consumer); fail: return 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..bb47734fe 100644 --- a/src/confd/yang/confd/infix-system-software.yang +++ b/src/confd/yang/confd/infix-system-software.yang @@ -24,6 +24,13 @@ 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, + and make it an RSS/Atom release feed. Add allow-prerelease."; + reference "Internal"; + } revision 2026-06-17 { description "Add check-update config, triggered from a referenced schedule"; reference "Internal"; @@ -93,13 +100,40 @@ submodule infix-system-software { description "Software management configuration."; + leaf update-url { + type string; + default "https://github.com/kernelkit/infix/releases.atom"; + 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. + + 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 +148,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