diff --git a/board/common/post-build.sh b/board/common/post-build.sh index 305f77ad9..35dbc2276 100755 --- a/board/common/post-build.sh +++ b/board/common/post-build.sh @@ -102,6 +102,9 @@ fi # Drop Buildroot default pam_lastlog.so from login chain sed -i '/^[^#]*pam_lastlog.so/s/^/# /' "$TARGET_DIR/etc/pam.d/login" +# Scratch dir for unattended-update's scheduled job (run as 'admin') to stage large downloads +mkdir -p "$TARGET_DIR/var/lib/misc/unattended-update" + # Allow bash to be login shells, it is added automatically when selected # in menuyconfig, but not when BusyBox provides a symlink (for ash). # The /bin/{true,false} are old UNIX beart means of disabling a user. 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..74d209081 --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/update-common @@ -0,0 +1,74 @@ +# Shared helpers for check-update and unattended-update. Sourced, not run; +# the caller sets TAG first. + +# Read the shared update-url from running-config, fall back to upstream. +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' +} + +# 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 release API unreachable. +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) + + # Derive the GitHub release API URL from the configured update URL. + # 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" + + RELEASE_JSON=$(curl -sSL --max-time 10 "$API_URL" 2>/dev/null) + LATEST_TAG=$(printf '%s' "$RELEASE_JSON" | jq -r '.tag_name // empty') + if [ -z "$LATEST_TAG" ]; then + return 2 + fi + LATEST=${LATEST_TAG#v} + 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 download URL of this platform's release tarball, or nothing if it +# is missing. Releases ship no standalone bundle; the RAUC .pkg is packed in +# a per-platform tarball named "-.tar.gz", e.g. +# "infix-x86_64-26.06.0.tar.gz". +update_tarball_url() { + name="${IMAGE_ID}-${LATEST}.tar.gz" + printf '%s' "$RELEASE_JSON" \ + | jq -r --arg n "$name" \ + '.assets[]? | select(.name == $n) | .browser_download_url' \ + | head -1 +} + +# Print the path of the RAUC bundle inside that tarball. The archive unpacks +# to a "-/" directory holding "-v.pkg" +# -- note the 'v' on the bundle name but not on the directory. +update_tarball_member() { + printf '%s-%s/%s-v%s.pkg' "$IMAGE_ID" "$LATEST" "$IMAGE_ID" "$LATEST" +} diff --git a/board/common/rootfs/usr/sbin/check-update b/board/common/rootfs/usr/sbin/check-update index f380bf392..ba25e1211 100755 --- a/board/common/rootfs/usr/sbin/check-update +++ b/board/common/rootfs/usr/sbin/check-update @@ -5,46 +5,19 @@ 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 +if [ $rc -eq 2 ]; then logger -p daemon.info -t "$TAG" "Update check skipped: could not reach ${API_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 +if update_available; then RELEASE_URL="${UPDATE_URL}/releases/${LATEST_TAG}" MSG="Software update available: ${LATEST_TAG}, running ${VERSION} (see ${RELEASE_URL})" logger -t "$TAG" "$MSG" diff --git a/board/common/rootfs/usr/sbin/unattended-update b/board/common/rootfs/usr/sbin/unattended-update new file mode 100755 index 000000000..42701604b --- /dev/null +++ b/board/common/rootfs/usr/sbin/unattended-update @@ -0,0 +1,85 @@ +#!/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 +# Scratch dir is used for the lock and the download bundle +SCRATCH=/var/lib/misc/unattended-update +LOCKFILE=$SCRATCH/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. +exec 9>"$LOCKFILE" +if ! flock -n 9; then + logger -t "$TAG" "An 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: could not reach ${API_URL}" + exit 0 +fi + +if ! update_available; then + logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST)" + exit 0 +fi + +TARBALL_URL=$(update_tarball_url) +if [ -z "$TARBALL_URL" ]; then + logger -t "$TAG" "Update ${LATEST_TAG} found, but no tarball '${IMAGE_ID}-${LATEST}.tar.gz' in release; skipping" + exit 1 +fi +MEMBER=$(update_tarball_member) + +# Stage the bundle next to the lock. Clean up on any exit. +PKG=$SCRATCH/bundle.pkg +trap 'rm -f "$PKG"' EXIT + +# The release ships the .pkg inside a tarball, so stream it through tar and +# write out only the bundle member -- we never store the full archive. +logger -t "$TAG" "Downloading ${LATEST_TAG} bundle from ${TARBALL_URL}" +set -o pipefail +if ! curl -sSfL --max-time 1800 "$TARBALL_URL" | tar -xzOf - "$MEMBER" > "$PKG"; then + logger -t "$TAG" "ERROR: failed to download or extract '${MEMBER}' from ${TARBALL_URL}" + exit 1 +fi +if [ ! -s "$PKG" ]; then + logger -t "$TAG" "ERROR: extracted bundle is empty; '${MEMBER}' not found in tarball?" + exit 1 +fi + +logger -t "$TAG" "Installing ${LATEST_TAG} (running ${VERSION})" +if ! rauc install "$PKG"; then + logger -t "$TAG" "ERROR: installation of ${LATEST_TAG} failed" + exit 1 +fi +rm -f "$PKG" + +POLICY=$(read_reboot_policy) +if [ "$POLICY" = immediate ]; then + logger -t "$TAG" "Installed ${LATEST_TAG}; reboot policy 'immediate', rebooting to activate" + sleep 2 + /usr/sbin/reboot +else + logger -t "$TAG" "Installed ${LATEST_TAG}; reboot to activate the new image" +fi diff --git a/board/common/xattrs b/board/common/xattrs index 89db8732e..ec36d37bc 100644 --- a/board/common/xattrs +++ b/board/common/xattrs @@ -3,3 +3,8 @@ /sbin/factory f 4750 root wheel - - - - - /var/lib/avahi-autoipd d 0755 avahi avahi - - - - - + +# Scratch area for scheduled jobs (run as 'admin', a wheel member) to stage +# downloads, e.g. unattended-update's install bundle. setgid so staged files +# inherit the wheel group; no access for other. +/var/lib/misc/unattended-update d 2770 root wheel - - - - - diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 1611a361c..36adf55dd 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -2,6 +2,14 @@ Change Log ========== All notable changes to the project are documented in this file. +[v26.08.0][UNRELEASED] - +------------------------- +### Changes +- Add support for unattended software upgrades, letting a unit fetch a newer release on a + schedule and install it to the inactive partition on its own, then either + reboot to activate it or leave it staged for the next reboot + +### Fixes [v26.06.0][] - 2026-07-01 ------------------------- diff --git a/src/confd/src/core.c b/src/confd/src/core.c index a0569b688..f3585659b 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -505,6 +505,42 @@ 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 **consumers; + size_t i, count; + + consumers = schedule_consumers(&count); + for (i = 0; i < count; i++) { + const struct cron_consumer *c = consumers[i]; + struct lyd_node *dnode, *cnode; + const char *name; + char xpath[256]; + + dnode = lydx_get_xpathf(*diff, "%s", c->path); + if (!dnode) + continue; + + cnode = lydx_get_xpathf(config, "%s", c->path); + name = cnode ? lydx_get_cattr(cnode, c->sched_leaf) : NULL; + if (!name) + name = lydx_get_cattr(dnode, c->sched_leaf); + 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 consumer %s", name, c->path); + return result; + } + } + + return result; +} + static confd_dependency_t handle_dependencies(struct lyd_node **diff, struct lyd_node *config) { confd_dependency_t result; @@ -529,6 +565,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 9d0273e83..dac062997 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -224,6 +224,7 @@ struct cron_consumer { const char *command; /* what crond runs on each occurrence */ }; int schedule_consumer_register(const struct cron_consumer *consumer); +const struct cron_consumer **schedule_consumers(size_t *count); 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..323ddf312 100644 --- a/src/confd/src/schedule.c +++ b/src/confd/src/schedule.c @@ -36,6 +36,13 @@ int schedule_consumer_register(const struct cron_consumer *consumer) return 0; } +/* Read-only view of the registered consumers, for the dependency tracker. */ +const struct cron_consumer **schedule_consumers(size_t *count) +{ + *count = consumer_count; + return consumers; +} + /* * Convert ietf-schedule recurrence to a 5-field cron expression. * 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 e740c6cd4..d89bd8e59 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-06-17.yang" + "infix-system@2026-07-02.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" "infix-ethernet-interface@2026-05-21.yang" diff --git a/src/confd/yang/confd/infix-system-software.yang b/src/confd/yang/confd/infix-system-software.yang index 29afb29f7..101ab856e 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-07-02 { + description "Add unattended-update config, triggered from a referenced + schedule, and lift update-url to the shared software + container so check-update and unattended-update use one + update source."; + reference "Internal"; + } revision 2026-06-17 { description "Add check-update config, triggered from a referenced schedule"; reference "Internal"; @@ -93,13 +100,24 @@ submodule infix-system-software { description "Software management configuration."; + leaf update-url { + type string; + default "https://github.com/kernelkit/infix"; + description + "Base URL of the update source, shared by check-update and + unattended-update. The latest release tag is resolved from + /releases/latest, and the per-platform bundle asset + is fetched from that release. Override for customer-specific + channels."; + } + 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 +132,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-07-02.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-07-02.yang diff --git a/src/confd/yang/confd/infix-system.yang b/src/confd/yang/confd/infix-system.yang index b79a2fcad..64663074d 100644 --- a/src/confd/yang/confd/infix-system.yang +++ b/src/confd/yang/confd/infix-system.yang @@ -32,6 +32,11 @@ module infix-system { contact "kernelkit@googlegroups.com"; description "Infix augments and deviations to ietf-system."; + revision 2026-07-02 { + description "Add unattended-update and shared software/update-url (see the + infix-system-software submodule)."; + reference "internal"; + } revision 2026-06-17 { description "Add scheduled-reboot, triggered from a referenced schedule."; reference "internal"; diff --git a/src/confd/yang/confd/infix-system@2026-06-17.yang b/src/confd/yang/confd/infix-system@2026-07-02.yang similarity index 100% rename from src/confd/yang/confd/infix-system@2026-06-17.yang rename to src/confd/yang/confd/infix-system@2026-07-02.yang