overlay: atomic CLI tools for v0.7+ (bootc upgrade, postinstall, doctor)

A3 inline (agent failed on API). Three CLIs ported / written for the
v0.7+ atomic system:

veilor-update — rewritten on bootc upgrade (was dnf upgrade --refresh).
  Pre-checks bootc status, pauses auditd while staging, prints summary
  and offers reboot. Returns 0/1/2/3 per legacy contract.

veilor-postinstall (NEW) — first-login TUI run via
  veilor-postinstall.service oneshot. Asks once for keyboard, locale,
  hostname, GPU drivers, package presets (dev/media/homelab),
  bluetooth, USBGuard snapshot, then invokes veilor-doctor. Writes
  /var/lib/veilor/postinstall-complete and self-disables on success.

veilor-doctor — Updates section rewritten to parse `bootc status
  --json` (with jq) when available, falls back to dnf history /
  check-update for legacy v0.5.x kickstart-installed systems.

Plus systemd units:
  - veilor-postinstall.service (oneshot on graphical.target, gated on
    absence of done-marker, runs on tty1)
  - veilor-doctor.service + .timer (weekly drift check)
This commit is contained in:
obsidian-ai 2026-05-06 16:46:59 +01:00
parent 61fec5e1a9
commit 606806f82f
6 changed files with 300 additions and 67 deletions

View file

@ -0,0 +1,7 @@
[Unit]
Description=veilor-doctor — system health + drift check
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/veilor-doctor --quiet

View file

@ -0,0 +1,10 @@
[Unit]
Description=veilor-doctor weekly drift check
[Timer]
OnCalendar=weekly
Persistent=true
RandomizedDelaySec=30m
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,17 @@
[Unit]
Description=veilor-os one-time post-install TUI (first login)
After=graphical.target
ConditionPathExists=!/var/lib/veilor/postinstall-complete
[Service]
Type=oneshot
ExecStart=/usr/local/bin/veilor-postinstall
StandardInput=tty
StandardOutput=tty
StandardError=journal
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
[Install]
WantedBy=graphical.target multi-user.target

View file

@ -147,23 +147,44 @@ PUBLIC_IP=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || echo "")
|| check Network public_ip fail "lookup timed out" || check Network public_ip fail "lookup timed out"
# ── 5. Updates ────────────────────────────────────────────────────── # ── 5. Updates ──────────────────────────────────────────────────────
LAST_DNF=$(sudo -n dnf history list 2>/dev/null \ # v0.7+ atomic — bootc replaces dnf as the update channel. Parse
| awk 'NR==4 {for(i=4;i<NF;i++)printf "%s ", $i; print $NF; exit}') # `bootc status --json` for the booted deployment + staged/cached image
[[ -n $LAST_DNF ]] && check Updates last_dnf pass "$LAST_DNF" \ # age. Fall back to dnf history if bootc not present (legacy v0.5.x).
|| check Updates last_dnf pass "(unknown — try \`sudo dnf history\`)" if have bootc; then
BOOTC_JSON=$(sudo -n bootc status --json 2>/dev/null || echo "")
# `dnf check-update` exits 100 if updates available, 0 if not. if [[ -n $BOOTC_JSON ]] && have jq; then
sudo -n dnf check-update -q >/dev/null 2>&1 BOOTED_IMG=$(jq -r '.status.booted.image.image.image // "unknown"' <<<"$BOOTC_JSON")
RC=$? BOOTED_DIGEST=$(jq -r '.status.booted.image.imageDigest // ""' <<<"$BOOTC_JSON")
case $RC in check Updates booted_image pass "${BOOTED_IMG}@${BOOTED_DIGEST:0:12}"
0) check Updates pending pass "system up-to-date" ;; STAGED=$(jq -r '.status.staged.image.image.image // ""' <<<"$BOOTC_JSON")
100) if [[ -n $STAGED ]]; then
AVAIL=$(sudo -n dnf check-update -q 2>/dev/null \ check Updates staged_image fail "staged: $STAGED — reboot to apply"
| awk 'NF>=3 && $1!~/^Last/ {n++} END {print n+0}') else
check Updates pending fail "${AVAIL} update(s) available — run \`veilor-update\`" check Updates staged_image pass "no staged update"
;; fi
*) check Updates pending fail "dnf check-update returned $RC (need sudo?)" ;; else
esac check Updates bootc_state pass "bootc present (jq missing — install for richer detail)"
fi
elif have dnf; then
# Legacy v0.5.x kickstart-installed system.
LAST_DNF=$(sudo -n dnf history list 2>/dev/null \
| awk 'NR==4 {for(i=4;i<NF;i++)printf "%s ", $i; print $NF; exit}')
[[ -n $LAST_DNF ]] && check Updates last_dnf pass "$LAST_DNF" \
|| check Updates last_dnf pass "(unknown — try \`sudo dnf history\`)"
sudo -n dnf check-update -q >/dev/null 2>&1
RC=$?
case $RC in
0) check Updates pending pass "system up-to-date" ;;
100)
AVAIL=$(sudo -n dnf check-update -q 2>/dev/null \
| awk 'NF>=3 && $1!~/^Last/ {n++} END {print n+0}')
check Updates pending fail "${AVAIL} update(s) available — run \`veilor-update\`"
;;
*) check Updates pending fail "dnf check-update returned $RC (need sudo?)" ;;
esac
else
check Updates channel fail "neither bootc nor dnf available"
fi
# ── 6. veilor services ────────────────────────────────────────────── # ── 6. veilor services ──────────────────────────────────────────────
for unit in veilor-firstboot.service veilor-modules-lock.service; do for unit in veilor-firstboot.service veilor-modules-lock.service; do

