Add Software Update, across packages, applications and firmware
Three sources that fail independently, so they are counted and applied separately: a flatpak mirror being down says nothing about whether a kernel security fix is waiting. Blending them into one number would hide exactly the case that matters. Checking costs about nine seconds, which is too long to spend every time a page opens, so the page opens on the last result and says when it was taken. A first visit with nothing cached goes and finds out rather than showing a confident "up to date" it has no basis for. Installing packages takes a snapshot first, named after what is about to happen, so Snapshots shows "before 32 package updates" rather than a timestamp. Best effort: a machine without snapper still updates, because an update that refuses to run when a nicety fails would be worse than one without a restore point. Automatic updates cover applications only, through a Panama-owned user timer running daily with a randomized delay. Packages still ask, and dnf-automatic is reported as absent rather than offered, because installing software is not a settings action. Health gained a check, and that is where the bug was: it first returned status "degraded", which is not in the doctor's vocabulary of ok, warning, error and unconfigured. It was counted as nothing at all while the summary still said healthy -- the same silent no-op this codebase keeps relearning. A contract now asserts every status a check can return is one the doctor counts, and the doctor's own contract knows about the new check rather than failing on its arrival. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -114,7 +114,7 @@ run_doctor() {
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
|
||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.updates\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||
|
||||
assert_schema_and_redaction() {
|
||||
local snapshot="$1"
|
||||
@@ -123,8 +123,8 @@ assert_schema_and_redaction() {
|
||||
and (.summary.status | IN("healthy", "warning", "error"))
|
||||
and (.context.session | IN("hyprland", "other"))
|
||||
and (.context.versions | type == "array")
|
||||
and ([.checks[].id] | length == 26)
|
||||
and ([.checks[].id] | unique | length == 26)
|
||||
and ([.checks[].id] | length == 27)
|
||||
and ([.checks[].id] | unique | length == 27)
|
||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
||||
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
||||
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Updates come from three places that fail independently, and the page must not
|
||||
# claim to know more than it checked.
|
||||
#
|
||||
# The rules:
|
||||
#
|
||||
# 1. Every status a health check can return must be one the doctor counts.
|
||||
# "degraded" is not in its vocabulary; a check returning it was counted as
|
||||
# nothing at all while the summary still said healthy. That is the silent
|
||||
# no-op this whole codebase keeps relearning.
|
||||
# 2. A count nobody verified is not a count. "Up to date" may only be said
|
||||
# after a check actually ran.
|
||||
# 3. Applying packages takes a restore point first, and a failure to take one
|
||||
# must not block the update.
|
||||
# 4. Checking is separate from opening. A nine-second scan on every page open
|
||||
# would make Settings feel broken.
|
||||
#
|
||||
# Read-only: it reads update state and never installs anything.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-updates"
|
||||
service="$repo_dir/config/dot/quickshell/services/Updates.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/UpdatesPage.qml"
|
||||
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor"
|
||||
|
||||
fail() {
|
||||
printf 'updates contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$helper" "$service" "$page" "$doctor"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-updates is not executable'
|
||||
|
||||
# ── 1. Every health status is one the doctor counts ─────────────────────────
|
||||
statuses="$(sed -n 's/^Status = Literal\[\(.*\)\]$/\1/p' "$doctor" | tr -d '" ' | tr ',' '\n' | grep -v '^$')"
|
||||
[[ -n "$statuses" ]] || fail 'could not read the doctor status vocabulary'
|
||||
while read -r used; do
|
||||
[[ -n "$used" ]] || continue
|
||||
grep -qx "$used" <<<"$statuses" \
|
||||
|| fail "a check reports status \"$used\", which the doctor does not count -- it would be invisible in the summary"
|
||||
done < <(grep -oE 'Check\("[a-z.]+", "[a-z-]+", "[^"]+", "[a-z]+"' "$doctor" \
|
||||
| sed -E 's/.*, "([a-z]+)"$/\1/' | sort -u)
|
||||
|
||||
# ── 2. No confident answer without a check ──────────────────────────────────
|
||||
grep -q 'everChecked' "$service" \
|
||||
|| fail 'the service cannot tell "no updates" from "never looked"'
|
||||
grep -q 'Not checked yet' "$service" \
|
||||
|| fail 'a machine that has never checked is reported as up to date'
|
||||
|
||||
# ── 3. Packages get a restore point, best effort ────────────────────────────
|
||||
apply_body="$(sed -n '/^def apply/,/^def /p' "$helper")"
|
||||
grep -q 'take_restore_point' <<<"$apply_body" \
|
||||
|| fail 'installing packages does not take a snapshot first'
|
||||
restore_body="$(sed -n '/^def take_restore_point/,/^def /p' "$helper")"
|
||||
grep -q 'return ""' <<<"$restore_body" \
|
||||
|| fail 'a failed snapshot has no non-fatal path, so it would block the update'
|
||||
grep -qE 'raise BoundaryError' <<<"$restore_body" \
|
||||
&& fail 'a failed snapshot aborts the update, which is worse than an update without a restore point'
|
||||
|
||||
# Firmware and applications must NOT take a system snapshot: neither changes
|
||||
# the system tree, and a restore point that restores nothing is noise.
|
||||
grep -qE 'take_restore_point.*firmware|firmware.*take_restore_point' <<<"$apply_body" \
|
||||
&& fail 'firmware updates take a system snapshot, which would restore nothing'
|
||||
|
||||
# ── 4. Opening is not checking ──────────────────────────────────────────────
|
||||
grep -q 'def snapshot' "$helper" || fail 'there is no cheap read'
|
||||
snapshot_body="$(sed -n '/^def snapshot/,/^def /p' "$helper")"
|
||||
grep -qE 'dnf_updates\(\)|flatpak_updates\(\)|firmware_updates\(\)' <<<"$snapshot_body" \
|
||||
&& fail 'the cheap read runs the expensive scan, so every page open would wait on the network'
|
||||
grep -q 'read_cache()' <<<"$snapshot_body" \
|
||||
|| fail 'the cheap read does not use the cached result'
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { printf 'updates contract: SKIP (no jq)\n'; exit 0; }
|
||||
|
||||
# ── The snapshot is fast and complete ───────────────────────────────────────
|
||||
started="$(date +%s)"
|
||||
state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
|
||||
elapsed=$(( $(date +%s) - started ))
|
||||
(( elapsed <= 5 )) || fail "the cheap read took ${elapsed}s; it is supposed to be instant"
|
||||
|
||||
jq -e '(.dnf | type == "object") and (.flatpak | type == "object") and (.firmware | type == "object")' \
|
||||
<<<"$state" >/dev/null || fail 'the snapshot is missing one of the three sources'
|
||||
jq -e '.kernel | has("running") and has("newestInstalled") and has("rebootNeeded")' <<<"$state" >/dev/null \
|
||||
|| fail 'the kernel state is incomplete'
|
||||
jq -e '.automatic | has("flatpakEnabled")' <<<"$state" >/dev/null \
|
||||
|| fail 'automatic update state is missing'
|
||||
|
||||
# The reboot signal must be derived, not guessed.
|
||||
jq -e '.kernel.rebootNeeded == (.kernel.running != .kernel.newestInstalled)' <<<"$state" >/dev/null \
|
||||
|| fail 'the reboot signal does not follow from the running and installed kernels'
|
||||
|
||||
# ── Refusals ────────────────────────────────────────────────────────────────
|
||||
[[ -n "$("$helper" apply nonsense 2>/dev/null | jq -r '.error // ""')" ]] \
|
||||
|| fail 'an unknown update source was accepted'
|
||||
[[ -n "$("$helper" bogus 2>/dev/null | jq -r '.error // ""')" ]] \
|
||||
|| fail 'an unknown command was accepted'
|
||||
|
||||
printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \
|
||||
"$(jq -r '.dnf.count // 0' <<<"$state")" \
|
||||
"$(jq -r '.flatpak.count // 0' <<<"$state")" \
|
||||
"$(jq -r '.firmware.count // 0' <<<"$state")" \
|
||||
"$(jq -r '.kernel.rebootNeeded' <<<"$state")"
|
||||
Reference in New Issue
Block a user