Skip to content

Commit 7c8a478

Browse files
committed
Add operational data for scheduled software updates
Neither check-update nor unattended-update left any trace beyond the log and a login notice, so after enabling them the only way to see that they took effect was crontab -l from the shell. The update scripts now record the outcome of each run in a state file that yanger folds into /system-state/software/update: when the feed was last queried, the newest release it offered and whether that is newer than what boots next, and the time and version of the last unattended install. Whether an installed image awaits a reboot is derived from the RAUC slot data, so it also covers manual upgrades. 'show software' prints the configured triggers next to this outcome, and the WebUI software page gets a Software Updates card. The slot table on that page now uses the same date format as the card. Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
1 parent 19adbfa commit 7c8a478

15 files changed

Lines changed: 374 additions & 13 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
f /run/os-update 0666 admin admin
2+
f /run/software-update.json 0666 admin admin
23
f /run/unattended-update.lock 0666 admin admin

‎board/common/rootfs/usr/libexec/infix/update-common‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# Shared helpers for check-update and unattended-update. Sourced, not run;
22
# the caller sets TAG first.
33

4+
# Outcome of the last run, read by yanger for /system-state/software/update.
5+
STATE_FILE=/run/software-update.json
6+
7+
# Apply a jq filter to the state file, extra args go to jq (--arg name value).
8+
# An empty or damaged file starts over from {}.
9+
update_state() {
10+
filter=$1; shift
11+
new=$(jq -n "$@" "(input? // {}) | $filter" "$STATE_FILE") && printf '%s\n' "$new" > "$STATE_FILE"
12+
}
13+
414
# Read the shared update-url (an RSS/Atom release feed) from running-config.
515
# The leaf is mandatory and factory-config supplies it, so an empty result
616
# means the software container is absent -- no update source is configured.
@@ -95,6 +105,25 @@ update_available() {
95105
newer "$LATEST_TAG" "$pending"
96106
}
97107

108+
# Query the feed and record the outcome. Same return values as
109+
# update_probe(); on success UPDATE_AVAILABLE tells if the latest release
110+
# should be applied.
111+
update_check() {
112+
update_probe
113+
rc=$?
114+
case $rc in
115+
0)
116+
update_available && UPDATE_AVAILABLE=true || UPDATE_AVAILABLE=false
117+
update_state '. + {"last-check": (now | todate), latest: $tag, "release-url": $url, available: $avail}' \
118+
--arg tag "$LATEST_TAG" --arg url "$(update_release_url)" --argjson avail "$UPDATE_AVAILABLE"
119+
;;
120+
2)
121+
update_state '.["last-check"] = (now | todate) | del(.latest, .["release-url"], .available)'
122+
;;
123+
esac
124+
return $rc
125+
}
126+
98127
# Print the release page URL of the latest release, for operator-facing logs.
99128
update_release_url() {
100129
[ -n "$RELEASE_BASE" ] || return 0

‎board/common/rootfs/usr/sbin/check-update‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ TAG=os-update
77

88
. /usr/libexec/infix/update-common
99

10-
update_probe
10+
update_check
1111
rc=$?
1212
if [ $rc -eq 1 ]; then
1313
exit 1
@@ -21,7 +21,7 @@ if [ $rc -eq 3 ]; then
2121
exit 0
2222
fi
2323

24-
if update_available; then
24+
if [ "$UPDATE_AVAILABLE" = true ]; then
2525
RELEASE_URL=$(update_release_url)
2626
MSG="Software update available: ${LATEST_TAG}, running ${VERSION} (see ${RELEASE_URL})"
2727
logger -t "$TAG" "$MSG"

‎board/common/rootfs/usr/sbin/unattended-update‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ if ! flock -n 9; then
3535
exit 0
3636
fi
3737

38-
update_probe
38+
update_check
3939
rc=$?
4040
if [ $rc -eq 1 ]; then
4141
exit 1
@@ -49,7 +49,7 @@ if [ $rc -eq 3 ]; then
4949
exit 0
5050
fi
5151

52-
if ! update_available; then
52+
if [ "$UPDATE_AVAILABLE" != true ]; then
5353
logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST_TAG)"
5454
exit 0
5555
fi
@@ -66,6 +66,8 @@ if ! rauc install "$BUNDLE_URL"; then
6666
logger -t "$TAG" "ERROR: installation of ${LATEST_TAG} failed"
6767
exit 1
6868
fi
69+
update_state '. + {"last-install": (now | todate), installed: $tag, available: false}' \
70+
--arg tag "$LATEST_TAG"
6971

7072
POLICY=$(read_reboot_policy)
7173
if [ "$POLICY" = immediate ]; then

‎doc/schedule.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ admin@example:~$ crontab -l
140140

141141
An empty crontab means nothing is scheduled. Check that the consuming
142142
feature is enabled, that it names the schedule correctly, and that the
143-
schedule itself is enabled.
143+
schedule itself is enabled. For update checks and unattended updates,
144+
`show software` reports the trigger and the outcome of the last occurrence.
144145

145146
> [!NOTE]
146147
> The crontab is generated and must not be edited by hand. It is

‎doc/upgrade.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,26 @@ unattended-update: Installed v26.08.1; reboot to activate the new image
405405
| `Skipped: failed to query latest release …` | Feed unreachable |
406406
| `Another update is already in progress …` | Previous occurrence still running|
407407

408-
`show software` reports installation state, slot contents and the boot
409-
order, both during and after the install.
408+
`show software` reports the configured triggers and the outcome of the
409+
last occurrence, next to the slot contents and boot order:
410+
411+
<pre class="cli"><code>admin@example:/> <b>show software</b>
412+
Boot order : primary secondary net
413+
414+
NAME STATE VERSION DATE
415+
primary booted v26.08.1 2026-09-28T03:02:41Z
416+
secondary inactive v26.08.0 2026-08-30T03:02:12Z
417+
418+
Software updates
419+
Source : https://github.com/kernelkit/infix/releases.atom
420+
Check : nightly (daily at 03:00)
421+
Unattended : nightly (daily at 03:00), reboot manual
422+
Last check : 2026-09-30T03:00:12Z, latest v26.08.1, up to date
423+
Last install : 2026-09-28T03:02:41Z, installed v26.08.1, reboot pending
424+
</code></pre>
425+
426+
The same data is available under `/system-state/software/update` in the
427+
operational datastore, and on the Software page of the WebUI.
410428

411429
> [!TIP]
412430
> A system running a development build has no comparable version number

‎src/bin/show/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ def software(args: List[str]) -> None:
245245
print(json.dumps(data, indent=2))
246246
return
247247
if len(args) == 0 or not args[0]: # Treat "" as no arg.
248+
# Trigger settings for the software updates section.
249+
for xpath in ("/ietf-system:system/infix-system:software",
250+
"/ietf-system:system/infix-schedule:schedules"):
251+
cfg = get_json(xpath, "running-config", quiet=True)
252+
data.setdefault("ietf-system:system", {}).update(cfg.get("ietf-system:system", {}))
248253
cli_pretty(data, "show-software")
249254
elif len(args) == 1:
250255
name = args[0]

‎src/confd/yang/confd/infix-system-software.yang‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ submodule infix-system-software {
3333
Add allow-prerelease.
3434
Use the infix-schedule scheduled-feature grouping for
3535
check-update and unattended-update, enabled now defaults
36-
to true.";
36+
to true.
37+
Add the update container to the operational data: outcome
38+
of the last update check or unattended update.";
3739
reference "Internal";
3840
}
3941
revision 2026-06-17 {
@@ -232,6 +234,52 @@ submodule infix-system-software {
232234
// }
233235
}
234236

237+
container update {
238+
description
239+
"Outcome of the last scheduled update check or unattended update,
240+
see the check-update and unattended-update settings.";
241+
242+
leaf last-check {
243+
type yang:date-and-time;
244+
description "When the release feed was last queried.";
245+
}
246+
247+
leaf latest {
248+
type string;
249+
description
250+
"Newest release the feed offered at the last check, after the
251+
pre-release filter. Absent when the feed could not be read.";
252+
}
253+
254+
leaf available {
255+
type boolean;
256+
description
257+
"True when latest is newer than the version that boots next.";
258+
}
259+
260+
leaf release-url {
261+
type string;
262+
description "Release page of the latest release.";
263+
}
264+
265+
leaf last-install {
266+
type yang:date-and-time;
267+
description "When an unattended update last installed a bundle.";
268+
}
269+
270+
leaf installed {
271+
type string;
272+
description "Version installed by the last unattended update.";
273+
}
274+
275+
leaf reboot-pending {
276+
type boolean;
277+
description
278+
"True when the slot that boots next holds another version than
279+
the running one, i.e., an installed update awaits a reboot.";
280+
}
281+
}
282+
235283
list slot {
236284
key "name";
237285
description

‎src/statd/python/cli_pretty/cli_pretty.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2218,6 +2218,75 @@ def show_software(json, name):
22182218
print(Decore.yellow(f"Note: the {slot.name} partition has {slot.version or 'unknown'},"
22192219
f" this is {booted.version}. Use 'upgrade' to update it."))
22202220

2221+
show_software_update(json, software.get("update", {}))
2222+
2223+
2224+
def describe_recurrence(rec):
2225+
"""Short form of an ietf-schedule recurrence, e.g. 'daily at 03:30'"""
2226+
units = {"minutely": "minute", "hourly": "hour", "daily": "day",
2227+
"weekly": "week", "monthly": "month", "yearly": "year"}
2228+
freq = rec.get('frequency', '').split(':')[-1]
2229+
interval = rec.get('interval', 1)
2230+
text = freq if interval == 1 else f"every {interval} {units.get(freq, freq)}s"
2231+
2232+
days = ",".join(d.get('weekday', '')[:3] for d in rec.get('byday', []))
2233+
if days:
2234+
text += f" on {days}"
2235+
2236+
hours = rec.get('byhour')
2237+
if hours:
2238+
minutes = rec.get('byminute', [0])
2239+
text += " at " + ",".join(f"{h:02}:{m:02}" for h in hours for m in minutes)
2240+
2241+
return text
2242+
2243+
2244+
def show_software_update(json, state):
2245+
"""Scheduled update checks and unattended updates: triggers from the
2246+
running configuration, outcome of the last run from operational data"""
2247+
cfg = get_json_data({}, json, 'ietf-system:system', 'infix-system:software')
2248+
if not cfg:
2249+
return
2250+
schedules = get_json_data([], json, 'ietf-system:system',
2251+
'infix-schedule:schedules', 'schedule')
2252+
2253+
def trigger(feature):
2254+
name = feature.get('schedule')
2255+
if not name:
2256+
return "not configured"
2257+
if not feature.get('enabled', True):
2258+
return f"{name}, paused"
2259+
sched = next((s for s in schedules if s.get('name') == name), {})
2260+
text = f"{name} ({describe_recurrence(sched.get('recurrence', {}))})"
2261+
if not sched.get('enabled', True):
2262+
text += ", schedule disabled"
2263+
return text
2264+
2265+
unattended = cfg.get('unattended-update', {})
2266+
print()
2267+
print("Software updates")
2268+
print(f" Source : {cfg.get('update-url', 'none')}")
2269+
print(f" Check : {trigger(cfg.get('check-update', {}))}")
2270+
text = trigger(unattended)
2271+
if unattended.get('schedule'):
2272+
text += f", reboot {unattended.get('reboot', 'manual')}"
2273+
print(f" Unattended : {text}")
2274+
2275+
if state.get('last-check'):
2276+
latest = state.get('latest')
2277+
if not latest:
2278+
result = "feed unreachable"
2279+
elif state.get('available'):
2280+
result = f"{latest} available"
2281+
else:
2282+
result = f"latest {latest}, up to date"
2283+
print(f" Last check : {state['last-check']}, {result}")
2284+
if state.get('last-install'):
2285+
result = f"installed {state.get('installed', 'unknown')}"
2286+
if state.get('reboot-pending'):
2287+
result += ", reboot pending"
2288+
print(f" Last install : {state['last-install']}, {result}")
2289+
22212290

22222291
def show_services(json):
22232292
if not json.get("ietf-system:system-state", "infix-system:services"):

‎src/statd/python/yanger/ietf_system.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,30 @@ def add_services(out):
226226

227227
insert(out, "infix-system:services", "service", services)
228228

229+
def software_update(data):
230+
"""Outcome of the last update check or unattended update, recorded by
231+
the update scripts, plus whether an installed image awaits a reboot."""
232+
keys = ("last-check", "latest", "available", "release-url",
233+
"last-install", "installed")
234+
state = HOST.read_json("/run/software-update.json", {})
235+
update = {k: state[k] for k in keys if k in state}
236+
237+
if data:
238+
slots = {n: s for entry in data.get("slots", []) for n, s in entry.items()}
239+
240+
def version(slot):
241+
return slot.get("slot_status", {}).get("bundle", {}).get("version")
242+
243+
running = next((version(s) for s in slots.values()
244+
if s.get("bootname") == data.get("booted")), None)
245+
pending = version(slots.get(data.get("boot_primary"), {}))
246+
update["reboot-pending"] = bool(running and pending and running != pending)
247+
248+
return update
249+
229250
def add_software(out):
230251
software = {}
252+
data = None
231253
try:
232254
data = HOST.run_json(["rauc", "status", "--detailed", "--output-format=json"], {})
233255
software["compatible"] = data.get("compatible", "")
@@ -254,6 +276,10 @@ def add_software(out):
254276
installer["progress"] = progress
255277
software["installer"] = installer
256278

279+
update = software_update(data)
280+
if update:
281+
software["update"] = update
282+
257283
insert(out, "infix-system:software", software)
258284

259285
def add_hostname(out):

0 commit comments

Comments
 (0)