Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions board/common/post-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
74 changes: 74 additions & 0 deletions board/common/rootfs/usr/libexec/infix/update-common
Original file line number Diff line number Diff line change
@@ -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 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)

# 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 -sSfL --max-time 10 "$API_URL" 2>/dev/null) || return 2
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 "<IMAGE_ID>-<version>.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 "<IMAGE_ID>-<version>/" directory holding "<IMAGE_ID>-v<version>.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"
}
43 changes: 8 additions & 35 deletions board/common/rootfs/usr/sbin/check-update
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 ${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"
Expand Down
90 changes: 90 additions & 0 deletions board/common/rootfs/usr/sbin/unattended-update
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/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. Open the lock
# explicitly first: a missing or unwritable scratch dir 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; is $SCRATCH present and writable?"
exit 1
fi
exec 9>"$LOCKFILE"
if ! flock -n 9; then
logger -t "$TAG" "Another update is already in progress, skipping"
exit 0
fi
Comment thread
saba8814 marked this conversation as resolved.

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 ${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}"
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
5 changes: 5 additions & 0 deletions board/common/xattrs
Original file line number Diff line number Diff line change
Expand Up @@ -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 - - - - -
8 changes: 8 additions & 0 deletions doc/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------------
Expand Down
3 changes: 2 additions & 1 deletion src/confd/configure.ac
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/confd/share/migrate/1.10/10-software-update-url.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
# Move software/check-update/update-url to the shared software/update-url.
#
# The update-source URL was lifted out of the check-update container so that
# check-update and unattended-update share a single setting. Relocate any
# configured value to the new location and drop the old leaf; configs that
# never set it are left untouched.

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
' "$file" > "$temp" && mv "$temp" "$file"
2 changes: 2 additions & 0 deletions src/confd/share/migrate/1.10/Makefile.am
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
migratedir = $(pkgdatadir)/migrate/1.10
dist_migrate_DATA = 10-software-update-url.sh
2 changes: 1 addition & 1 deletion src/confd/share/migrate/Makefile.am
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions src/confd/src/core.c
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,45 @@ 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;

/* Safe to interpolate: infix-schedule constrains the schedule
* name to a bounded identifier (no quotes, length 1..64), so it
* neither breaks the XPath literal nor overflows xpath[]. */
snprintf(xpath, sizeof(xpath),
"/ietf-system:system/infix-schedule:schedules/schedule[name='%s']", name);
result = add_dependencies(diff, xpath, name);
Comment thread
saba8814 marked this conversation as resolved.
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;
Expand All @@ -529,6 +568,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;
}

Expand Down
1 change: 1 addition & 0 deletions src/confd/src/core.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
8 changes: 8 additions & 0 deletions src/confd/src/schedule.c
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ 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)
{
if (count)
*count = consumer_count;
return consumers;
Comment thread
saba8814 marked this conversation as resolved.
Outdated
}

/*
* Convert ietf-schedule recurrence to a 5-field cron expression.
*
Expand Down
9 changes: 9 additions & 0 deletions src/confd/src/system-software.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading