Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 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 "<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"
}
41 changes: 7 additions & 34 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
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"
Expand Down
85 changes: 85 additions & 0 deletions board/common/rootfs/usr/sbin/unattended-update
Original file line number Diff line number Diff line change
@@ -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
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
40 changes: 40 additions & 0 deletions src/confd/src/core.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

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
7 changes: 7 additions & 0 deletions src/confd/src/schedule.c
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
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
2 changes: 1 addition & 1 deletion src/confd/yang/confd.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading