Skip to content
Closed
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
1 change: 1 addition & 0 deletions board/common/rootfs/etc/tmpfiles.d/os-schedule.conf
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
f /run/os-update 0666 admin admin
f /run/unattended-update.lock 0666 admin admin
96 changes: 96 additions & 0 deletions board/common/rootfs/usr/libexec/infix/update-common
Original file line number Diff line number Diff line change
@@ -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/<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 <link> 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"
}
45 changes: 9 additions & 36 deletions board/common/rootfs/usr/sbin/check-update
Original file line number Diff line number Diff line change
Expand Up @@ -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://github.com/ghapi/repos/org/repo
REPO=$(echo "$UPDATE_URL" | sed 's|https://github.com/||; s|/*$||')
API_URL="https://github.com/ghapi/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"
Expand Down
74 changes: 74 additions & 0 deletions board/common/rootfs/usr/sbin/unattended-update
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
saba8814 marked this conversation as resolved.
sync
sleep 2
/usr/sbin/reboot
else
logger -t "$TAG" "Installed ${LATEST_TAG}; reboot to activate the new image"
fi
4 changes: 4 additions & 0 deletions doc/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------------
Expand Down
149 changes: 149 additions & 0 deletions doc/schedule.md
Original file line number Diff line number Diff line change
@@ -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.

<pre class="cli"><code>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit system schedule nightly</b>
admin@example:/config/system/schedule/nightly/> <b>set description "Nightly maintenance window"</b>
admin@example:/config/system/schedule/nightly/> <b>set recurrence frequency daily</b>
admin@example:/config/system/schedule/nightly/> <b>set recurrence byhour 3</b>
admin@example:/config/system/schedule/nightly/> <b>set recurrence byminute 30</b>
admin@example:/config/system/schedule/nightly/> <b>leave</b>
</code></pre>

**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.

<pre class="cli"><code>admin@example:/> <b>configure</b>
admin@example:/config/> <b>set system scheduled-reboot schedule nightly</b>
admin@example:/config/> <b>leave</b>
</code></pre>


## 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
Loading