View file

@ -0,0 +1,178 @@
#!/usr/bin/bash
# veilor-postinstall — first-login TUI on v0.7+ atomic systems.
#
# Runs ONCE on first SDDM login via the user-mode systemd unit
# `veilor-postinstall.service`. Asks the operator for the small set
# of decisions we deliberately defer from install time:
# - keyboard / locale
# - hostname override
# - GPU drivers (NVIDIA layered via rpm-ostree, mesa = no-op)
# - package preset (dev / media / homelab — additive, opt-out)
# - bluetooth opt-in
# - USBGuard policy snapshot
# - veilor-doctor first run
# Writes /var/lib/veilor/postinstall-complete on success and disables
# its own autostart unit. Idempotent: safe to re-run.
#
# Style: gum if present, plain bash read fallback. No decorative ASCII.
set -uo pipefail
export TERM="${TERM:-linux}"
STATE_DIR=/var/lib/veilor
DONE_MARKER="$STATE_DIR/postinstall-complete"
LOG=/var/log/veilor-postinstall.log
have() { command -v "$1" >/dev/null 2>&1; }
GUM=$(have gum && echo gum || echo "")
# Always log + tee to stdout for live progress.
mkdir -p "$STATE_DIR" 2>/dev/null || true
exec > >(tee -a "$LOG") 2>&1
if [[ -e $DONE_MARKER && ${1:-} != "--force" ]]; then
echo "veilor-postinstall already ran (marker: $DONE_MARKER). Pass --force to re-run."
exit 0
fi
# ── Wrappers ────────────────────────────────────────────────────────
choose() {
local header=$1; shift
if [[ -n $GUM ]]; then
gum choose --header "$header" "$@"
else
echo
echo "$header"
local i=1
for opt in "$@"; do printf ' %d) %s\n' "$i" "$opt"; ((i++)); done
local n
read -rp " choice (1-$#): " n
[[ $n -ge 1 && $n -le $# ]] || return 1
eval "echo \${$n}"
fi
}
ask() {
local prompt=$1 default=${2:-}
if [[ -n $GUM ]]; then
gum input --header "$prompt" --value "$default"
else
local v
read -rp "$prompt [$default] " v
echo "${v:-$default}"
fi
}
confirm() {
local prompt=$1
if [[ -n $GUM ]]; then
gum confirm "$prompt" && return 0 || return 1
else
read -rp "$prompt [y/N] " y
[[ ${y,,} == y* ]]
fi
}
say() {
if [[ -n $GUM ]]; then
gum style --foreground 212 --bold "$1"
else
printf '\n=== %s ===\n' "$1"
fi
}
# Need root for several actions; re-exec under sudo if not root.
if [[ $EUID -ne 0 ]]; then
say "veilor-postinstall: sudo required"
exec sudo -E bash "$0" "$@"
fi
say "veilor-postinstall — one-time setup"
echo " This runs once. Each step is skippable. Defaults are sane."
echo
# ── 1. Keyboard layout ──────────────────────────────────────────────
KB=$(choose "Keyboard layout" us gb de fr es ru "skip") || KB=skip
if [[ $KB != skip ]]; then
localectl set-keymap "$KB" 2>/dev/null || true
echo " [OK] keymap = $KB"
fi
# ── 2. Locale ───────────────────────────────────────────────────────
LOC=$(choose "Locale" en_US.UTF-8 en_GB.UTF-8 de_DE.UTF-8 fr_FR.UTF-8 "skip") || LOC=skip
if [[ $LOC != skip ]]; then
localectl set-locale LANG="$LOC" 2>/dev/null || true
echo " [OK] locale = $LOC"
fi
# ── 3. Hostname ─────────────────────────────────────────────────────
HN=$(ask "Hostname" "veilor")
if [[ -n $HN && $HN != $(hostnamectl --static 2>/dev/null) ]]; then
hostnamectl set-hostname "$HN"
echo " [OK] hostname = $HN"
fi
# ── 4. GPU drivers ──────────────────────────────────────────────────
GPU=$(choose "GPU drivers" "Skip (use mesa defaults)" "NVIDIA proprietary (akmod-nvidia)" "Intel/AMD mesa (no-op)") || GPU=skip
case "$GPU" in
*NVIDIA*)
say "Layering NVIDIA driver — this takes a few minutes"
rpm-ostree install --idempotent akmod-nvidia xorg-x11-drv-nvidia-cuda \
&& echo " [OK] NVIDIA driver layered (reboot to use)" \
|| echo " [WARN] NVIDIA layer failed; check rpm-ostree status"
;;
*) echo " (skipped GPU layering)" ;;
esac
# ── 5. Package presets (multi-select) ───────────────────────────────
say "Package presets — pick any combination (skip = none)"
PRESET_DEV="git tmux vim-enhanced htop podman skopeo"
PRESET_MEDIA="vlc obs-studio"
PRESET_HOMELAB="wireguard-tools jq yq tmux"
PICKED=()
confirm "Install dev preset? ($PRESET_DEV)" && PICKED+=($PRESET_DEV) || true
confirm "Install media preset? ($PRESET_MEDIA)" && PICKED+=($PRESET_MEDIA) || true
confirm "Install homelab preset? ($PRESET_HOMELAB)" && PICKED+=($PRESET_HOMELAB) || true
if (( ${#PICKED[@]} > 0 )); then
# de-dupe
UNIQ=$(printf '%s\n' "${PICKED[@]}" | sort -u | tr '\n' ' ')
say "Layering: $UNIQ"
rpm-ostree install --idempotent $UNIQ \
&& echo " [OK] preset packages layered (reboot to use)" \
|| echo " [WARN] preset layer failed; check rpm-ostree status"
fi
# ── 6. Bluetooth ────────────────────────────────────────────────────
if confirm "Enable Bluetooth?"; then
systemctl enable --now bluetooth.service 2>/dev/null || true
echo " [OK] bluetooth enabled"
else
echo " (skipped bluetooth)"
fi
# ── 7. USBGuard snapshot ────────────────────────────────────────────
say "USBGuard policy snapshot"
echo " Plug in EVERY USB device you trust right now (keyboard,"
echo " mouse, dock, yubikey, etc.) before continuing."
if confirm "Snapshot current USB devices into the allowlist?"; then
usbguard generate-policy > /etc/usbguard/rules.conf \
&& echo " [OK] policy written to /etc/usbguard/rules.conf" \
|| echo " [WARN] generate-policy failed"
systemctl restart usbguard 2>/dev/null || true
fi
# ── 8. veilor-doctor ────────────────────────────────────────────────
if confirm "Run veilor-doctor now?"; then
veilor-doctor || true
fi
# ── Done ────────────────────────────────────────────────────────────
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$DONE_MARKER"
say "veilor-postinstall complete"
echo " Marker written: $DONE_MARKER"
echo " Disabling autostart unit so this never runs again."
systemctl --user --global disable veilor-postinstall.service 2>/dev/null || true
systemctl disable veilor-postinstall.service 2>/dev/null || true
echo
echo " If you layered any packages or drivers, reboot to activate."

View file

@ -1,25 +1,22 @@
#!/usr/bin/bash #!/usr/bin/bash
# veilor-update — system update wrapper. # veilor-update — atomic update wrapper for v0.7+ (bootc + rpm-ostree).
# Wraps `dnf upgrade --refresh` + `flatpak update` behind a single command. #
# User-facing CLI shipped in /usr/local/bin/. v0.6 ergonomic tooling. # Wraps `bootc upgrade` + flatpak update behind a single command.
# Pre-checks rollback availability, pauses auditd while staging the
# new image, prints a clear post-state summary, and offers reboot.
# #
# Exit codes: # Exit codes:
# 0 success # 0 success (with or without pending reboot)
# 1 dnf failed # 1 bootc upgrade failed
# 2 flatpak failed (dnf still ran successfully) # 2 flatpak failed (bootc still ran successfully)
# 3 no network # 3 no network
#
# Uses `gum` for spinner output if present, falls back to plain stdout.
set -uo pipefail set -uo pipefail
# ── Helpers ─────────────────────────────────────────────────────────
have() { command -v "$1" >/dev/null 2>&1; } have() { command -v "$1" >/dev/null 2>&1; }
GUM=$(have gum && echo gum || echo "") GUM=$(have gum && echo gum || echo "")
say() { say() {
# Print a status line. Coloured if gum present, else plain.
if [[ -n $GUM ]]; then if [[ -n $GUM ]]; then
gum style --foreground 212 --bold "$1" gum style --foreground 212 --bold "$1"
else else
@ -27,46 +24,50 @@ say() {
fi fi
} }
run_with_spinner() { confirm() {
local title=$1; shift local prompt=$1
if [[ -n $GUM ]]; then if [[ -n $GUM ]]; then
gum spin --spinner dot --title "$title" -- "$@" gum confirm "$prompt"
else else
echo "[+] $title" read -r -p "$prompt [y/N] " yn
"$@" [[ ${yn,,} == y* ]]
fi fi
} }
# ── Pre-flight: network check ─────────────────────────────────────── # ── Pre-flight: network ─────────────────────────────────────────────
say "veilor-update: checking network" say "veilor-update: checking network"
if ! ping -c 1 -W 2 mirrors.fedoraproject.org >/dev/null 2>&1; then if ! ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1; then
echo echo " No network. Connect and re-run \`veilor-update\`."
echo " No route to mirrors.fedoraproject.org."
echo " Connect to a network and re-run \`veilor-update\`."
exit 3 exit 3
fi fi
# ── Snapshot kernel before upgrade so we can warn about reboot need ─ # ── Pre-flight: rollback target available ───────────────────────────
KERNEL_BEFORE=$(uname -r) # bootc has two deployments by design (booted + rollback). If
# something's wrong we want the user to see it before staging more.
# ── DNF upgrade ───────────────────────────────────────────────────── if have bootc; then
say "veilor-update: refreshing DNF metadata + applying updates" say "veilor-update: bootc status"
# Capture upgrade output so we can count packages afterwards. Tee to bootc status || true
# stdout for live progress; swallow into a tempfile for the count. else
LOG=$(mktemp -t veilor-update.XXXXXX) echo " bootc not present — this CLI targets v0.7+ atomic systems."
trap 'rm -f "$LOG"' EXIT
if ! sudo dnf upgrade --refresh -y 2>&1 | tee "$LOG"; then
echo
echo " dnf upgrade failed. See output above."
exit 1 exit 1
fi fi
# ── Count packages updated ────────────────────────────────────────── # ── Pause auditd while staging ──────────────────────────────────────
# DNF prints "Upgraded: N", "Installed: N", "Removed: N" at end. # Reduces audit log noise during the heavy fs writes; resume after.
# Sum the upgrade/install lines for the user-visible total. AUDIT_PAUSED=0
UPDATED=$(grep -E '^(Upgraded|Installed)\b' "$LOG" 2>/dev/null \ if systemctl is-active auditd >/dev/null 2>&1; then
| awk -F: '{ gsub(/[^0-9]/,"",$2); s+=$2 } END { print s+0 }') if sudo systemctl stop auditd 2>/dev/null; then
AUDIT_PAUSED=1
fi
fi
trap '[[ $AUDIT_PAUSED == 1 ]] && sudo systemctl start auditd 2>/dev/null || true' EXIT
# ── bootc upgrade ───────────────────────────────────────────────────
say "veilor-update: bootc upgrade"
if ! sudo bootc upgrade; then
echo " bootc upgrade failed. See output above."
exit 1
fi
# ── Flatpak (best-effort) ─────────────────────────────────────────── # ── Flatpak (best-effort) ───────────────────────────────────────────
FLATPAK_RC=0 FLATPAK_RC=0
@ -74,21 +75,20 @@ if have flatpak; then
say "veilor-update: updating flatpaks" say "veilor-update: updating flatpaks"
if ! flatpak update -y; then if ! flatpak update -y; then
FLATPAK_RC=2 FLATPAK_RC=2
echo " flatpak update failed; continuing anyway." echo " flatpak update failed; continuing."
fi fi
else
echo " (flatpak not installed — skipping)"
fi fi
# ── Post-update: reboot hint if kernel changed ────────────────────── # ── Post-update summary ─────────────────────────────────────────────
KERNEL_AFTER_LATEST=$(rpm -q kernel --last 2>/dev/null \
| awk 'NR==1 { sub(/^kernel-/,"",$1); print $1 }')
say "veilor-update: complete" say "veilor-update: complete"
printf ' Packages updated : %s\n' "${UPDATED:-0}" bootc status 2>/dev/null | head -20 || true
printf ' Running kernel : %s\n' "$KERNEL_BEFORE"
if [[ -n ${KERNEL_AFTER_LATEST:-} && $KERNEL_AFTER_LATEST != "$KERNEL_BEFORE" ]]; then # ── Reboot prompt ───────────────────────────────────────────────────
printf ' Newest kernel : %s (reboot suggested)\n' "$KERNEL_AFTER_LATEST" # bootc always writes the new image into the staged deployment; reboot
# is required for it to become the running root.
if confirm " Reboot now to activate the new image?"; then
say "veilor-update: rebooting"
sudo systemctl reboot
fi fi
exit $FLATPAK_RC exit $FLATPAK_RC