From 591273f65ca1a9ae24aefb8dbbe0fc74ee06f547 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:31:15 +0000 Subject: [PATCH 1/9] docs: lean audit with baseline metrics from the last ISO build Sizes come from CI run 48 (4ca2f45): ISO, rootfs, installed size and the largest packages of both editions. The bloat table lists what each item costs, why it is bloat and what is done about it; product decisions are listed separately and not acted on. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- docs/lean-audit.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/lean-audit.md diff --git a/docs/lean-audit.md b/docs/lean-audit.md new file mode 100644 index 0000000..1fddc33 --- /dev/null +++ b/docs/lean-audit.md @@ -0,0 +1,90 @@ +# Lean audit + +What the images weigh, what is bloat and what was done about it. The audit +(this file's first commit) comes before any removal, so each change can be +checked against it. + +* Init system: **OpenRC** is the default and the only one the Lite edition + builds; the full edition also builds runit and dinit. Every new service + file ships for all three, nothing assumes systemd. +* Repositories: Artix (`system`, `world`, `galaxy`, `lib32`) and + Chaotic-AUR, which exists only for `brave-bin`. The Arch repositories are + not enabled. +* Sizes below are *uncompressed installed sizes* from the ISO workflow's + "What takes the space" report (`build-in-container.sh`), unless a row says + otherwise. + +## Before + +Measured by CI run 48 of 19 October 2026 (commit `4ca2f45`, OpenRC, +linux-zen), the last ISO build before this work. + +| Metric | Lite | Full | +|---|---|---| +| ISO | 774,014,976 B (738 MiB) | 3,030,378,496 B (2.82 GiB) | +| `LiveOS/rootfs.img` | 637.2 MiB | 2,681.8 MiB | +| initramfs | 49.7 MiB | 156.7 MiB | +| Installed system, uncompressed | 1,790.1 MiB | 7,139.8 MiB | +| Package list entries (OpenRC) | 48 | 164 (159 unique: 5 listed twice) | +| Services started in the live session | 1 | 8 | +| Services enabled by Setup on an installed system | 14 | 14 | +| Idle RAM | not measured | 792 MB used, live session, 4 GB VM, software GL (docs/desktop.md, 15 Oct) | +| Boot time in QEMU | not measured | not measured | +| Rust code | 24,849 lines, 7 crates | | + +Boot time and idle RAM could not be measured for this work: the sandbox it +ran in has no route to the Artix mirrors, no QEMU and no loop devices, so an +ISO cannot be built or booted there (see "Verification limits" in the final +section). + +## Bloat table + +| Item | Size / cost | Why it is bloat | Action | +|---|---|---|---| +| `linux-zen-headers` (`linux-headers` in the list) | 325.1 MiB, on both editions (Lite's Setup downloads it too) | Only needed to build `nvidia-open-dkms` on GTX 16 / RTX GPUs. Setup already downloads that driver in a step that needs a network. | **remove** from the image; Setup adds the running kernel's headers to the NVIDIA step | +| `spectacle` (and `opencv`, which only it needs) | `opencv` 112.8 MiB, plus the libraries only opencv uses | Second screenshot tool: the desktop has its own (Print, Super+Shift+S: grim + slurp). Spectacle would only serve the Plasma fallback session. | **remove** | +| `xorg-server`, `xf86-input-*`, `xf86-video-*` | tens of MiB | Legacy X server and its drivers. The sessions are Wayland (labwc, Plasma Wayland); applications use XWayland. The Lite image already ships only `xorg-xwayland`. | **replace** with `xorg-xwayland` only, as Lite does | +| `inxi` | ~1 MiB (Perl stays: OpenRC needs it) | Third system-information tool beside fastfetch and KInfoCenter | **remove** | +| `cupsd` started at boot | an idle daemon on every machine; no socket activation without systemd | Few gaming PCs print | **disable by default** (live session and Setup); packages stay, `seg svc enable cupsd` turns it on | +| 5 packages listed twice (`xorg-server`, `xorg-xwayland`, `xdg-user-dirs`, `xdg-utils`, `ttf-liberation`) | none | Noise | **remove** the repeats | +| `kitty` | 65.6 MiB, needs Python (73.9 MiB, shared). 185 MB + 31 MB of RAM idle in the 15 Oct VM measurement, the largest process | Lite uses `foot`, which draws on the CPU and is tiny | **keep, maintainers decide**: the README presents kitty as the terminal | +| Plasma fallback session (`plasma-workspace` 57.8, `plasma-desktop` 42.0, `breeze` 38.8 MiB, KWin, PowerDevil, KScreen, ...) | roughly 250 MiB | Second desktop: second network applet (`plasma-nm` / `network-manager-applet`), volume (`plasma-pa` / `pavucontrol`), polkit agent, portal backends | **keep, maintainers decide**: the README advertises it as the fallback | +| `discover` -> `signon-ui` -> `qt6-webengine` | 282.9 MiB | A browser engine for the Store window | **keep, maintainers decide** (the Store icon is part of the Windows-like pitch) | +| `vlc` -> Qt 5 (`qt5-base` 64.0 MiB, declarative, svg, x11extras) | about 100 MiB | A second Qt generation for one application | **keep, maintainers decide** (a Qt 6 player would remove it) | +| `mangohud` -> `python-numpy` 49.6 MiB, matplotlib | about 100 MiB | Plotting libraries for a log tool | **keep**: the overlay is a headline gaming feature | +| `vim-runtime` 38.8 MiB | 38.8 MiB | Two command-line editors (`nano`, `vi`/vim) | **keep**: `crontab -e` and `visudo` fall back to `vi` | +| `samba` | 64.3 MiB | Needed by `kdenetwork-filesharing` | **keep**: sharing folders with Windows PCs | +| `wine` 584.9, `brave-bin` 471.6, `lutris` -> `webkit2gtk-4.1` 138.3 | | The product | **keep** | +| `linux-firmware-intel` 138.0, `-nvidia` 103.5, `sof-firmware` 55.1 | | The full edition installs offline on any common hardware | **keep** | +| `llvm-libs` 166.6, `lib32-llvm-libs` 187.8 | | Mesa and Steam need them | **keep** | +| `perl` 55.0 | | `openrc` depends on it | **keep** (protected) | +| `syslog-ng` | 28 MB RSS idle (15 Oct measurement) | Heavier than needed, but logging is wanted | **keep**; `metalog` is a candidate, needs a boot test | +| `bluetoothd` | ~2 MB idle | Controllers and headsets | **keep** | +| `ntpd`, `cronie`, `acpid`, `power-profiles-daemon` | small | Time, scheduled jobs (weekly fstrim uses cron), power events and profiles | **keep** | +| `etc/sddm.conf.d` file and SDDM migration code | 2 lines | SDDM is gone from the image; `seglinux-upgrade` still migrates older systems | **keep** until those systems are gone | +| `s6` entries in `common.yaml` | 12 lines | The build scripts reject s6 | **keep**: the same layout as Artix's own profile | + +Not bloat, checked: one network stack (NetworkManager with `wpa_supplicant`), +one audio stack (PipeWire), one browser, one file manager, one media player, +one terminal per edition. No `-git` packages, and the only package of +AUR origin (`brave-bin`, with `chaotic-keyring` and `chaotic-mirrorlist`) is +a deliberate product choice. No systemd units or `systemctl` calls anywhere +in the repository, and no package with the wrong init suffix. + +Protected, never touched: `base`, the kernel, `openrc` and the `-openrc` / +`-runit` / `-dinit` service packages, `elogind`, `artix-keyring` (through +`base`), `pacman`, `sudo`, NetworkManager, the firmware packages. + +## Decisions for the maintainers + +These are product choices, so they were measured and left alone: + +1. **kitty -> foot** for the full edition (Lite already uses foot): about + 65 MiB on disk and, by the 15 October measurement, about 200 MB less RAM + in a VM with software OpenGL. +2. **Drop the Plasma fallback session**: roughly 250 MiB and a second set of + desktop services. +3. **Drop Discover**, or the Store's account integration: 283 MiB of + `qt6-webengine`. +4. **A Qt 6 media player instead of VLC**: removes Qt 5, about 100 MiB. +5. **Brave from Chaotic-AUR**: the only AUR-origin package of the image. From 866fb9f67add5fb47afb83096b7e174ec7bddc28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:34:20 +0000 Subject: [PATCH 2/9] perf: leave kernel headers, spectacle, the X server and inxi off the image From the 19 October build report (installed size): linux-zen-headers 325 MiB, opencv 113 MiB (only spectacle needs it), plus the legacy xorg-server and xf86 driver stack and a third system-information tool. - Setup downloads the running kernel's headers with the NVIDIA DKMS driver, the only thing that builds modules. The kernel package comes from the pkgbase file of the running kernel. - The artools library patch that reads the kernel version from /usr/lib/modules now applies to every kernel: the stock one no longer has headers to read it from. - The desktop has its own screenshots, so spectacle goes; both sessions are Wayland, so the X stack becomes xorg-xwayland, as in Lite. - Five packages listed twice are listed once. - cupsd is no longer enabled by default (live session and Setup); the packages stay. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- crates/seglinux-installer/src/model.rs | 1 + crates/seglinux-setup/src/answer.rs | 3 +- crates/seglinux-setup/src/facts.rs | 30 +++++++++++++++++++ crates/seglinux-setup/src/plan.rs | 41 +++++++++++++++++++------- crates/seglinux-setup/src/tui.rs | 1 + docs/building.md | 11 +++---- docs/windows-to-seglinux.md | 2 +- iso/iso-profiles/common/common.yaml | 27 +++++------------ iso/iso-profiles/seglinux/profile.yaml | 7 ----- iso/scripts/build-in-container.sh | 12 ++++---- 10 files changed, 85 insertions(+), 50 deletions(-) diff --git a/crates/seglinux-installer/src/model.rs b/crates/seglinux-installer/src/model.rs index 327bbe5..283f8cd 100644 --- a/crates/seglinux-installer/src/model.rs +++ b/crates/seglinux-installer/src/model.rs @@ -200,6 +200,7 @@ pub fn demo_facts() -> Facts { network: true, memory: 8 * GIB, gpus: vec![], + kernel: Some("linux-zen".into()), timezones: Some(set(&[ "UTC", "Africa/Tunis", diff --git a/crates/seglinux-setup/src/answer.rs b/crates/seglinux-setup/src/answer.rs index 7c9621c..bcf3541 100644 --- a/crates/seglinux-setup/src/answer.rs +++ b/crates/seglinux-setup/src/answer.rs @@ -323,6 +323,8 @@ pub enum CommandPass { } /// Services enabled on every installation (missing ones are skipped). +/// `cupsd` is installed but not started: few gaming PCs print, and OpenRC has +/// no socket activation to start it on demand (`seg svc enable cupsd`). pub const DEFAULT_SERVICES: &[&str] = &[ "dbus", "elogind", @@ -332,7 +334,6 @@ pub const DEFAULT_SERVICES: &[&str] = &[ "NetworkManager", "greetd", "bluetoothd", - "cupsd", "cronie", "syslog-ng", "ntpd", diff --git a/crates/seglinux-setup/src/facts.rs b/crates/seglinux-setup/src/facts.rs index 06239c6..a89e925 100644 --- a/crates/seglinux-setup/src/facts.rs +++ b/crates/seglinux-setup/src/facts.rs @@ -109,6 +109,9 @@ pub struct Facts { pub network: bool, pub memory: u64, pub gpus: Vec, + /// Package name of the running kernel (`linux-zen`): the new system gets + /// the same one, and its headers when a driver has to be built. + pub kernel: Option, /// Known values for validation and pick lists; `None` = not checked. pub timezones: Option>, pub locales: Option>, @@ -140,6 +143,7 @@ impl Facts { network: has_default_route(), memory: memory_bytes().unwrap_or(0), gpus: list_gpus(Path::new("/sys/bus/pci/devices")), + kernel: running_kernel(), timezones: Some(list_timezones(Path::new("/usr/share/zoneinfo"))) .filter(|s| !s.is_empty()), locales: fs::read_to_string("/usr/share/i18n/SUPPORTED") @@ -291,6 +295,24 @@ pub fn parse_default_route(route: &str, ipv6_route: &str) -> bool { v4 || v6 } +/// The package that owns the running kernel, read from the `pkgbase` file +/// every Arch and Artix kernel package puts in its module directory. +pub fn running_kernel() -> Option { + let release = fs::read_to_string("/proc/sys/kernel/osrelease").ok()?; + let pkgbase = + fs::read_to_string(format!("/usr/lib/modules/{}/pkgbase", release.trim())).ok()?; + parse_pkgbase(&pkgbase) +} + +pub fn parse_pkgbase(text: &str) -> Option { + let name = text.lines().next()?.trim(); + let valid = !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.+".contains(c)); + valid.then(|| name.to_string()) +} + pub fn memory_bytes() -> Option { let text = fs::read_to_string("/proc/meminfo").ok()?; parse_meminfo_total(&text) @@ -487,6 +509,14 @@ mod tests { assert_eq!(parse_meminfo_total("nope"), None); } + #[test] + fn kernel_package_from_pkgbase() { + assert_eq!(parse_pkgbase("linux-zen\n"), Some("linux-zen".into())); + assert_eq!(parse_pkgbase("linux-lts"), Some("linux-lts".into())); + assert_eq!(parse_pkgbase(""), None); + assert_eq!(parse_pkgbase("linux zen; rm -rf /"), None); + } + #[test] fn locales_and_layouts() { let locales = diff --git a/crates/seglinux-setup/src/plan.rs b/crates/seglinux-setup/src/plan.rs index 81a55e7..caea182 100644 --- a/crates/seglinux-setup/src/plan.rs +++ b/crates/seglinux-setup/src/plan.rs @@ -817,15 +817,19 @@ pub fn build( description: format!( "Installing the NVIDIA driver for {gpu} (needs a network connection)" ), - actions: vec![Action::chroot_optional(Cmd::new("pacman").args([ - "-Syu", - "--noconfirm", - "--needed", - "nvidia-open-dkms", - "nvidia-utils", - "lib32-nvidia-utils", - "nvidia-settings", - ]))], + // The image carries no kernel headers (325 MiB nobody else + // needs); DKMS builds the driver against the ones fetched here. + actions: vec![Action::chroot_optional( + Cmd::new("pacman") + .args(["-Syu", "--noconfirm", "--needed"]) + .arg(kernel_headers(facts.kernel.as_deref())) + .args([ + "nvidia-open-dkms", + "nvidia-utils", + "lib32-nvidia-utils", + "nvidia-settings", + ]), + )], }); } sp.push(Step { @@ -1082,6 +1086,12 @@ fn run_command_step(c: &crate::answer::RunCommand) -> Step { } } +/// The headers package of the kernel the new system runs. The stock +/// kernel's is the fallback when the running one is unknown. +fn kernel_headers(kernel: Option<&str>) -> String { + format!("{}-headers", kernel.unwrap_or("linux")) +} + /// `UTF-8`/`utf8` -> `UTF-8` for locale.gen. fn charset_name(charset: &str) -> String { match charset.to_ascii_lowercase().replace('-', "").as_str() { @@ -1137,6 +1147,7 @@ mod tests { network: true, memory: 4 * GIB, gpus: vec![], + kernel: Some("linux-zen".into()), timezones: None, locales: None, keyboards: None, @@ -1288,10 +1299,20 @@ mod tests { }]; let text = build_plan(&answer(""), &f).unwrap().to_string(); assert!(text.contains("Installing the NVIDIA driver for NVIDIA 10de:2684")); - assert!(text.contains("pacman -Syu --noconfirm --needed nvidia-open-dkms nvidia-utils lib32-nvidia-utils nvidia-settings # may fail")); + assert!(text.contains("pacman -Syu --noconfirm --needed linux-zen-headers nvidia-open-dkms nvidia-utils lib32-nvidia-utils nvidia-settings # may fail")); // The driver is installed before the initramfs is built. assert!(text.find("nvidia-open-dkms").unwrap() < text.find("mkinitcpio -P").unwrap()); + // The headers match the kernel that is running; the stock kernel's + // are the fallback when it cannot be told. + f.kernel = Some("linux-lts".into()); + let text = build_plan(&answer(""), &f).unwrap().to_string(); + assert!(text.contains("--needed linux-lts-headers nvidia-open-dkms")); + f.kernel = None; + let text = build_plan(&answer(""), &f).unwrap().to_string(); + assert!(text.contains("--needed linux-headers nvidia-open-dkms")); + f.kernel = Some("linux-zen".into()); + let a = answer("[drivers]\nnvidia = \"never\"\n"); assert!( !build_plan(&a, &f) diff --git a/crates/seglinux-setup/src/tui.rs b/crates/seglinux-setup/src/tui.rs index 10fda2a..e14be2a 100644 --- a/crates/seglinux-setup/src/tui.rs +++ b/crates/seglinux-setup/src/tui.rs @@ -818,6 +818,7 @@ mod tests { network: true, memory: 8 * GIB, gpus: vec![], + kernel: Some("linux-zen".into()), timezones: Some( ["UTC", "Europe/Paris", "Europe/Berlin"] .iter() diff --git a/docs/building.md b/docs/building.md index a7f0716..dc70747 100644 --- a/docs/building.md +++ b/docs/building.md @@ -22,12 +22,13 @@ The ISO and its checksum are written to `iso/out/`, for example `common.yaml` names the stock `linux` kernel, which is what artools expects. For `linux-zen` (or `linux-lts`) the build swaps the kernel in a staged copy -of the profile and runs `buildiso` with a patched copy of the artools +of the profile. It always runs `buildiso` with a patched copy of the artools library (`LIBDIR`): artools reads the live kernel's version from -`/usr/src/linux/version`, which only the stock kernel's headers provide, and -the copy reads it from `/usr/lib/modules` instead. The installed artools -files are not modified. If artools changes that code, the build stops with a -message suggesting `-k linux`. +`/usr/src/linux/version`, which only a kernel's headers provide, and the +images carry no headers (only DKMS drivers need them, and Setup downloads +them with the NVIDIA driver), so the copy reads the version from +`/usr/lib/modules` instead. The installed artools files are not modified. +If artools changes that code, the build stops with a message. Needs: about 25 GB of free disk space, a network connection, and root rights for the container (`--privileged`, because `buildiso` mounts file diff --git a/docs/windows-to-seglinux.md b/docs/windows-to-seglinux.md index 6ac14b6..61ecc0c 100644 --- a/docs/windows-to-seglinux.md +++ b/docs/windows-to-seglinux.md @@ -58,7 +58,7 @@ prints it): | Virtual desktops (Win+Ctrl+Left/Right) | labwc workspaces, with the same shortcut; their number is set in Settings | | Xbox app / Game Bar, Game Mode | Steam, Lutris, MangoHud, GameMode and the SegLinux Gaming Mode session (see [gaming.md](gaming.md)) | | Window buttons (minimize, maximize, close on the right), Alt+Tab thumbnails | KWin defaults in `/etc/xdg/kwinrc` | -| File Explorer, Notepad, Calculator, Snipping Tool, Photos, Task Manager, Disk Management | Dolphin, Kate, KCalc, Spectacle, Gwenview, System Monitor, Partition Manager | +| File Explorer, Notepad, Calculator, Snipping Tool, Photos, Task Manager, Disk Management | Dolphin, Kate, KCalc, the desktop's own screenshots (Print, Super+Shift+S), Gwenview, System Monitor, Partition Manager | | Microsoft Store | Discover with Flathub (Flatpak) | | Windows Update | `sudo seglinux-upgrade` (see [updating.md](updating.md)) or Discover | | Edge, Office, Media Player | Brave, LibreOffice, VLC | diff --git a/iso/iso-profiles/common/common.yaml b/iso/iso-profiles/common/common.yaml index b03a451..f1b37f3 100644 --- a/iso/iso-profiles/common/common.yaml +++ b/iso/iso-profiles/common/common.yaml @@ -9,7 +9,9 @@ packages-base: - base - linux - linux-firmware - - linux-headers + # No kernel headers: 325 MiB that only DKMS drivers need. Setup downloads + # the running kernel's headers with the NVIDIA driver (the one DKMS + # module it installs). - intel-ucode - amd-ucode - mkinitcpio @@ -48,33 +50,18 @@ packages-base: - wpa_supplicant packages-apps: - - inxi - fastfetch +# Both sessions are Wayland (labwc, Plasma) and applications use XWayland, +# so the X server and its drivers are left out, as in the Lite edition. packages-xorg: - - xorg-server - - xf86-input-libinput - - xf86-input-vmmouse - - xf86-input-wacom - - xf86-video-amdgpu - - xf86-video-ati - - xf86-video-fbdev - - xf86-video-qxl - - xf86-video-vesa + - xorg-xwayland packages-xlibre: - - xlibre-xserver - - xlibre-input-vmmouse - - xlibre-input-wacom - - xlibre-video-amdgpu - - xlibre-video-ati - - xlibre-video-fbdev - - xlibre-video-qxl - - xlibre-video-vesa + - xorg-xwayland packages-misc: - wayland - - xorg-xwayland - xdg-user-dirs - xdg-utils - terminus-font diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index c0e19d6..af8885a 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -22,7 +22,6 @@ live-session: - acpid - bluetoothd - cronie - - cupsd - syslog-ng - NetworkManager - greetd @@ -76,17 +75,12 @@ rootfs: - print-manager # Devices and Printers - discover # Microsoft Store - flatpak - - xorg-server - - xorg-xwayland - - xdg-user-dirs - - xdg-utils # Accessories - dolphin # File Explorer - kitty # Terminal - kate # Notepad - kcalc # Calculator - - spectacle # Snipping Tool - gwenview # Photos - okular # PDF reader - ark # compressed folders @@ -120,7 +114,6 @@ rootfs: # Courier New, Calibri) so documents keep their layout - noto-fonts - noto-fonts-emoji - - ttf-liberation - ttf-carlito # Running Windows programs and games diff --git a/iso/scripts/build-in-container.sh b/iso/scripts/build-in-container.sh index cf571f3..130818d 100755 --- a/iso/scripts/build-in-container.sh +++ b/iso/scripts/build-in-container.sh @@ -131,9 +131,10 @@ fi # Kernel: common.yaml names the stock kernel, which artools expects; swap in # the requested one. artools derives the live initramfs' kernel version from -# /usr/src/linux/version, which only the stock linux-headers provides, so for -# other kernels buildiso runs with a patched copy of its library (buildiso -# honours LIBDIR) that reads the version from /usr/lib/modules instead. +# /usr/src/linux/version, which only a kernel's headers provide. The images +# carry no headers (only DKMS drivers need them, and Setup downloads those), +# so for every kernel buildiso runs with a patched copy of its library +# (buildiso honours LIBDIR) that reads the version from /usr/lib/modules. # Copy artools' library once so that buildiso (which honours LIBDIR) runs # with patched code; the installed artools files stay untouched. patched_lib() { @@ -156,12 +157,11 @@ patch_lib() { use_kernel() { sed -i -E "s/^(\s*-\s*)linux$/\1$kernel/; s/^(\s*-\s*)linux-headers$/\1$kernel-headers/" \ "$workspace/iso-profiles/common/common.yaml" - [[ $kernel == linux ]] && return - patched_lib || die "this buildiso does not honour LIBDIR; build with -k linux" + patched_lib || die "this buildiso does not honour LIBDIR; the images have no kernel headers for it to read the kernel version from" # shellcheck disable=SC2016 # literal shell code, not expansions patch_lib iso/initcpio.sh 'k=$(<"$mnt"/usr/src/linux/version)' \ 'k=$(basename "$(dirname "$(find "$mnt"/usr/lib/modules -mindepth 2 -maxdepth 2 -name vmlinuz | sort -V | tail -n1)")")' || - die "artools changed how it finds the kernel version in iso/initcpio.sh; build with -k linux" + die "artools changed how it finds the kernel version in iso/initcpio.sh" } step "Using the $kernel kernel" use_kernel From a4526e2e9df3156a9b27092401a882bd5c5e5c9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:09 +0000 Subject: [PATCH 3/9] feat(seg): seg, one small Rust tool for the system (tools/seg) A workspace of its own (release profile: opt-level z, LTO, panic=abort, stripped; about 550 KiB) with the crate segctl and the binary seg. One dependency, lexopt. - seg info: distro, kernel, init, uptime, RAM, packages, disk. - seg svc list|enable|disable|start|stop: OpenRC, runit, dinit and s6, detected from PID 1, /run, then the installed files. - seg clean: orphans, pacman cache beyond the newest 2 versions (a port of libalpm's vercmp), rotated logs, ~/.cache size. Dry run by default. - seg tune light|balanced|performance: vm sysctls, CPU governor, I/O scheduler per disk type; saved to /etc/sysctl.d/99-seg.conf. - seg zram on|off|status: zstd swap in memory, half the RAM. - seg doctor: failed services, missing firmware, low disk space, a stale db.lck, a broken keyring, each with its fix. Root is checked up front, unwrap/expect/panic/indexing are compile errors outside tests, output ends quietly on a closed pipe. 77 unit tests cover every parser and the file-writing parts against a fake /sys, /proc, /etc. Also run in an Artix container: info, doctor, clean, tune, zram status, svc against the real dinit layout. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .gitignore | 5 + Cargo.toml | 2 + docs/seg.md | 8 + tools/seg/Cargo.lock | 16 + tools/seg/Cargo.toml | 21 + tools/seg/README.md | 147 +++++ tools/seg/dist/99-seg.conf | 5 + tools/seg/dist/cron-fstrim | 3 + tools/seg/dist/dinit/seg-tune | 5 + tools/seg/dist/dinit/seg-zram | 5 + tools/seg/dist/fstrim-weekly | 31 + tools/seg/dist/openrc/seg-tune | 17 + tools/seg/dist/openrc/seg-zram | 23 + tools/seg/dist/runit/seg-tune/run | 6 + tools/seg/dist/runit/seg-zram/finish | 2 + tools/seg/dist/runit/seg-zram/run | 6 + tools/seg/dist/tune.conf | 3 + tools/seg/segctl/Cargo.toml | 17 + tools/seg/segctl/src/clean.rs | 498 +++++++++++++++ tools/seg/segctl/src/doctor.rs | 600 +++++++++++++++++++ tools/seg/segctl/src/error.rs | 109 ++++ tools/seg/segctl/src/info.rs | 213 +++++++ tools/seg/segctl/src/init.rs | 138 +++++ tools/seg/segctl/src/main.rs | 234 ++++++++ tools/seg/segctl/src/svc.rs | 735 +++++++++++++++++++++++ tools/seg/segctl/src/sys.rs | 336 +++++++++++ tools/seg/segctl/src/tune.rs | 866 +++++++++++++++++++++++++++ tools/seg/segctl/src/vercmp.rs | 244 ++++++++ tools/seg/segctl/src/zram.rs | 573 ++++++++++++++++++ 29 files changed, 4868 insertions(+) create mode 100644 docs/seg.md create mode 100644 tools/seg/Cargo.lock create mode 100644 tools/seg/Cargo.toml create mode 100644 tools/seg/README.md create mode 100644 tools/seg/dist/99-seg.conf create mode 100644 tools/seg/dist/cron-fstrim create mode 100644 tools/seg/dist/dinit/seg-tune create mode 100644 tools/seg/dist/dinit/seg-zram create mode 100755 tools/seg/dist/fstrim-weekly create mode 100755 tools/seg/dist/openrc/seg-tune create mode 100755 tools/seg/dist/openrc/seg-zram create mode 100755 tools/seg/dist/runit/seg-tune/run create mode 100755 tools/seg/dist/runit/seg-zram/finish create mode 100755 tools/seg/dist/runit/seg-zram/run create mode 100644 tools/seg/dist/tune.conf create mode 100644 tools/seg/segctl/Cargo.toml create mode 100644 tools/seg/segctl/src/clean.rs create mode 100644 tools/seg/segctl/src/doctor.rs create mode 100644 tools/seg/segctl/src/error.rs create mode 100644 tools/seg/segctl/src/info.rs create mode 100644 tools/seg/segctl/src/init.rs create mode 100644 tools/seg/segctl/src/main.rs create mode 100644 tools/seg/segctl/src/svc.rs create mode 100644 tools/seg/segctl/src/sys.rs create mode 100644 tools/seg/segctl/src/tune.rs create mode 100644 tools/seg/segctl/src/vercmp.rs create mode 100644 tools/seg/segctl/src/zram.rs diff --git a/.gitignore b/.gitignore index ac5b059..5c5dbe2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ /packaging/seglinux/src/ /packaging/seglinux/pkg/ /packaging/seglinux/*.pkg.tar.* +/tools/seg/target/ +/pkg/segctl/*.tar.gz +/pkg/segctl/src/ +/pkg/segctl/pkg/ +/pkg/segctl/*.pkg.tar.* *.iso *.iso.sha256 _site/ diff --git a/Cargo.toml b/Cargo.toml index 1ed79e2..d725934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "crates/seglinux-installer", "crates/seglinux-greeter", ] +# seg has its own workspace and release profile (tools/seg/Cargo.toml). +exclude = ["tools/seg"] [workspace.package] version = "0.1.0" diff --git a/docs/seg.md b/docs/seg.md new file mode 100644 index 0000000..7ad690e --- /dev/null +++ b/docs/seg.md @@ -0,0 +1,8 @@ +# The seg tool + +`seg` looks after a SegLinux system: `seg info`, `seg svc` (services on +OpenRC, runit, dinit or s6), `seg clean`, `seg tune`, `seg zram` and +`seg doctor`. It is a small Rust program in `tools/seg` and is installed from +the `segctl` package. + +Everything about it is in [tools/seg/README.md](../tools/seg/README.md). diff --git a/tools/seg/Cargo.lock b/tools/seg/Cargo.lock new file mode 100644 index 0000000..2bb4d03 --- /dev/null +++ b/tools/seg/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "lexopt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" + +[[package]] +name = "segctl" +version = "0.1.0" +dependencies = [ + "lexopt", +] diff --git a/tools/seg/Cargo.toml b/tools/seg/Cargo.toml new file mode 100644 index 0000000..0d29c44 --- /dev/null +++ b/tools/seg/Cargo.toml @@ -0,0 +1,21 @@ +# A workspace of its own: `seg` is a tiny, dependency-light tool with its own +# release profile (size over speed, no unwinding), unlike the GTK desktop +# crates in the repository's root workspace. +[workspace] +resolver = "3" +members = ["segctl"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +authors = ["SegLinux contributors"] +repository = "https://github.com/SegMind25/SegLinux" +homepage = "https://github.com/SegMind25/SegLinux" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/tools/seg/README.md b/tools/seg/README.md new file mode 100644 index 0000000..9e37d76 --- /dev/null +++ b/tools/seg/README.md @@ -0,0 +1,147 @@ +# seg + +One small Rust program to look after a SegLinux system, the same on OpenRC, +runit, dinit and s6. No async runtime and one dependency +([lexopt](https://crates.io/crates/lexopt), a minimal argument parser); the +release binary is about 550 KiB. + +``` +seg info distro, kernel, init, uptime, memory, packages, disk +seg svc list|enable|disable|start|stop [name] +seg clean [--apply] orphans, old cached packages, old logs +seg tune light|balanced|performance|apply|status +seg zram on|off|status +seg doctor look for problems and print their fixes +``` + +Commands that change the system need root and say so ("`seg tune +performance` needs root. Run it again with sudo"); errors are messages, not +panics. Reports (`info`, `svc list`, `tune status`, `zram status`, `doctor`, +`clean` without `--apply`) work as a normal user. + +## seg info + +``` +SegLinux + Kernel 7.2.11-zen1-1-zen + Init openrc + Uptime 2h 15m + Memory 1.9 GiB / 15.6 GiB (12%) + Packages 912 (pacman) + Disk / 21.4 GiB / 232.9 GiB (9%) +``` + +It reads /proc, /etc/os-release and pacman's local database directly; the only +program it starts is `df`. + +## seg svc + +One command for the four init systems, detected from PID 1 (`openrc-init`, +`runit`, `dinit`, `s6-svscan`), then from the state the init leaves in /run, +then from what is installed. + +| | OpenRC | runit | dinit | s6 | +|---|---|---|---|---| +| `enable` | `/etc/runlevels//` -> `/etc/init.d/` | `/etc/runit/runsvdir/default/` -> `/etc/runit/sv/` | `/etc/dinit.d/boot.d/` -> `../` | `/etc/s6/adminsv/default/contents.d/`, then `s6-db-reload` | +| `start`, `stop` | `rc-service` | `sv` | `dinitctl` | `s6-rc -u/-d change` | +| state in `list` | `rc-status -s` | `sv status` | `dinitctl list` | `s6-rc -a list` | + +`enable` and `disable` only create or remove those links, so they also work in +a chroot or on a mounted system (`--offline` picks the init from what is +installed there instead of from the running PID 1). Service names are checked: +no paths, no hidden files. + +## seg clean + +Dry run unless `--apply` is given. + +* **orphans**: `pacman -Qdtq`; applied with `pacman -Rns`. +* **package cache**: for every package and architecture, all but the newest 2 + versions (`--keep N`), with their `.sig` files. The versions are ordered + with a port of libalpm's `vercmp` (epochs, `1.10 > 1.2`, `1.0a < 1.0`), so it + keeps what `paccache -k2` would. `CacheDir` is read from pacman.conf. + Partial downloads and anything that is not a finished package are left alone. +* **logs**: rotated logs (`messages.1`, `*.log.old`, `*-20240101.gz`) in + /var/log older than 30 days (`--log-days N`). Live logs are never touched. +* **~/.cache**: its size and the five biggest folders (of the user who ran + `sudo`). Reported, never deleted. + +## seg tune + +| | light | balanced | performance | +|---|---|---|---| +| `vm.swappiness` | 100 | 60 | 10 | +| `vm.vfs_cache_pressure` | 150 | 100 | 50 | +| `vm.dirty_ratio` | 10 | 20 | 15 | +| `vm.dirty_background_ratio` | 5 | 10 | 5 | +| CPU governor (first available) | powersave, conservative, schedutil, ondemand | schedutil, ondemand, powersave | performance | +| energy/performance preference | power | balance_performance | performance | + +* With zram swap on, swappiness is at least 100: swapping to compressed memory + is cheaper than re-reading files from disk. +* I/O scheduler of every disk: `none` for NVMe and virtual disks (their + controller or host schedules), `mq-deadline` for SSDs, `bfq` for spinning + disks. A scheduler the kernel does not have is reported, not forced. +* The profile is applied at once and saved: /etc/seg/tune.conf (the profile) and + /etc/sysctl.d/99-seg.conf (the values, for the `sysctl` service). The + `seg-tune` service runs `seg tune apply` after boot to set what only + exists at run time (governor, schedulers). +* `power-profiles-daemon`, when it runs, also sets the CPU's energy preference; + `seg-tune` starts after it, so the saved profile wins at boot and the last + change wins afterwards. +* `--dry-run` shows the changes without making them. + +## seg zram + +`seg zram on` loads the module if needed and makes /dev/zram0 swap: zstd, +half the RAM (at most 8 GiB, because the device's table uses memory in +proportion to its size; `--size 2G` overrides), priority 100. It also turns +zswap off (it would compress the same pages twice) and sets +`vm.page-cluster=0`. `seg zram off` swaps off and frees the memory; `status` +shows size, use and compression ratio. The `seg-zram` service runs it at boot. + +## seg doctor + +| check | looks at | fix it prints | +|---|---|---| +| services | enabled services that are stopped or crashed | the init's start command, or `seg svc disable` | +| firmware | `dmesg`: "Direct firmware load for ... failed with error -2" | `pacman -F` to find the package, then install and reboot | +| disk | /, /home, /var, /usr under 1 GiB free or 90 % full; /boot under 20 MiB | `seg clean --apply` | +| pacman | /var/lib/pacman/db.lck with no pacman running | `sudo rm /var/lib/pacman/db.lck` | +| keyring | /etc/pacman.d/gnupg, `artix-keyring`, Artix (and Arch, if enabled) keys | `pacman-key --init` / `--populate artix` | + +Exit status 1 when it found a problem. The kernel log and the keyring need +root to read (`sudo seg doctor`); without it those checks say "skip". +Some drivers (iwlwifi, amdgpu) ask for a newer firmware file first and fall +back, which also appears in the log; the report says so. + +## Shipped in the package (`pkg/segctl`) + +* `/usr/bin/seg` +* `seg-tune` and `seg-zram` for OpenRC (`/etc/init.d`), runit + (`/etc/runit/sv`) and dinit (`/etc/dinit.d`); the package enables both + (`seg svc --offline enable`) when it is installed, also in the ISO build's chroot +* `/etc/seg/tune.conf` and `/etc/sysctl.d/99-seg.conf`: the balanced profile +* `/usr/lib/seg/fstrim-weekly` and `/etc/cron.d/seg-fstrim`: cron checks every + hour and trims once a week (a stamp in /var/lib/seg), so a computer that + was off on the usual day catches up + +## Building + +```sh +cd tools/seg +cargo fmt --check && cargo clippy -- -D warnings && cargo test +cargo build --release # target/release/seg +make -C ../../pkg/segctl package # the pacman package, on Arch/Artix +``` + +The release profile is `opt-level = "z"`, LTO, one codegen unit, +`panic = "abort"`, stripped. Outside tests, `unwrap`, `expect`, `panic!` and +slice indexing are compile errors (`#![deny(clippy::...)]`), and output goes +through a writer that ends quietly on a closed pipe (`seg clean | head`). + +The code is split so that every parser is a pure function with tests +(`/proc/meminfo`, `df -Pk`, `rc-status`, `sv status`, `dinitctl list`, +`/proc/swaps`, `dmesg`, pacman cache file names, version order), and the +parts that write to /sys, /proc and /etc take a root directory, which the +tests point at a fake tree. diff --git a/tools/seg/dist/99-seg.conf b/tools/seg/dist/99-seg.conf new file mode 100644 index 0000000..93d20fc --- /dev/null +++ b/tools/seg/dist/99-seg.conf @@ -0,0 +1,5 @@ +# Written by `seg tune balanced`; change it with `sudo seg tune `. +vm.swappiness = 60 +vm.vfs_cache_pressure = 100 +vm.dirty_ratio = 20 +vm.dirty_background_ratio = 10 diff --git a/tools/seg/dist/cron-fstrim b/tools/seg/dist/cron-fstrim new file mode 100644 index 0000000..0dfe3d2 --- /dev/null +++ b/tools/seg/dist/cron-fstrim @@ -0,0 +1,3 @@ +# Weekly TRIM for SSDs (see /usr/lib/seg/fstrim-weekly): checked every hour, +# done once a week. +17 * * * * root /usr/lib/seg/fstrim-weekly diff --git a/tools/seg/dist/dinit/seg-tune b/tools/seg/dist/dinit/seg-tune new file mode 100644 index 0000000..124fd58 --- /dev/null +++ b/tools/seg/dist/dinit/seg-tune @@ -0,0 +1,5 @@ +# Applies the saved `seg tune` profile (CPU governor, I/O schedulers and +# memory settings) at boot. Choose the profile with: sudo seg tune +type = scripted +command = /usr/bin/seg tune apply +restart = false diff --git a/tools/seg/dist/dinit/seg-zram b/tools/seg/dist/dinit/seg-zram new file mode 100644 index 0000000..fdff5b7 --- /dev/null +++ b/tools/seg/dist/dinit/seg-zram @@ -0,0 +1,5 @@ +# Compressed swap in memory (zram): zstd, half the RAM. See: seg zram status +type = scripted +command = /usr/bin/seg zram on +stop-command = /usr/bin/seg zram off +restart = false diff --git a/tools/seg/dist/fstrim-weekly b/tools/seg/dist/fstrim-weekly new file mode 100755 index 0000000..295890b --- /dev/null +++ b/tools/seg/dist/fstrim-weekly @@ -0,0 +1,31 @@ +#!/bin/sh +# Trims the free space of every mounted file system that supports it, at most +# once a week. cron starts this every hour (/etc/cron.d/seg-fstrim); the stamp +# makes it run once, and a computer that was off on the usual day catches up +# when it is next on. TRIM keeps SSDs fast; on other drives it does nothing. +set -eu +PATH=/usr/bin:/usr/sbin:/bin:/sbin +stamp=/var/lib/seg/fstrim-last +week=604800 + +now=$(date +%s) +if [ -r "$stamp" ]; then + last=$(cat "$stamp" 2>/dev/null || echo 0) + case $last in + '' | *[!0-9]*) last=0 ;; + esac + [ $((now - last)) -ge $week ] || exit 0 +fi + +mkdir -p "$(dirname "$stamp")" +# Lowest CPU and disk priority: this runs while someone may be playing. +if command -v ionice >/dev/null 2>&1; then + set -- ionice -c3 nice -n 19 fstrim -Av +else + set -- nice -n 19 fstrim -Av +fi +# fstrim exits non-zero when one file system cannot be trimmed (a RAM disk, +# a drive without TRIM): the others are still done. +out=$("$@" 2>&1) || true +printf '%s\n' "$out" | logger -t seg-fstrim 2>/dev/null || true +printf '%s\n' "$now" > "$stamp" diff --git a/tools/seg/dist/openrc/seg-tune b/tools/seg/dist/openrc/seg-tune new file mode 100755 index 0000000..8d82408 --- /dev/null +++ b/tools/seg/dist/openrc/seg-tune @@ -0,0 +1,17 @@ +#!/usr/bin/openrc-run +# shellcheck shell=sh disable=SC2034 +# Applies the saved `seg tune` profile (CPU governor, I/O schedulers and +# memory settings) at boot. Choose the profile with: sudo seg tune + +description="Apply the saved seg tune profile" + +depend() { + # Last writer wins: let power-profiles-daemon and the zram swap settle first. + after sysctl udev power-profiles-daemon seg-zram +} + +start() { + ebegin "Applying the seg tune profile" + /usr/bin/seg tune apply >/dev/null + eend $? +} diff --git a/tools/seg/dist/openrc/seg-zram b/tools/seg/dist/openrc/seg-zram new file mode 100755 index 0000000..88a2306 --- /dev/null +++ b/tools/seg/dist/openrc/seg-zram @@ -0,0 +1,23 @@ +#!/usr/bin/openrc-run +# shellcheck shell=sh disable=SC2034 +# Compressed swap in memory (zram): zstd, half the RAM, used before any disk +# swap. See: seg zram status + +description="Compressed swap in memory (zram)" + +depend() { + after modules sysctl udev + before seg-tune +} + +start() { + ebegin "Setting up zram swap" + /usr/bin/seg zram on >/dev/null + eend $? +} + +stop() { + ebegin "Stopping zram swap" + /usr/bin/seg zram off >/dev/null + eend $? +} diff --git a/tools/seg/dist/runit/seg-tune/run b/tools/seg/dist/runit/seg-tune/run new file mode 100755 index 0000000..19bb6e1 --- /dev/null +++ b/tools/seg/dist/runit/seg-tune/run @@ -0,0 +1,6 @@ +#!/bin/sh +# Applies the saved `seg tune` profile once, then stays "up" so runit does +# not start it again. +exec 2>&1 +/usr/bin/seg tune apply +exec sleep infinity diff --git a/tools/seg/dist/runit/seg-zram/finish b/tools/seg/dist/runit/seg-zram/finish new file mode 100755 index 0000000..bf08720 --- /dev/null +++ b/tools/seg/dist/runit/seg-zram/finish @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/bin/seg zram off diff --git a/tools/seg/dist/runit/seg-zram/run b/tools/seg/dist/runit/seg-zram/run new file mode 100755 index 0000000..224ddf3 --- /dev/null +++ b/tools/seg/dist/runit/seg-zram/run @@ -0,0 +1,6 @@ +#!/bin/sh +# Sets up zram swap once, then stays "up" so runit does not start it again. +# `sv stop seg-zram` runs ./finish, which turns it off. +exec 2>&1 +/usr/bin/seg zram on +exec sleep infinity diff --git a/tools/seg/dist/tune.conf b/tools/seg/dist/tune.conf new file mode 100644 index 0000000..0da72f1 --- /dev/null +++ b/tools/seg/dist/tune.conf @@ -0,0 +1,3 @@ +# seg tune profile: light, balanced or performance. +# Change it with `sudo seg tune `; the seg-tune service applies it at boot. +mode = balanced diff --git a/tools/seg/segctl/Cargo.toml b/tools/seg/segctl/Cargo.toml new file mode 100644 index 0000000..00b608c --- /dev/null +++ b/tools/seg/segctl/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "segctl" +description = "seg: system information, services, cleaning, tuning, zram and checks for SegLinux, over runit, OpenRC, dinit and s6" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +license = "LicenseRef-SegLinux-unspecified" +publish = false + +[[bin]] +name = "seg" +path = "src/main.rs" + +[dependencies] +lexopt = "0.3" diff --git a/tools/seg/segctl/src/clean.rs b/tools/seg/segctl/src/clean.rs new file mode 100644 index 0000000..e80b73e --- /dev/null +++ b/tools/seg/segctl/src/clean.rs @@ -0,0 +1,498 @@ +//! `seg clean`: orphans, the pacman cache, old logs and the size of +//! ~/.cache. A dry run unless `--apply` is given. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use crate::error::{Context, Error, Result}; +use crate::sys::{self, bold, human_bytes}; +use crate::vercmp::{self, parse_cache_file}; + +pub const HELP: &str = "\ +Usage: seg clean [--apply] [--keep N] [--log-days N] + +Finds what can go: packages nothing needs any more (pacman -Qdtq), cached +packages beyond the newest N versions (default 2, like paccache -k2), +rotated logs older than N days (default 30), and shows how big ~/.cache is. + +By default nothing is changed: it only lists. --apply deletes (as root); +--dry-run is accepted for clarity and is the default. +"; + +#[derive(Debug)] +pub struct Options { + pub apply: bool, + pub keep: usize, + pub log_days: u64, +} + +impl Default for Options { + fn default() -> Self { + Options { + apply: false, + keep: 2, + log_days: 30, + } + } +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut o = Options::default(); + while let Some(arg) = parser.next()? { + match arg { + Long("apply") => o.apply = true, + Long("dry-run") => o.apply = false, + Long("keep") => o.keep = parser.value()?.parse()?, + Long("log-days") => o.log_days = parser.value()?.parse()?, + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + other => return Err(other.unexpected().into()), + } + } + Ok(o) +} + +/// `CacheDir` lines of pacman.conf; the default when there are none. +pub fn parse_cache_dirs(conf: &str) -> Vec { + let dirs: Vec = conf + .lines() + .filter_map(|l| { + let l = l.trim(); + let (key, value) = l.split_once('=')?; + (key.trim() == "CacheDir").then(|| PathBuf::from(value.trim())) + }) + .collect(); + if dirs.is_empty() { + vec![PathBuf::from("/var/cache/pacman/pkg")] + } else { + dirs + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct CachePlan { + pub remove: Vec, + pub bytes: u64, + pub kept: usize, +} + +/// Which package files go: for every package (and architecture), all but the +/// newest `keep` versions, together with their signatures. `files` are +/// (path, size) pairs. +pub fn plan_cache(files: &[(PathBuf, u64)], keep: usize) -> CachePlan { + // (name, arch) -> version -> files; signatures follow the package. + type Versions<'a> = BTreeMap>; + let mut groups: BTreeMap<(String, String), Versions> = BTreeMap::new(); + for (path, size) in files { + let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some(parsed) = parse_cache_file(file_name) else { + continue; + }; + groups + .entry((parsed.name, parsed.arch)) + .or_default() + .entry(parsed.version) + .or_default() + .push((path.as_path(), *size)); + } + let mut plan = CachePlan::default(); + for versions in groups.into_values() { + let mut versions: Vec<_> = versions.into_iter().collect(); + // Newest first. + versions.sort_by(|a, b| vercmp::vercmp(&b.0, &a.0)); + for (index, (_, paths)) in versions.into_iter().enumerate() { + if index < keep { + plan.kept += 1; + continue; + } + for (path, size) in paths { + plan.remove.push(path.to_path_buf()); + plan.bytes += size; + } + } + } + plan.remove.sort(); + plan +} + +fn cache_files(dir: &Path) -> Vec<(PathBuf, u64)> { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|e| { + let meta = e.metadata().ok()?; + meta.is_file().then(|| (e.path(), meta.len())) + }) + .collect() +} + +/// Rotated logs: `messages.1`, `Xorg.0.log.old`, `syslog-20240101`, +/// `pacman.log.1.gz`, `auth.log-2024.xz`. The live log files are never +/// matched. +pub fn is_rotated_log(name: &str) -> bool { + let compressed = [".gz", ".xz", ".zst", ".bz2", ".lz4"] + .iter() + .find_map(|ext| name.strip_suffix(ext)); + let stem = compressed.unwrap_or(name); + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + if stem.ends_with(".old") { + return true; + } + // "name.1", "name.12" + if let Some((base, n)) = stem.rsplit_once('.') + && !base.is_empty() + && digits(n) + { + return true; + } + // "name-20240101", "name-2024" + if let Some((base, date)) = stem.rsplit_once('-') + && !base.is_empty() + && date.len() >= 4 + && digits(date) + { + return true; + } + // A compressed copy of a log. + compressed.is_some() && stem.contains(".log") +} + +fn collect_old_logs(dir: &Path, depth: u32, older_than: Duration, out: &mut Vec<(PathBuf, u64)>) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let now = SystemTime::now(); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + if meta.is_dir() { + if depth > 0 { + collect_old_logs(&path, depth - 1, older_than, out); + } + continue; + } + let rotated = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(is_rotated_log); + let old = meta + .modified() + .ok() + .and_then(|m| now.duration_since(m).ok()) + .is_some_and(|age| age >= older_than); + if meta.is_file() && rotated && old { + out.push((path, meta.len())); + } + } +} + +fn orphans() -> Option> { + sys::which("pacman")?; + // pacman -Qdtq exits 1 with no output when there are none. + let out = sys::run("pacman", &["-Qdtq"]).ok()?; + Some( + out.stdout + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(String::from) + .collect(), + ) +} + +fn print_list(items: &[PathBuf]) { + const SHOWN: usize = 8; + for p in items.iter().take(SHOWN) { + outln!(" {}", p.display()); + } + if items.len() > SHOWN { + outln!(" ... and {} more", items.len() - SHOWN); + } +} + +pub fn run(o: Options) -> Result<()> { + if o.apply { + sys::require_root("`seg clean --apply`")?; + if Path::new("/var/lib/pacman/db.lck").exists() { + return Err(Error::other( + "pacman is locked (/var/lib/pacman/db.lck). If no pacman runs, `seg doctor` explains how to clear it.", + )); + } + } + let verb = if o.apply { "Removing" } else { "Would remove" }; + outln!( + "{}", + bold(if o.apply { + "seg clean" + } else { + "seg clean (dry run: nothing is changed; --apply deletes)" + }) + ); + + // Orphans. + let orphans = orphans(); + match &orphans { + None => outln!(" orphans: pacman not found"), + Some(list) if list.is_empty() => outln!(" orphans: none"), + Some(list) => { + outln!( + " orphans: {} packages nothing needs ({})", + list.len(), + list.join(" ") + ); + } + } + + // Package cache. + let conf = fs::read_to_string("/etc/pacman.conf").unwrap_or_default(); + let mut cache = CachePlan::default(); + for dir in parse_cache_dirs(&conf) { + let plan = plan_cache(&cache_files(&dir), o.keep); + cache.kept += plan.kept; + cache.bytes += plan.bytes; + cache.remove.extend(plan.remove); + } + outln!( + " package cache: {verb} {} files ({}), keeping the newest {} versions of each package", + cache.remove.len(), + human_bytes(cache.bytes), + o.keep + ); + print_list(&cache.remove); + + // Logs. + let mut logs = Vec::new(); + collect_old_logs( + Path::new("/var/log"), + 3, + Duration::from_secs(o.log_days * 86_400), + &mut logs, + ); + let log_bytes: u64 = logs.iter().map(|(_, s)| s).sum(); + let log_paths: Vec = logs.into_iter().map(|(p, _)| p).collect(); + outln!( + " logs: {verb} {} rotated logs older than {} days ({})", + log_paths.len(), + o.log_days, + human_bytes(log_bytes) + ); + print_list(&log_paths); + + // ~/.cache: reported, never deleted. + if let Some(home) = sys::invoking_home() { + let cache_dir = home.join(".cache"); + let mut subdirs: Vec<(u64, String)> = fs::read_dir(&cache_dir) + .map(|d| { + d.flatten() + .map(|e| { + ( + sys::dir_size(&e.path()), + e.file_name().to_string_lossy().into_owned(), + ) + }) + .collect() + }) + .unwrap_or_default(); + let total: u64 = subdirs.iter().map(|(s, _)| s).sum(); + outln!( + " {}: {} (never deleted by seg)", + cache_dir.display(), + human_bytes(total) + ); + subdirs.sort_by(|a, b| b.cmp(a)); + for (size, name) in subdirs.into_iter().take(5) { + outln!(" {:>10} {name}", human_bytes(size)); + } + } + + if o.apply { + for path in cache.remove.iter().chain(&log_paths) { + fs::remove_file(path).context(format!("removing {}", path.display()))?; + } + if let Some(list) = orphans.filter(|l| !l.is_empty()) { + let mut args = vec!["-Rns", "--noconfirm"]; + args.extend(list.iter().map(String::as_str)); + sys::run_inherit("pacman", &args)?; + } + outln!("Done."); + } else { + let total = cache.bytes + log_bytes; + outln!( + "\nSegLinux could free {} of cache and logs. Run `sudo seg clean --apply` to do it.", + human_bytes(total) + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + fn files(names: &[&str]) -> Vec<(PathBuf, u64)> { + names + .iter() + .map(|n| (PathBuf::from("/c").join(n), 100)) + .collect() + } + + fn removed(plan: &CachePlan) -> Vec { + plan.remove + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn keeps_the_newest_two_versions() { + let f = files(&[ + "foo-1.0-1-x86_64.pkg.tar.zst", + "foo-1.0-1-x86_64.pkg.tar.zst.sig", + "foo-1.1-1-x86_64.pkg.tar.zst", + "foo-1.2-1-x86_64.pkg.tar.zst", + "foo-1.10-1-x86_64.pkg.tar.zst", + "bar-2.0-1-x86_64.pkg.tar.zst", + ]); + let plan = plan_cache(&f, 2); + // 1.10 and 1.2 stay (1.10 is newer than 1.2, not older as text). + assert_eq!( + removed(&plan), + [ + "foo-1.0-1-x86_64.pkg.tar.zst", + "foo-1.0-1-x86_64.pkg.tar.zst.sig", + "foo-1.1-1-x86_64.pkg.tar.zst" + ] + ); + assert_eq!(plan.bytes, 300); + assert_eq!(plan.kept, 3); + } + + #[test] + fn epochs_and_releases_order_versions() { + let f = files(&[ + "qt-6.7-1-x86_64.pkg.tar.zst", + "qt-1:5.15-1-x86_64.pkg.tar.zst", + "qt-6.7-2-x86_64.pkg.tar.zst", + ]); + let plan = plan_cache(&f, 1); + // The epoch makes 5.15 the newest. + assert_eq!( + removed(&plan), + ["qt-6.7-1-x86_64.pkg.tar.zst", "qt-6.7-2-x86_64.pkg.tar.zst"] + ); + } + + #[test] + fn architectures_and_names_are_separate_groups() { + let f = files(&[ + "lib-1.0-1-x86_64.pkg.tar.zst", + "lib-1.0-1-any.pkg.tar.zst", + "lib-2.0-1-x86_64.pkg.tar.zst", + "lib-git-1.0-1-x86_64.pkg.tar.zst", + ]); + assert!( + plan_cache(&f, 1) + .remove + .iter() + .all(|p| p.ends_with("lib-1.0-1-x86_64.pkg.tar.zst")) + ); + assert!(plan_cache(&f, 2).remove.is_empty()); + } + + #[test] + fn foreign_files_are_never_planned() { + let f = files(&[ + "download-x", + "foo-1.0-1-x86_64.pkg.tar.zst.part", + "notes.txt", + ]); + assert_eq!(plan_cache(&f, 0), CachePlan::default()); + } + + #[test] + fn keep_zero_removes_everything() { + let f = files(&["a-1-1-any.pkg.tar.xz", "a-2-1-any.pkg.tar.xz"]); + assert_eq!(plan_cache(&f, 0).remove.len(), 2); + } + + #[test] + fn cache_dirs_from_pacman_conf() { + let conf = "[options]\n#CacheDir = /nope\nCacheDir = /var/cache/pacman/pkg/\nCacheDir=/mnt/cache\nHoldPkg = pacman\n"; + assert_eq!( + parse_cache_dirs(conf), + [ + PathBuf::from("/var/cache/pacman/pkg/"), + PathBuf::from("/mnt/cache") + ] + ); + assert_eq!( + parse_cache_dirs("[options]\n"), + [PathBuf::from("/var/cache/pacman/pkg")] + ); + } + + #[test] + fn rotated_logs_only() { + for yes in [ + "messages.1", + "messages.12", + "Xorg.0.log.old", + "syslog-20240101.gz", + "pacman.log.1.gz", + "auth.log-2024.xz", + "daemon.log.3.zst", + ] { + assert!(is_rotated_log(yes), "{yes}"); + } + for no in [ + "messages", + "pacman.log", + "Xorg.0.log", + "wtmp", + "lastlog", + "syslog.gz.bak", + "1", + ".1", + "kern.log", + ] { + assert!(!is_rotated_log(no), "{no}"); + } + } + + #[test] + fn old_rotated_logs_are_found_below_the_depth_limit() { + let t = TempDir::new(); + t.put("messages", "live"); + t.put("messages.1", "old"); + t.put("sub/a.log.2", "old"); + t.put("sub/deep/er/est/z.1", "too deep"); + let mut found = Vec::new(); + // Everything is brand new, so a zero age finds it and a day does not. + collect_old_logs(t.path(), 3, Duration::from_secs(86_400), &mut found); + assert!(found.is_empty()); + collect_old_logs(t.path(), 2, Duration::ZERO, &mut found); + let mut names: Vec<_> = found + .iter() + .map(|(p, _)| { + p.strip_prefix(t.path()) + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + assert_eq!(names, ["messages.1", "sub/a.log.2"]); + } +} diff --git a/tools/seg/segctl/src/doctor.rs b/tools/seg/segctl/src/doctor.rs new file mode 100644 index 0000000..6ac97c9 --- /dev/null +++ b/tools/seg/segctl/src/doctor.rs @@ -0,0 +1,600 @@ +//! `seg doctor`: looks for the problems that most often break an Artix +//! system and prints the fix for each one it finds. Read-only. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use crate::error::Result; +use crate::info::{self, DfRow}; +use crate::init; +use crate::svc::{Service, State, Svc}; +use crate::sys::{self, bold, human_bytes}; + +pub const HELP: &str = "\ +Usage: seg doctor + +Checks for: services that are enabled but not running, firmware the kernel +could not load, low disk space, a stale pacman lock (db.lck) and a missing +or incomplete pacman keyring. For each problem it prints the command that +fixes it. Exits with status 1 when it found something. + +Some checks need root to read the kernel log and the keyring: run +`sudo seg doctor` to get all of them. +"; + +#[derive(Debug, PartialEq, Eq)] +pub struct Problem { + pub detail: String, + pub fix: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + Healthy(String), + Problems(Vec), + /// The check could not run here. + Skipped(String), +} + +/// Services that start at boot but are not running. +pub fn failed_services(list: &[Service]) -> Vec<&Service> { + list.iter() + .filter(|s| s.enabled && matches!(s.state, State::Crashed | State::Stopped)) + .collect() +} + +fn services_problems(list: &[Service], init: init::Init) -> Outcome { + let failed = failed_services(list); + if failed.is_empty() { + let known = list.iter().any(|s| s.state != State::Unknown); + return Outcome::Healthy(if known { + "every enabled service is running".into() + } else { + "no service state to check".into() + }); + } + let start = |name: &str| match init { + init::Init::OpenRc => { + format!("sudo rc-service {name} start (then: rc-service {name} status)") + } + init::Init::Runit => format!( + "sudo sv start /etc/runit/runsvdir/default/{name} (log: /var/log/{name}/current)" + ), + init::Init::Dinit => format!("sudo dinitctl start {name} (log: dinitctl catlog {name})"), + init::Init::S6 => format!("sudo s6-rc -u change {name}"), + }; + Outcome::Problems( + failed + .into_iter() + .map(|s| Problem { + detail: format!("{} is enabled but {}", s.name, s.state.label()), + fix: format!( + "{}; if it should not start at boot: sudo seg svc disable {}", + start(&s.name), + s.name + ), + }) + .collect(), + ) +} + +/// Whether a log line reports error -2 (ENOENT), and not -22 or -20. +fn is_enoent(line: &str) -> bool { + ["error -2", "(-2"].iter().any(|needle| { + line.match_indices(needle).any(|(at, _)| { + !line + .as_bytes() + .get(at + needle.len()) + .is_some_and(u8::is_ascii_digit) + }) + }) +} + +/// File names the kernel could not load firmware for, from `dmesg`. +pub fn parse_missing_firmware(dmesg: &str) -> BTreeSet { + let mut files = BTreeSet::new(); + for line in dmesg.lines() { + let found = if let Some(rest) = line.split("Direct firmware load for ").nth(1) { + rest.split_whitespace().next() + } else if let Some(rest) = line.split("firmware: failed to load ").nth(1) { + rest.split_whitespace().next() + } else if let Some(rest) = line.split("Failed to load firmware \"").nth(1) { + rest.split('"').next() + } else { + None + }; + if let Some(name) = found.map(|n| n.trim_end_matches([',', '.', ';'])) { + // Only "file not found" (-2) is a missing file; other errors mean + // a bad file, which a package cannot fix. + let missing = is_enoent(line) || line.contains("Failed to load firmware"); + if !name.is_empty() && missing { + files.insert(name.to_string()); + } + } + } + files +} + +fn firmware_outcome(dmesg: Option<&str>) -> Outcome { + let Some(text) = dmesg else { + return Outcome::Skipped("cannot read the kernel log; run `sudo seg doctor`".into()); + }; + let files = parse_missing_firmware(text); + if files.is_empty() { + return Outcome::Healthy("the kernel found all the firmware it asked for".into()); + } + let first = files.iter().next().cloned().unwrap_or_default(); + Outcome::Problems(vec![Problem { + detail: format!( + "the kernel could not load {} firmware file{}: {}. Drivers that try a newer version first and fall back (iwlwifi, amdgpu) are harmless when the device works", + files.len(), + if files.len() == 1 { "" } else { "s" }, + files.iter().take(6).cloned().collect::>().join(", ") + + if files.len() > 6 { ", ..." } else { "" } + ), + fix: format!( + "sudo pacman -Fy && pacman -F usr/lib/firmware/{first} (install the package it names, or linux-firmware), then reboot" + ), + }]) +} + +const BOOT_MOUNTS: [&str; 3] = ["/boot", "/boot/efi", "/efi"]; +const WATCHED_MOUNTS: [&str; 4] = ["/", "/home", "/var", "/usr"]; + +/// File systems that are nearly full. The boot partition is small by design, +/// so it only counts when it has almost nothing left. +pub fn low_space(rows: &[DfRow]) -> Vec<&DfRow> { + const GIB_KIB: u64 = 1024 * 1024; + rows.iter() + .filter(|r| { + if BOOT_MOUNTS.contains(&r.mount.as_str()) { + r.avail_kib < 20 * 1024 + } else if WATCHED_MOUNTS.contains(&r.mount.as_str()) { + r.avail_kib < GIB_KIB || (r.use_percent >= 90 && r.avail_kib < 5 * GIB_KIB) + } else { + false + } + }) + .collect() +} + +fn disk_outcome(rows: &[DfRow]) -> Outcome { + if rows.is_empty() { + return Outcome::Skipped("`df` gave no answer".into()); + } + let low = low_space(rows); + if low.is_empty() { + return Outcome::Healthy("enough free space on /, /home, /var and /boot".into()); + } + Outcome::Problems( + low.into_iter() + .map(|r| Problem { + detail: format!( + "{} is {}% full, {} left", + r.mount, + r.use_percent, + human_bytes(r.avail_kib * 1024) + ), + fix: if BOOT_MOUNTS.contains(&r.mount.as_str()) { + "remove old kernels and initramfs images you do not boot (pacman -Rns the old kernel package)".into() + } else { + "sudo seg clean --apply (then: du -xh --max-depth=1 / | sort -h | tail)".into() + }, + }) + .collect(), + ) +} + +/// A lock is stale when pacman is not running. +pub fn lock_is_stale(lock_exists: bool, pacman_running: bool) -> bool { + lock_exists && !pacman_running +} + +fn pacman_running(proc: &Path) -> bool { + let Ok(entries) = fs::read_dir(proc) else { + return false; + }; + entries.flatten().any(|e| { + e.file_name() + .to_string_lossy() + .bytes() + .all(|b| b.is_ascii_digit()) + && sys::read_trimmed(e.path().join("comm")).as_deref() == Some("pacman") + }) +} + +fn lock_outcome(lock: &Path, proc: &Path) -> Outcome { + let exists = lock.exists(); + if !exists { + return Outcome::Healthy("pacman is not locked".into()); + } + if lock_is_stale(exists, pacman_running(proc)) { + Outcome::Problems(vec![Problem { + detail: format!("{} exists but pacman is not running", lock.display()), + fix: format!("sudo rm {}", lock.display()), + }]) + } else { + Outcome::Healthy("pacman is running (its lock is in use)".into()) + } +} + +/// Section names of pacman.conf, without `options`. +pub fn enabled_repos(conf: &str) -> Vec { + conf.lines() + .filter_map(|l| { + let l = l.trim(); + let name = l.strip_prefix('[')?.strip_suffix(']')?; + (name != "options").then(|| name.to_string()) + }) + .collect() +} + +pub enum Keys<'a> { + Listed(&'a str), + Failed(&'a str), + Unavailable, +} + +pub struct KeyringState<'a> { + pub gnupg_dir: bool, + pub artix_keyring_installed: bool, + pub keys: Keys<'a>, + pub arch_repos: bool, +} + +pub fn analyze_keyring(k: &KeyringState) -> Vec { + let mut problems = Vec::new(); + let reinit = "sudo pacman-key --init && sudo pacman-key --populate artix"; + if !k.gnupg_dir { + problems.push(Problem { + detail: "pacman's keyring does not exist (/etc/pacman.d/gnupg)".into(), + fix: reinit.into(), + }); + } + if !k.artix_keyring_installed { + problems.push(Problem { + detail: "the artix-keyring package is not installed".into(), + fix: "sudo pacman -Sy artix-keyring && sudo pacman-key --populate artix".into(), + }); + } + match k.keys { + Keys::Listed(text) => { + if !text.contains("Artix") { + problems.push(Problem { + detail: "the keyring has no Artix packager keys, so packages fail with \"signature is unknown trust\"".into(), + fix: "sudo pacman-key --populate artix".into(), + }); + } + if k.arch_repos && !text.contains("Arch Linux") { + problems.push(Problem { + detail: + "the Arch repositories are enabled but the keyring has no Arch Linux keys" + .into(), + fix: "sudo pacman -S archlinux-keyring && sudo pacman-key --populate archlinux" + .into(), + }); + } + } + Keys::Failed(err) if k.gnupg_dir => problems.push(Problem { + detail: format!("pacman-key cannot read the keyring: {}", err.trim()), + fix: format!("sudo rm -rf /etc/pacman.d/gnupg && {reinit}"), + }), + Keys::Failed(_) | Keys::Unavailable => {} + } + problems +} + +fn keyring_outcome(root_user: bool) -> Outcome { + let conf = fs::read_to_string("/etc/pacman.conf").unwrap_or_default(); + let repos = enabled_repos(&conf); + let arch_repos = repos + .iter() + .any(|r| matches!(r.as_str(), "core" | "extra" | "multilib")); + let artix_keyring_installed = fs::read_dir("/var/lib/pacman/local") + .map(|d| { + d.flatten().any(|e| { + e.file_name() + .to_string_lossy() + .starts_with("artix-keyring-") + }) + }) + .unwrap_or(false); + let listed = if sys::which("pacman-key").is_some() { + sys::run("pacman-key", &["--list-keys"]).ok() + } else { + None + }; + // Without root the keyring may simply be unreadable. + let unreadable = !root_user + && listed + .as_ref() + .is_some_and(|o| !o.ok && o.stderr.contains("ermission")); + let keys = match &listed { + Some(o) if o.ok => Keys::Listed(&o.stdout), + Some(_) if unreadable => Keys::Unavailable, + Some(o) => Keys::Failed(&o.stderr), + None => Keys::Unavailable, + }; + let problems = analyze_keyring(&KeyringState { + gnupg_dir: Path::new("/etc/pacman.d/gnupg").exists(), + artix_keyring_installed, + keys, + arch_repos, + }); + if !problems.is_empty() { + Outcome::Problems(problems) + } else if unreadable { + Outcome::Skipped("cannot read the keyring; run `sudo seg doctor`".into()) + } else if listed.is_none() { + Outcome::Skipped("pacman-key is not installed".into()) + } else { + Outcome::Healthy("pacman's keyring has the Artix keys".into()) + } +} + +fn print_outcome(name: &str, outcome: &Outcome) -> bool { + match outcome { + Outcome::Healthy(msg) => outln!("[ ok ] {name:<10}{msg}"), + Outcome::Skipped(msg) => outln!("[skip] {name:<10}{msg}"), + Outcome::Problems(problems) => { + for p in problems { + outln!("[FAIL] {name:<10}{}", p.detail); + outln!(" {:<10}fix: {}", "", p.fix); + } + } + } + !matches!(outcome, Outcome::Problems(_)) +} + +/// Runs every check. Returns whether the system is healthy. +pub fn run() -> Result { + outln!("{}", bold("seg doctor")); + let root_user = sys::euid().map(|u| u == 0).unwrap_or(false); + + let services = match init::detect_running() { + None => Outcome::Skipped("no supported init system found".into()), + Some(init) => match Svc::new(init, "/").list() { + Ok(list) => services_problems(&list, init), + Err(e) => Outcome::Skipped(e.to_string()), + }, + }; + let dmesg = sys::run("dmesg", &[]) + .ok() + .filter(|o| o.ok) + .map(|o| o.stdout); + let outcomes = [ + ("services", services), + ("firmware", firmware_outcome(dmesg.as_deref())), + ("disk", disk_outcome(&info::df())), + ( + "pacman", + lock_outcome(Path::new("/var/lib/pacman/db.lck"), Path::new("/proc")), + ), + ("keyring", keyring_outcome(root_user)), + ]; + let mut healthy = true; + for (name, outcome) in &outcomes { + healthy &= print_outcome(name, outcome); + } + if healthy { + outln!("\nNo problems found."); + } else { + outln!("\nProblems found: run the fix shown under each one."); + } + Ok(healthy) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::init::Init; + use crate::sys::testutil::TempDir; + + fn svc(name: &str, enabled: bool, state: State) -> Service { + Service { + name: name.into(), + enabled, + state, + } + } + + fn row(mount: &str, avail_kib: u64, use_percent: u8) -> DfRow { + DfRow { + filesystem: "/dev/x".into(), + size_kib: 100_000_000, + used_kib: 0, + avail_kib, + use_percent, + mount: mount.into(), + } + } + + #[test] + fn only_enabled_services_that_are_down_are_failures() { + let list = [ + svc("a", true, State::Started), + svc("b", true, State::Crashed), + svc("c", true, State::Stopped), + svc("d", false, State::Stopped), + svc("e", true, State::Unknown), + ]; + let failed: Vec<_> = failed_services(&list) + .iter() + .map(|s| s.name.as_str()) + .collect(); + assert_eq!(failed, ["b", "c"]); + } + + #[test] + fn the_service_fix_names_the_inits_own_command() { + let list = [svc("cupsd", true, State::Crashed)]; + let Outcome::Problems(p) = services_problems(&list, Init::OpenRc) else { + panic!("no problem") + }; + assert!(p[0].detail.contains("cupsd is enabled but crashed")); + assert!(p[0].fix.contains("rc-service cupsd start")); + assert!(p[0].fix.contains("seg svc disable cupsd")); + let Outcome::Problems(p) = services_problems(&list, Init::Dinit) else { + panic!("no problem") + }; + assert!(p[0].fix.contains("dinitctl start cupsd")); + assert_eq!( + services_problems(&[svc("a", true, State::Started)], Init::OpenRc), + Outcome::Healthy("every enabled service is running".into()) + ); + } + + #[test] + fn firmware_failures_from_dmesg() { + let log = "\ +[ 3.1] r8169 0000:02:00.0: Direct firmware load for rtl_nic/rtl8168h-2.fw failed with error -2 +[ 3.2] r8169 0000:02:00.0: Unable to load firmware rtl_nic/rtl8168h-2.fw (-2) +[ 4.0] iwlwifi 0000:03:00.0: Direct firmware load for iwlwifi-cc-a0-77.ucode failed with error -2 +[ 5.0] bluetooth hci0: Direct firmware load for intel/ibt-0040-0041.sfi failed with error -2 +[ 6.0] foo: firmware: failed to load bar/baz.bin (-2) +[ 6.1] foo: Failed to load firmware \"old/style.fw\" +[ 7.0] amdgpu: Direct firmware load for amdgpu/x.bin failed with error -22 +[ 8.0] usb 1-1: new high-speed USB device +"; + let files = parse_missing_firmware(log); + assert_eq!( + files.into_iter().collect::>(), + [ + "bar/baz.bin", + "intel/ibt-0040-0041.sfi", + "iwlwifi-cc-a0-77.ucode", + "old/style.fw", + "rtl_nic/rtl8168h-2.fw" + ] + ); + assert!(parse_missing_firmware("").is_empty()); + } + + #[test] + fn firmware_outcomes() { + assert!(matches!(firmware_outcome(None), Outcome::Skipped(_))); + assert!(matches!( + firmware_outcome(Some("all fine\n")), + Outcome::Healthy(_) + )); + let Outcome::Problems(p) = firmware_outcome(Some( + "x: Direct firmware load for a/b.fw failed with error -2\n", + )) else { + panic!("no problem") + }; + assert!(p[0].detail.contains("1 firmware file: a/b.fw")); + assert!(p[0].fix.contains("pacman -F usr/lib/firmware/a/b.fw")); + } + + #[test] + fn low_disk_space() { + let rows = [ + row("/", 500 * 1024, 99), // 500 MiB left + row("/home", 400 * 1024 * 1024, 40), // plenty + row("/var", 3 * 1024 * 1024, 93), // 3 GiB left at 93% + row("/boot", 10 * 1024, 95), // 10 MiB: nearly nothing + row("/boot/efi", 90 * 1024, 70), // small by design, fine + row("/mnt/data", 1, 100), // not watched + row("/usr", 50 * 1024 * 1024, 92), // 92% but 50 GiB left + ]; + let low: Vec<_> = low_space(&rows).iter().map(|r| r.mount.as_str()).collect(); + assert_eq!(low, ["/", "/var", "/boot"]); + let Outcome::Problems(p) = disk_outcome(&rows) else { + panic!("no problem") + }; + assert!(p[0].detail.contains("/ is 99% full")); + assert!(p[0].fix.contains("seg clean")); + assert!(p[2].fix.contains("old kernels")); + assert!(matches!(disk_outcome(&[]), Outcome::Skipped(_))); + } + + #[test] + fn a_lock_is_stale_only_without_a_running_pacman() { + assert!(lock_is_stale(true, false)); + assert!(!lock_is_stale(true, true)); + assert!(!lock_is_stale(false, false)); + } + + #[test] + fn lock_check_looks_for_a_pacman_process() { + let t = TempDir::new(); + let lock = t.put("db.lck", ""); + t.put("proc/1/comm", "init\n"); + t.put("proc/42/comm", "bash\n"); + let Outcome::Problems(p) = lock_outcome(&lock, &t.path().join("proc")) else { + panic!("no problem") + }; + assert!(p[0].fix.starts_with("sudo rm ")); + t.put("proc/43/comm", "pacman\n"); + assert!(matches!( + lock_outcome(&lock, &t.path().join("proc")), + Outcome::Healthy(_) + )); + fs::remove_file(&lock).unwrap(); + assert_eq!( + lock_outcome(&lock, &t.path().join("proc")), + Outcome::Healthy("pacman is not locked".into()) + ); + } + + #[test] + fn repos_of_pacman_conf() { + let conf = "[options]\nColor\n[system]\nInclude = x\n# [extra]\n[world]\n[lib32]\n"; + assert_eq!(enabled_repos(conf), ["system", "world", "lib32"]); + assert_eq!(enabled_repos("[options]\n"), Vec::::new()); + } + + #[test] + fn keyring_analysis() { + let healthy = KeyringState { + gnupg_dir: true, + artix_keyring_installed: true, + keys: Keys::Listed("uid [ full ] Artix Linux Master Key"), + arch_repos: false, + }; + assert!(analyze_keyring(&healthy).is_empty()); + + let empty = KeyringState { + gnupg_dir: false, + artix_keyring_installed: false, + keys: Keys::Unavailable, + arch_repos: false, + }; + let p = analyze_keyring(&empty); + assert_eq!(p.len(), 2); + assert!(p[0].fix.contains("pacman-key --init")); + assert!(p[1].fix.contains("pacman -Sy artix-keyring")); + + let unpopulated = KeyringState { + keys: Keys::Listed("pubring.kbx\n-----\n"), + ..healthy + }; + let p = analyze_keyring(&unpopulated); + assert_eq!(p.len(), 1); + assert_eq!(p[0].fix, "sudo pacman-key --populate artix"); + + let arch = KeyringState { + gnupg_dir: true, + artix_keyring_installed: true, + keys: Keys::Listed("Artix"), + arch_repos: true, + }; + let p = analyze_keyring(&arch); + assert_eq!(p.len(), 1); + assert!(p[0].fix.contains("archlinux-keyring")); + + let broken = KeyringState { + gnupg_dir: true, + artix_keyring_installed: true, + keys: Keys::Failed("gpg: keyblock resource: No such file\n"), + arch_repos: false, + }; + let p = analyze_keyring(&broken); + assert!( + p[0].detail + .contains("cannot read the keyring: gpg: keyblock resource") + ); + assert!(p[0].fix.contains("rm -rf /etc/pacman.d/gnupg")); + } +} diff --git a/tools/seg/segctl/src/error.rs b/tools/seg/segctl/src/error.rs new file mode 100644 index 0000000..b29f49e --- /dev/null +++ b/tools/seg/segctl/src/error.rs @@ -0,0 +1,109 @@ +//! One error type for the whole tool, so `main` can print it and pick the +//! exit status. No panics on user-facing paths: everything returns this. + +use std::fmt; +use std::io; + +#[derive(Debug)] +pub enum Error { + /// Wrong command line; the exit status is 2. + Usage(String), + /// The command writes to the system and must run as root. + NeedRoot(String), + Io { + context: String, + source: io::Error, + }, + /// A program could not be started, or failed. + Command { + program: String, + detail: String, + }, + /// Something this system does not have (no init found, no zram module). + Unsupported(String), + /// Anything else, already worded for the user. + Other(String), +} + +pub type Result = std::result::Result; + +impl Error { + pub fn io(context: impl Into, source: io::Error) -> Self { + Error::Io { + context: context.into(), + source, + } + } + + pub fn other(message: impl Into) -> Self { + Error::Other(message.into()) + } + + pub fn exit_code(&self) -> u8 { + match self { + Error::Usage(_) => 2, + _ => 1, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Usage(m) => write!(f, "{m}\nTry `seg --help`."), + Error::NeedRoot(what) => write!( + f, + "{what} needs root. Run it again with sudo: `sudo seg ...`." + ), + Error::Io { context, source } => write!(f, "{context}: {source}"), + Error::Command { program, detail } => write!(f, "`{program}`: {detail}"), + Error::Unsupported(m) | Error::Other(m) => f.write_str(m), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: lexopt::Error) -> Self { + Error::Usage(e.to_string()) + } +} + +/// `.context("reading /proc/meminfo")?` on io results. +pub trait Context { + fn context(self, what: impl Into) -> Result; +} + +impl Context for io::Result { + fn context(self, what: impl Into) -> Result { + self.map_err(|e| Error::io(what, e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usage_errors_exit_2_and_others_1() { + assert_eq!(Error::Usage("x".into()).exit_code(), 2); + assert_eq!(Error::NeedRoot("tune".into()).exit_code(), 1); + assert_eq!(Error::other("x").exit_code(), 1); + } + + #[test] + fn root_error_names_the_command_and_the_fix() { + let text = Error::NeedRoot("`seg tune performance`".into()).to_string(); + assert!(text.contains("`seg tune performance` needs root")); + assert!(text.contains("sudo")); + } + + #[test] + fn io_errors_keep_their_context() { + let e = Err::<(), _>(io::Error::from(io::ErrorKind::NotFound)) + .context("reading /proc/meminfo") + .unwrap_err(); + assert!(e.to_string().starts_with("reading /proc/meminfo: ")); + } +} diff --git a/tools/seg/segctl/src/info.rs b/tools/seg/segctl/src/info.rs new file mode 100644 index 0000000..a04cd9e --- /dev/null +++ b/tools/seg/segctl/src/info.rs @@ -0,0 +1,213 @@ +//! `seg info`: a fast fetch-style summary. Reads /proc and /etc directly; +//! the only program it starts is `df`. + +use std::fs; +use std::path::Path; + +use crate::error::Result; +use crate::init; +use crate::sys::{self, bold, human_bytes, human_duration}; + +/// `PRETTY_NAME` (else `NAME`) from os-release text. +pub fn parse_os_release(text: &str) -> Option { + let value = |key: &str| { + text.lines().find_map(|l| { + let v = l.strip_prefix(key)?.strip_prefix('=')?; + Some(v.trim().trim_matches('"').trim_matches('\'').to_string()) + }) + }; + value("PRETTY_NAME") + .or_else(|| value("NAME")) + .filter(|s| !s.is_empty()) +} + +/// Seconds up, from /proc/uptime (`12345.67 54321.00`). +pub fn parse_uptime(text: &str) -> Option { + let secs: f64 = text.split_whitespace().next()?.parse().ok()?; + (secs >= 0.0).then_some(secs as u64) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Memory { + pub total_kib: u64, + pub available_kib: u64, +} + +impl Memory { + pub fn used_kib(&self) -> u64 { + self.total_kib.saturating_sub(self.available_kib) + } +} + +pub fn parse_meminfo(text: &str) -> Option { + let field = |key: &str| -> Option { + text.lines() + .find(|l| l.starts_with(key))? + .split_whitespace() + .nth(1)? + .parse() + .ok() + }; + Some(Memory { + total_kib: field("MemTotal:")?, + available_kib: field("MemAvailable:").or_else(|| field("MemFree:"))?, + }) +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct DfRow { + pub filesystem: String, + pub size_kib: u64, + pub used_kib: u64, + pub avail_kib: u64, + pub use_percent: u8, + pub mount: String, +} + +/// `df -Pk` output (POSIX format: one line per file system). +pub fn parse_df(text: &str) -> Vec { + text.lines() + .skip(1) + .filter_map(|line| { + let mut f = line.split_whitespace(); + let filesystem = f.next()?.to_string(); + let size_kib = f.next()?.parse().ok()?; + let used_kib = f.next()?.parse().ok()?; + let avail_kib = f.next()?.parse().ok()?; + let use_percent = f.next()?.trim_end_matches('%').parse().ok()?; + let mount = f.collect::>().join(" "); + (!mount.is_empty()).then_some(DfRow { + filesystem, + size_kib, + used_kib, + avail_kib, + use_percent, + mount, + }) + }) + .collect() +} + +pub fn df() -> Vec { + sys::run("df", &["-Pk"]) + .map(|o| parse_df(&o.stdout)) + .unwrap_or_default() +} + +/// Installed packages: one directory per package in pacman's local database. +pub fn count_packages(local_db: &Path) -> Option { + let entries = fs::read_dir(local_db).ok()?; + Some(entries.flatten().filter(|e| e.path().is_dir()).count()) +} + +pub fn run() -> Result<()> { + let distro = sys::read_trimmed("/etc/os-release") + .or_else(|| sys::read_trimmed("/usr/lib/os-release")) + .and_then(|t| parse_os_release(&t)) + .unwrap_or_else(|| "Linux".into()); + let kernel = sys::read_trimmed("/proc/sys/kernel/osrelease").unwrap_or_else(|| "?".into()); + let init = init::detect_running().map_or("unknown", |i| i.name()); + let uptime = sys::read_trimmed("/proc/uptime") + .and_then(|t| parse_uptime(&t)) + .map_or_else(|| "?".into(), human_duration); + let memory = sys::read_trimmed("/proc/meminfo") + .and_then(|t| parse_meminfo(&t)) + .map_or_else( + || "?".into(), + |m| { + let pct = m.used_kib() * 100 / m.total_kib.max(1); + format!( + "{} / {} ({pct}%)", + human_bytes(m.used_kib() * 1024), + human_bytes(m.total_kib * 1024) + ) + }, + ); + let packages = count_packages(Path::new("/var/lib/pacman/local")) + .map_or_else(|| "?".into(), |n| format!("{n} (pacman)")); + let disk = df().into_iter().find(|r| r.mount == "/").map_or_else( + || "?".into(), + |r| { + format!( + "{} / {} ({}%)", + human_bytes(r.used_kib * 1024), + human_bytes(r.size_kib * 1024), + r.use_percent + ) + }, + ); + + outln!("{}", bold(&distro)); + for (label, value) in [ + ("Kernel", kernel), + ("Init", init.to_string()), + ("Uptime", uptime), + ("Memory", memory), + ("Packages", packages), + ("Disk /", disk), + ] { + outln!(" {label:<9}{value}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[test] + fn os_release_prefers_the_pretty_name() { + let t = "NAME=\"SegLinux\"\nPRETTY_NAME=\"SegLinux (Artix)\"\nID=seglinux\n"; + assert_eq!(parse_os_release(t), Some("SegLinux (Artix)".into())); + assert_eq!( + parse_os_release("NAME='Artix Linux'\n"), + Some("Artix Linux".into()) + ); + assert_eq!(parse_os_release("ID=x\n"), None); + assert_eq!(parse_os_release("PRETTY_NAME=\"\"\n"), None); + } + + #[test] + fn uptime_and_memory() { + assert_eq!(parse_uptime("8100.52 30000.10\n"), Some(8100)); + assert_eq!(parse_uptime(""), None); + assert_eq!(parse_uptime("-1 2"), None); + + let m = parse_meminfo( + "MemTotal: 16000000 kB\nMemFree: 1000000 kB\nMemAvailable: 12000000 kB\n", + ) + .unwrap(); + assert_eq!(m.used_kib(), 4_000_000); + // Old kernels have no MemAvailable. + let m = parse_meminfo("MemTotal: 100 kB\nMemFree: 40 kB\n").unwrap(); + assert_eq!(m.used_kib(), 60); + assert_eq!(parse_meminfo("MemTotal: 100 kB\n"), None); + } + + #[test] + fn df_rows() { + let text = "Filesystem 1024-blocks Used Available Capacity Mounted on\n\ + /dev/nvme0n1p2 244813824 21474836 210000000 9% /\n\ + tmpfs 8000000 0 8000000 0% /dev/shm\n\ + /dev/sdb1 1000000 500000 500000 50% /mnt/My Disk\n\ + garbage line\n"; + let rows = parse_df(text); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].mount, "/"); + assert_eq!(rows[0].use_percent, 9); + assert_eq!(rows[0].avail_kib, 210_000_000); + assert_eq!(rows[2].mount, "/mnt/My Disk"); + assert!(parse_df("").is_empty()); + } + + #[test] + fn package_count_counts_directories() { + let t = TempDir::new(); + t.put("local/bash-5.2-1/desc", ""); + t.put("local/glibc-2.40-1/desc", ""); + t.put("local/ALPM_DB_VERSION", "9\n"); + assert_eq!(count_packages(&t.path().join("local")), Some(2)); + assert_eq!(count_packages(&t.path().join("missing")), None); + } +} diff --git a/tools/seg/segctl/src/init.rs b/tools/seg/segctl/src/init.rs new file mode 100644 index 0000000..6cc35ac --- /dev/null +++ b/tools/seg/segctl/src/init.rs @@ -0,0 +1,138 @@ +//! Which init system is running. Artix offers four; `seg svc` speaks all of +//! them. + +use std::fmt; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Init { + OpenRc, + Runit, + Dinit, + S6, +} + +impl Init { + pub fn name(self) -> &'static str { + match self { + Init::OpenRc => "openrc", + Init::Runit => "runit", + Init::Dinit => "dinit", + Init::S6 => "s6", + } + } + + /// The name PID 1 gives itself in /proc/1/comm. + fn from_pid1(comm: &str) -> Option { + match comm.trim() { + "openrc-init" => Some(Init::OpenRc), + "runit" | "runit-init" => Some(Init::Runit), + "dinit" => Some(Init::Dinit), + "s6-svscan" | "s6-linux-init" | "s6-linux-init-maker" => Some(Init::S6), + _ => None, + } + } +} + +impl fmt::Display for Init { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Detect the init system of the system under `root` (`/` for the running +/// one). In order of certainty: +/// +/// 1. what PID 1 is (`pid1_comm`, the contents of /proc/1/comm); +/// 2. state the running init leaves in /run; +/// 3. what is installed, for an offline root or a container whose PID 1 is +/// something else. +pub fn detect(root: &Path, pid1_comm: Option<&str>) -> Option { + if let Some(init) = pid1_comm.and_then(Init::from_pid1) { + return Some(init); + } + let has = |rel: &str| root.join(rel).exists(); + const RUNNING: [(&str, Init); 4] = [ + ("run/openrc", Init::OpenRc), + ("run/runit", Init::Runit), + ("run/dinitctl", Init::Dinit), + ("run/s6-rc", Init::S6), + ]; + if let Some((_, init)) = RUNNING.iter().find(|(path, _)| has(path)) { + return Some(*init); + } + const INSTALLED: [(&str, Init); 7] = [ + ("usr/bin/openrc-init", Init::OpenRc), + ("usr/bin/openrc", Init::OpenRc), + ("usr/bin/dinit", Init::Dinit), + ("usr/bin/runit", Init::Runit), + ("usr/bin/runsvdir", Init::Runit), + ("usr/bin/s6-svscan", Init::S6), + ("usr/bin/s6-rc", Init::S6), + ]; + INSTALLED + .iter() + .find(|(path, _)| has(path)) + .map(|(_, init)| *init) +} + +/// Detect for the running system. +pub fn detect_running() -> Option { + let comm = std::fs::read_to_string("/proc/1/comm").ok(); + detect(Path::new("/"), comm.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[test] + fn pid1_decides_first() { + let t = TempDir::new(); + // Even if another init's files are around. + t.put("usr/bin/openrc", ""); + assert_eq!(detect(t.path(), Some("dinit\n")), Some(Init::Dinit)); + assert_eq!(detect(t.path(), Some("runit")), Some(Init::Runit)); + assert_eq!(detect(t.path(), Some("openrc-init")), Some(Init::OpenRc)); + assert_eq!(detect(t.path(), Some("s6-svscan")), Some(Init::S6)); + } + + #[test] + fn a_generic_pid1_falls_back_to_the_state_in_run() { + let t = TempDir::new(); + t.put("run/openrc/softlevel", "default"); + assert_eq!(detect(t.path(), Some("init")), Some(Init::OpenRc)); + let t = TempDir::new(); + t.put("run/dinitctl", ""); + assert_eq!(detect(t.path(), None), Some(Init::Dinit)); + let t = TempDir::new(); + t.put("run/runit/stopit", ""); + assert_eq!(detect(t.path(), None), Some(Init::Runit)); + } + + #[test] + fn installed_binaries_are_the_last_resort() { + let t = TempDir::new(); + assert_eq!(detect(t.path(), Some("bash")), None); + t.put("usr/bin/s6-svscan", ""); + assert_eq!(detect(t.path(), Some("bash")), Some(Init::S6)); + t.put("usr/bin/openrc", ""); + // The first match in the list wins: OpenRC before s6. + assert_eq!(detect(t.path(), None), Some(Init::OpenRc)); + } + + #[test] + fn running_state_beats_installed_binaries() { + let t = TempDir::new(); + t.put("usr/bin/openrc", ""); + t.put("run/dinitctl", ""); + assert_eq!(detect(t.path(), None), Some(Init::Dinit)); + } + + #[test] + fn names() { + assert_eq!(Init::OpenRc.to_string(), "openrc"); + assert_eq!(Init::S6.name(), "s6"); + } +} diff --git a/tools/seg/segctl/src/main.rs b/tools/seg/segctl/src/main.rs new file mode 100644 index 0000000..e2fae1c --- /dev/null +++ b/tools/seg/segctl/src/main.rs @@ -0,0 +1,234 @@ +//! seg: system information, services, cleaning, tuning, zram swap and +//! health checks for SegLinux, over runit, OpenRC, dinit and s6. + +// No unwrap or panic on paths a user can reach: errors are values. +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + ) +)] + +/// `print!` and `println!` that end quietly when the reader goes away +/// (`seg clean | head`) instead of panicking on the broken pipe. +macro_rules! out { + ($($arg:tt)*) => { $crate::sys::emit(format_args!($($arg)*), false) }; +} +macro_rules! outln { + ($($arg:tt)*) => { $crate::sys::emit(format_args!($($arg)*), true) }; +} + +mod clean; +mod doctor; +mod error; +mod info; +mod init; +mod svc; +mod sys; +mod tune; +mod vercmp; +mod zram; + +use std::process::ExitCode; + +use error::{Error, Result}; + +const HELP: &str = "\ +seg - look after a SegLinux system + +Usage: seg [options] + +Commands: + info distro, kernel, init, uptime, memory, packages, disk + svc list|enable|disable|start|stop [name] + services, on whichever init is running + clean [--apply] orphans, old cached packages, old logs (dry run by default) + tune light|balanced|performance|apply|status + memory, CPU and disk settings + zram on|off|status compressed swap in memory + doctor look for problems and print their fixes + +Options: + -h, --help show this help (also: seg --help) + -V, --version show the version +"; + +const SVC_HELP: &str = "\ +Usage: seg svc [name] + + list [--enabled] installed services: enabled at boot, and running or not + enable start at boot + disable do not start at boot + start start now + stop stop now + +Works on OpenRC, runit, dinit and s6 (detected). enable, disable, start and +stop need root; list does not. + +--offline pick the init from what is installed here, not from the running + PID 1: for package scripts that run in a chroot +"; + +fn main() -> ExitCode { + match run() { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::from(1), + Err(e) => { + sys::emit_err(format_args!("seg: {e}")); + ExitCode::from(e.exit_code()) + } + } +} + +/// Returns whether everything was fine (`doctor` reports problems this way). +fn run() -> Result { + use lexopt::prelude::*; + let mut parser = lexopt::Parser::from_env(); + let command = match parser.next()? { + None => { + out!("{HELP}"); + return Ok(true); + } + Some(Short('h') | Long("help")) => { + out!("{HELP}"); + return Ok(true); + } + Some(Short('V') | Long("version")) => { + outln!("seg {}", env!("CARGO_PKG_VERSION")); + return Ok(true); + } + Some(Value(v)) => v.string()?, + Some(other) => return Err(other.unexpected().into()), + }; + match command.as_str() { + "info" => { + no_more_args(&mut parser, "info")?; + info::run()?; + } + "svc" => svc_command(&mut parser)?, + "clean" => clean::run(clean::parse_args(&mut parser)?)?, + "tune" => tune::run(tune::parse_args(&mut parser)?)?, + "zram" => zram::run(zram::parse_args(&mut parser)?)?, + "doctor" => { + no_more_args(&mut parser, "doctor")?; + return doctor::run(); + } + other => { + return Err(Error::Usage(format!("unknown command `{other}`"))); + } + } + Ok(true) +} + +/// Commands without options of their own: `--help` or nothing. +fn no_more_args(parser: &mut lexopt::Parser, name: &str) -> Result<()> { + use lexopt::prelude::*; + match parser.next()? { + None => Ok(()), + Some(Short('h') | Long("help")) => { + match name { + "doctor" => out!("{}", doctor::HELP), + _ => outln!("Usage: seg {name}\n\nPrints a short summary of this system."), + } + std::process::exit(0); + } + Some(other) => Err(other.unexpected().into()), + } +} + +fn svc_command(parser: &mut lexopt::Parser) -> Result<()> { + use lexopt::prelude::*; + let mut action: Option = None; + let mut name: Option = None; + let mut only_enabled = false; + let mut offline = false; + while let Some(arg) = parser.next()? { + match arg { + Short('h') | Long("help") => { + out!("{SVC_HELP}"); + return Ok(()); + } + Long("enabled") => only_enabled = true, + Long("offline") => offline = true, + Value(v) if action.is_none() => action = Some(v.string()?), + Value(v) if name.is_none() => name = Some(v.string()?), + other => return Err(other.unexpected().into()), + } + } + let action = action.ok_or_else(|| { + Error::Usage("seg svc needs a command (list, enable, disable, start or stop)".into()) + })?; + let detected = if offline { + init::detect(std::path::Path::new("/"), None) + } else { + init::detect_running() + }; + let init = detected.ok_or_else(|| { + Error::Unsupported("no supported init system found (OpenRC, runit, dinit or s6)".into()) + })?; + let svc = svc::Svc::new(init, "/"); + let need_name = || { + name.clone() + .ok_or_else(|| Error::Usage(format!("seg svc {action} needs a service name"))) + }; + match action.as_str() { + "list" => { + outln!("{:<28}{:<9}STATE ({init})", "SERVICE", "BOOT"); + for s in svc + .list()? + .into_iter() + .filter(|s| !only_enabled || s.enabled) + { + outln!( + "{:<28}{:<9}{}", + s.name, + if s.enabled { "enabled" } else { "-" }, + s.state.label() + ); + } + } + "enable" => { + sys::require_root("`seg svc enable`")?; + let name = need_name()?; + match svc.enable(&name)? { + svc::Change::Enabled => { + outln!("{name} will start at boot. Start it now with: seg svc start {name}"); + } + _ => outln!("{name} already starts at boot"), + } + } + "disable" => { + sys::require_root("`seg svc disable`")?; + let name = need_name()?; + match svc.disable(&name)? { + svc::Change::Disabled => { + outln!("{name} will not start at boot. Stop it now with: seg svc stop {name}"); + } + _ => outln!("{name} already does not start at boot"), + } + } + "start" | "stop" => { + sys::require_root(if action == "start" { + "`seg svc start`" + } else { + "`seg svc stop`" + })?; + let name = need_name()?; + let act = if action == "start" { + svc::Action::Start + } else { + svc::Action::Stop + }; + svc.control(act, &name)?; + } + other => { + return Err(Error::Usage(format!( + "unknown svc command `{other}` (list, enable, disable, start or stop)" + ))); + } + } + Ok(()) +} diff --git a/tools/seg/segctl/src/svc.rs b/tools/seg/segctl/src/svc.rs new file mode 100644 index 0000000..21974bb --- /dev/null +++ b/tools/seg/segctl/src/svc.rs @@ -0,0 +1,735 @@ +//! `seg svc`: one interface over the services of OpenRC, runit, dinit and s6. +//! +//! Enabling and disabling only touch the files each init reads at boot (the +//! symlinks `rc-update`, `ln -s ... /etc/runit/runsvdir/default` and +//! `dinitctl enable` make), so they work on a running system, in a chroot and +//! on an offline root alike. Starting and stopping ask the running init. + +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use crate::error::{Context, Error, Result}; +use crate::init::Init; +use crate::sys; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + Started, + Stopped, + Crashed, + Unknown, +} + +impl State { + pub fn label(self) -> &'static str { + match self { + State::Started => "started", + State::Stopped => "stopped", + State::Crashed => "crashed", + State::Unknown => "-", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Service { + pub name: String, + pub enabled: bool, + pub state: State, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Start, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Change { + Enabled, + AlreadyEnabled, + Disabled, + AlreadyDisabled, +} + +/// Service names become file names under /etc: no paths, no hidden files. +pub fn valid_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('.') + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.@+".contains(c)) +} + +/// OpenRC services that belong in the `boot` runlevel rather than `default`. +fn openrc_runlevel(name: &str) -> &'static str { + const BOOT: &[&str] = &[ + "elogind", + "udev", + "udev-trigger", + "binfmt", + "sysctl", + "modules", + "hwclock", + "keymaps", + "hostname", + "localmount", + "lvm", + "dmcrypt", + "mdraid", + "haveged", + ]; + if BOOT.contains(&name) { + "boot" + } else { + "default" + } +} + +/// The services of one init system under `root` (`/` for the running one). +pub struct Svc { + init: Init, + root: PathBuf, +} + +impl Svc { + pub fn new(init: Init, root: impl Into) -> Self { + Svc { + init, + root: root.into(), + } + } + + fn p(&self, rel: &str) -> PathBuf { + self.root.join(rel.trim_start_matches('/')) + } + + fn live(&self) -> bool { + self.root == Path::new("/") + } + + /// Where the installed definition of `name` lives. + fn definition(&self, name: &str) -> String { + match self.init { + Init::OpenRc => format!("/etc/init.d/{name}"), + Init::Runit => format!("/etc/runit/sv/{name}"), + Init::Dinit => format!("/etc/dinit.d/{name}"), + Init::S6 => format!("/etc/s6/sv/{name}"), + } + } + + pub fn exists(&self, name: &str) -> bool { + self.p(&self.definition(name)).exists() + } + + fn check(&self, name: &str) -> Result<()> { + if !valid_name(name) { + return Err(Error::Usage(format!("`{name}` is not a service name"))); + } + if !self.exists(name) { + return Err(Error::other(format!( + "no service `{name}` for {} (expected {}); `seg svc list` shows the installed ones", + self.init, + self.definition(name) + ))); + } + Ok(()) + } + + /// Symlinks (or marker files) that make `name` start at boot. + fn boot_links(&self, name: &str) -> Vec<(String, Option)> { + match self.init { + Init::OpenRc => vec![( + format!("/etc/runlevels/{}/{name}", openrc_runlevel(name)), + Some(format!("/etc/init.d/{name}")), + )], + Init::Runit => vec![( + format!("/etc/runit/runsvdir/default/{name}"), + Some(format!("/etc/runit/sv/{name}")), + )], + Init::Dinit => vec![( + format!("/etc/dinit.d/boot.d/{name}"), + Some(format!("../{name}")), + )], + // s6-rc bundles are compiled databases: the source of the + // `default` bundle lists its contents as empty files. + Init::S6 => vec![(format!("/etc/s6/adminsv/default/contents.d/{name}"), None)], + } + } + + /// Every place `name` may be enabled in (OpenRC has several runlevels). + fn enabled_paths(&self, name: &str) -> Vec { + match self.init { + Init::OpenRc => { + let Ok(levels) = fs::read_dir(self.p("/etc/runlevels")) else { + return Vec::new(); + }; + levels + .flatten() + .map(|l| l.path().join(name)) + .filter(|p| p.symlink_metadata().is_ok()) + .collect() + } + _ => self + .boot_links(name) + .into_iter() + .map(|(link, _)| self.p(&link)) + .filter(|p| p.symlink_metadata().is_ok()) + .collect(), + } + } + + pub fn enable(&self, name: &str) -> Result { + self.check(name)?; + if !self.enabled_paths(name).is_empty() { + return Ok(Change::AlreadyEnabled); + } + for (link, target) in self.boot_links(name) { + let link = self.p(&link); + if let Some(dir) = link.parent() { + fs::create_dir_all(dir).context(format!("creating {}", dir.display()))?; + } + match target { + Some(target) => symlink(&target, &link) + .context(format!("linking {} to {target}", link.display()))?, + None => fs::write(&link, "").context(format!("creating {}", link.display()))?, + } + } + if self.init == Init::S6 && self.live() && sys::which("s6-db-reload").is_some() { + sys::run_inherit("s6-db-reload", &[])?; + } + Ok(Change::Enabled) + } + + pub fn disable(&self, name: &str) -> Result { + if !valid_name(name) { + return Err(Error::Usage(format!("`{name}` is not a service name"))); + } + let paths = self.enabled_paths(name); + if paths.is_empty() { + return Ok(Change::AlreadyDisabled); + } + for path in paths { + fs::remove_file(&path).context(format!("removing {}", path.display()))?; + } + if self.init == Init::S6 && self.live() && sys::which("s6-db-reload").is_some() { + sys::run_inherit("s6-db-reload", &[])?; + } + Ok(Change::Disabled) + } + + /// All installed services with whether they start at boot and, when the + /// init can be asked, whether they run. + pub fn list(&self) -> Result> { + let (available, enabled) = self.available_and_enabled(); + let states = if self.live() { + self.states(&enabled) + } else { + HashMap::new() + }; + Ok(available + .into_iter() + .map(|name| Service { + enabled: enabled.contains(&name), + state: states.get(&name).copied().unwrap_or(State::Unknown), + name, + }) + .collect()) + } + + fn available_and_enabled(&self) -> (BTreeSet, BTreeSet) { + let names = |dir: &str, want_dirs: bool| -> BTreeSet { + let Ok(entries) = fs::read_dir(self.p(dir)) else { + return BTreeSet::new(); + }; + entries + .flatten() + .filter(|e| { + let is_dir = e.path().is_dir(); + is_dir == want_dirs + }) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| valid_name(n)) + .collect() + }; + match self.init { + Init::OpenRc => { + let mut enabled = BTreeSet::new(); + if let Ok(levels) = fs::read_dir(self.p("/etc/runlevels")) { + for level in levels.flatten() { + if let Ok(entries) = fs::read_dir(level.path()) { + enabled.extend( + entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()), + ); + } + } + } + (names("/etc/init.d", false), enabled) + } + Init::Runit => { + let enabled = fs::read_dir(self.p("/etc/runit/runsvdir/default")) + .map(|d| { + d.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default(); + (names("/etc/runit/sv", true), enabled) + } + Init::Dinit => { + let enabled = fs::read_dir(self.p("/etc/dinit.d/boot.d")) + .map(|d| { + d.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default(); + (names("/etc/dinit.d", false), enabled) + } + Init::S6 => { + let enabled = fs::read_dir(self.p("/etc/s6/adminsv/default/contents.d")) + .map(|d| { + d.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default(); + (names("/etc/s6/sv", true), enabled) + } + } + } + + /// Ask the running init what runs. Best effort: a missing tool means + /// "unknown", not an error. + fn states(&self, enabled: &BTreeSet) -> HashMap { + let ask = |program: &str, args: &[&str]| -> Option { + sys::which(program)?; + let out = sys::run(program, args).ok()?; + // rc-status and sv exit non-zero when something is down, with + // the list on stdout all the same. + (!out.stdout.is_empty()).then_some(out.stdout) + }; + match self.init { + Init::OpenRc => ask("rc-status", &["-s"]) + .map(|t| parse_rc_status(&t)) + .unwrap_or_default(), + Init::Runit => { + let dirs: Vec = enabled + .iter() + .map(|n| format!("/etc/runit/runsvdir/default/{n}")) + .collect(); + if dirs.is_empty() { + return HashMap::new(); + } + let mut args = vec!["status"]; + args.extend(dirs.iter().map(String::as_str)); + ask("sv", &args) + .map(|t| parse_sv_status(&t)) + .unwrap_or_default() + } + Init::Dinit => ask("dinitctl", &["list"]) + .map(|t| parse_dinit_list(&t)) + .unwrap_or_default(), + Init::S6 => { + let Some(active) = ask("s6-rc", &["-a", "list"]) else { + return HashMap::new(); + }; + let active: BTreeSet<&str> = active.lines().map(str::trim).collect(); + let (available, _) = self.available_and_enabled(); + available + .into_iter() + .map(|n| { + let state = if active.contains(n.as_str()) { + State::Started + } else { + State::Stopped + }; + (n, state) + }) + .collect() + } + } + } + + /// The command that starts or stops `name` on the running init. + pub fn control_command(&self, action: Action, name: &str) -> Result<(String, Vec)> { + self.check(name)?; + let verb = match action { + Action::Start => "start", + Action::Stop => "stop", + }; + Ok(match self.init { + Init::OpenRc => ("rc-service".into(), vec![name.into(), verb.into()]), + Init::Runit => { + let dir = self.p(&format!("/etc/runit/runsvdir/default/{name}")); + if dir.symlink_metadata().is_err() { + return Err(Error::other(format!( + "`{name}` is not enabled, so runit does not supervise it; run `seg svc enable {name}` first" + ))); + } + ("sv".into(), vec![verb.into(), dir.display().to_string()]) + } + Init::Dinit => ("dinitctl".into(), vec![verb.into(), name.into()]), + Init::S6 => ( + "s6-rc".into(), + vec![ + match action { + Action::Start => "-u", + Action::Stop => "-d", + } + .into(), + "change".into(), + name.into(), + ], + ), + }) + } + + pub fn control(&self, action: Action, name: &str) -> Result<()> { + let (program, args) = self.control_command(action, name)?; + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + sys::run_inherit(&program, &args) + } +} + +/// `rc-status -s`: ` sshd [ started ]`, ` foo [ started 00:01:02 (0) ]`. +pub fn parse_rc_status(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let (name, rest) = line.split_once('[')?; + let name = name.trim(); + if name.is_empty() || name.contains(char::is_whitespace) { + return None; + } + let word = rest.split(']').next()?.split_whitespace().next()?; + let state = match word { + "started" => State::Started, + "stopped" | "inactive" | "stopping" => State::Stopped, + "crashed" => State::Crashed, + _ => State::Unknown, + }; + Some((name.to_string(), state)) + }) + .collect() +} + +/// `sv status`: `run: /etc/runit/runsvdir/default/sshd: (pid 12) 5s; ...`. +pub fn parse_sv_status(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let (word, rest) = line.split_once(": ")?; + let path = rest.split(':').next()?; + let name = path.rsplit('/').next()?.trim(); + if name.is_empty() { + return None; + } + let state = match word { + "run" => State::Started, + "down" | "finish" | "warning" => State::Stopped, + "fail" => State::Crashed, + _ => State::Unknown, + }; + Some((name.to_string(), state)) + }) + .collect() +} + +/// `dinitctl list`: `[{+} ] NetworkManager (pid: 450)`. The first +/// three characters after the bracket are the service's state; a `+` in +/// them means started. +pub fn parse_dinit_list(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let line = line.trim(); + let group = line.strip_prefix('[')?.get(..3)?; + let tail = line.get(4..)?; + let rest = tail.get(tail.find(']')? + 1..)?; + let name = rest.split_whitespace().next()?; + let state = if group.contains('+') { + State::Started + } else { + State::Stopped + }; + Some((name.to_string(), state)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + fn link_target(root: &Path, rel: &str) -> PathBuf { + fs::read_link(root.join(rel)).unwrap() + } + + #[test] + fn service_names_are_file_names() { + for good in [ + "sshd", + "NetworkManager", + "seg-zram", + "getty@tty1", + "a.b_c+d", + ] { + assert!(valid_name(good), "{good}"); + } + for bad in ["", ".hidden", "../etc/passwd", "a/b", "a b", "a;b", "$(x)"] { + assert!(!valid_name(bad), "{bad}"); + } + } + + #[test] + fn openrc_enable_disable_and_runlevels() { + let t = TempDir::new(); + t.put("etc/init.d/sshd", ""); + t.put("etc/init.d/sysctl", ""); + let svc = Svc::new(Init::OpenRc, t.path()); + assert_eq!(svc.enable("sshd").unwrap(), Change::Enabled); + assert_eq!(svc.enable("sshd").unwrap(), Change::AlreadyEnabled); + assert_eq!( + link_target(t.path(), "etc/runlevels/default/sshd"), + Path::new("/etc/init.d/sshd") + ); + svc.enable("sysctl").unwrap(); + assert!( + t.path() + .join("etc/runlevels/boot/sysctl") + .symlink_metadata() + .is_ok() + ); + assert_eq!(svc.disable("sshd").unwrap(), Change::Disabled); + assert_eq!(svc.disable("sshd").unwrap(), Change::AlreadyDisabled); + assert!(!t.path().join("etc/runlevels/default/sshd").exists()); + } + + #[test] + fn openrc_disable_removes_every_runlevel() { + let t = TempDir::new(); + t.put("etc/init.d/foo", ""); + let svc = Svc::new(Init::OpenRc, t.path()); + for level in ["default", "nonetwork", "boot"] { + fs::create_dir_all(t.path().join("etc/runlevels").join(level)).unwrap(); + symlink( + "/etc/init.d/foo", + t.path().join("etc/runlevels").join(level).join("foo"), + ) + .unwrap(); + } + assert_eq!(svc.disable("foo").unwrap(), Change::Disabled); + for level in ["default", "nonetwork", "boot"] { + assert!( + t.path() + .join("etc/runlevels") + .join(level) + .join("foo") + .symlink_metadata() + .is_err() + ); + } + } + + #[test] + fn runit_links_into_the_default_runsvdir() { + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/runit/sv/cupsd")).unwrap(); + let svc = Svc::new(Init::Runit, t.path()); + assert_eq!(svc.enable("cupsd").unwrap(), Change::Enabled); + assert_eq!( + link_target(t.path(), "etc/runit/runsvdir/default/cupsd"), + Path::new("/etc/runit/sv/cupsd") + ); + assert_eq!(svc.disable("cupsd").unwrap(), Change::Disabled); + } + + #[test] + fn dinit_links_relative_into_boot_d() { + let t = TempDir::new(); + t.put("etc/dinit.d/NetworkManager", "type = process\n"); + let svc = Svc::new(Init::Dinit, t.path()); + assert_eq!(svc.enable("NetworkManager").unwrap(), Change::Enabled); + assert_eq!( + link_target(t.path(), "etc/dinit.d/boot.d/NetworkManager"), + Path::new("../NetworkManager") + ); + // The link resolves. + assert!(t.path().join("etc/dinit.d/boot.d/NetworkManager").exists()); + } + + #[test] + fn s6_uses_the_default_bundle_contents() { + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/s6/sv/sshd")).unwrap(); + let svc = Svc::new(Init::S6, t.path()); + assert_eq!(svc.enable("sshd").unwrap(), Change::Enabled); + assert!( + t.path() + .join("etc/s6/adminsv/default/contents.d/sshd") + .exists() + ); + assert_eq!(svc.disable("sshd").unwrap(), Change::Disabled); + assert_eq!(svc.disable("sshd").unwrap(), Change::AlreadyDisabled); + } + + #[test] + fn unknown_and_unsafe_names_are_refused() { + let t = TempDir::new(); + let svc = Svc::new(Init::OpenRc, t.path()); + let missing = svc.enable("nothing").unwrap_err().to_string(); + assert!( + missing.contains("no service `nothing` for openrc"), + "{missing}" + ); + assert!(matches!(svc.enable("../x"), Err(Error::Usage(_)))); + assert!(matches!(svc.disable("a/b"), Err(Error::Usage(_)))); + assert!(!t.path().join("etc/runlevels").exists()); + } + + #[test] + fn list_combines_installed_and_enabled() { + let t = TempDir::new(); + t.put("etc/init.d/a", ""); + t.put("etc/init.d/b", ""); + t.put("etc/init.d/.keep", ""); + let svc = Svc::new(Init::OpenRc, t.path()); + svc.enable("b").unwrap(); + assert_eq!( + svc.list().unwrap(), + vec![ + Service { + name: "a".into(), + enabled: false, + state: State::Unknown + }, + Service { + name: "b".into(), + enabled: true, + state: State::Unknown + }, + ] + ); + + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/runit/sv/x")).unwrap(); + fs::create_dir_all(t.path().join("etc/runit/sv/y")).unwrap(); + let svc = Svc::new(Init::Runit, t.path()); + svc.enable("y").unwrap(); + let list = svc.list().unwrap(); + assert_eq!( + list.iter() + .map(|s| (s.name.as_str(), s.enabled)) + .collect::>(), + [("x", false), ("y", true)] + ); + + // dinit: boot.d is a directory of links, not a service. + let t = TempDir::new(); + t.put("etc/dinit.d/sshd", ""); + let svc = Svc::new(Init::Dinit, t.path()); + svc.enable("sshd").unwrap(); + let list = svc.list().unwrap(); + assert_eq!(list.len(), 1); + assert!(list[0].enabled); + } + + #[test] + fn control_commands_per_init() { + let t = TempDir::new(); + t.put("etc/init.d/sshd", ""); + let svc = Svc::new(Init::OpenRc, t.path()); + assert_eq!( + svc.control_command(Action::Start, "sshd").unwrap(), + ( + "rc-service".to_string(), + vec!["sshd".to_string(), "start".to_string()] + ) + ); + + let t = TempDir::new(); + t.put("etc/dinit.d/sshd", ""); + let svc = Svc::new(Init::Dinit, t.path()); + assert_eq!( + svc.control_command(Action::Stop, "sshd").unwrap(), + ( + "dinitctl".to_string(), + vec!["stop".to_string(), "sshd".to_string()] + ) + ); + + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/s6/sv/sshd")).unwrap(); + let svc = Svc::new(Init::S6, t.path()); + assert_eq!( + svc.control_command(Action::Start, "sshd").unwrap().1, + ["-u", "change", "sshd"] + ); + assert_eq!( + svc.control_command(Action::Stop, "sshd").unwrap().1, + ["-d", "change", "sshd"] + ); + + // runit supervises only what is enabled. + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/runit/sv/sshd")).unwrap(); + let svc = Svc::new(Init::Runit, t.path()); + let e = svc + .control_command(Action::Start, "sshd") + .unwrap_err() + .to_string(); + assert!(e.contains("seg svc enable sshd"), "{e}"); + svc.enable("sshd").unwrap(); + let (program, args) = svc.control_command(Action::Start, "sshd").unwrap(); + assert_eq!(program, "sv"); + assert_eq!(args[0], "start"); + assert!(args[1].ends_with("etc/runit/runsvdir/default/sshd")); + } + + #[test] + fn rc_status_lines() { + let text = " acpid [ started ]\n \ + syslog-ng [ stopped ]\n \ + foo [ crashed ]\n \ + bar [ started 00:00:12 (0) ]\n\ + Runlevel: default\n\ + Dynamic Runlevel: hotplugged\n \ + baz [ starting ]\n"; + let m = parse_rc_status(text); + assert_eq!(m["acpid"], State::Started); + assert_eq!(m["syslog-ng"], State::Stopped); + assert_eq!(m["foo"], State::Crashed); + assert_eq!(m["bar"], State::Started); + assert_eq!(m["baz"], State::Unknown); + assert_eq!(m.len(), 5); + } + + #[test] + fn sv_status_lines() { + let text = "run: /etc/runit/runsvdir/default/sshd: (pid 1234) 5s; run: log: (pid 1233) 5s\n\ + down: /etc/runit/runsvdir/default/cupsd: 3s, normally up\n\ + fail: /etc/runit/runsvdir/default/gone: unable to change to service directory: file does not exist\n\ + warning: /etc/runit/runsvdir/default/new: unable to open supervise/ok: file does not exist\n"; + let m = parse_sv_status(text); + assert_eq!(m["sshd"], State::Started); + assert_eq!(m["cupsd"], State::Stopped); + assert_eq!(m["gone"], State::Crashed); + assert_eq!(m["new"], State::Stopped); + } + + #[test] + fn dinitctl_list_lines() { + let text = "[[+] ] boot\n\ + [{+} ] NetworkManager (pid: 450)\n\ + [{ } ] cupsd\n\ + not a service line\n"; + let m = parse_dinit_list(text); + assert_eq!(m["boot"], State::Started); + assert_eq!(m["NetworkManager"], State::Started); + assert_eq!(m["cupsd"], State::Stopped); + assert_eq!(m.len(), 3); + } +} diff --git a/tools/seg/segctl/src/sys.rs b/tools/seg/segctl/src/sys.rs new file mode 100644 index 0000000..28d4096 --- /dev/null +++ b/tools/seg/segctl/src/sys.rs @@ -0,0 +1,336 @@ +//! Small helpers over /proc, /sys and child processes. + +use std::fs; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::error::{Context, Error, Result}; + +/// The effective user id, from `Uid: real effective saved fs` in +/// /proc/self/status (std has no geteuid, and this needs no libc crate). +pub fn parse_euid(status: &str) -> Option { + let line = status.lines().find(|l| l.starts_with("Uid:"))?; + line.split_whitespace().nth(2)?.parse().ok() +} + +pub fn euid() -> Result { + let status = fs::read_to_string("/proc/self/status").context("reading /proc/self/status")?; + parse_euid(&status) + .ok_or_else(|| Error::other("cannot read the user id from /proc/self/status")) +} + +/// Fails with a clear message unless running as root. `what` names the +/// command in the message, for example "`seg zram on`". +pub fn require_root(what: &str) -> Result<()> { + if euid()? == 0 { + Ok(()) + } else { + Err(Error::NeedRoot(what.to_string())) + } +} + +/// A file's trimmed contents, or None when it cannot be read. +pub fn read_trimmed(path: impl AsRef) -> Option { + fs::read_to_string(path).ok().map(|s| s.trim().to_string()) +} + +pub fn read(path: impl AsRef) -> Result { + let path = path.as_ref(); + fs::read_to_string(path).context(format!("reading {}", path.display())) +} + +pub fn write(path: impl AsRef, contents: &str) -> Result<()> { + let path = path.as_ref(); + fs::write(path, contents).context(format!("writing {}", path.display())) +} + +/// Replace a file in one step, so a crash never leaves half a configuration. +pub fn write_atomic(path: &Path, contents: &str) -> Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).context(format!("creating {}", dir.display()))?; + } + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".seg-tmp"); + let tmp = PathBuf::from(tmp); + write(&tmp, contents)?; + fs::rename(&tmp, path).context(format!("replacing {}", path.display())) +} + +pub struct Output { + pub ok: bool, + pub stdout: String, + pub stderr: String, +} + +/// Run a program and capture its output. Only a program that cannot be +/// started is an error; a failing exit status is reported in `ok`. +pub fn run(program: &str, args: &[&str]) -> Result { + let out = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .output() + .map_err(|e| Error::Command { + program: program.to_string(), + detail: format!("cannot start it: {e}"), + })?; + Ok(Output { + ok: out.status.success(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) +} + +/// Like [`run`] but a failing exit status is an error carrying stderr. +pub fn run_ok(program: &str, args: &[&str]) -> Result { + let out = run(program, args)?; + if out.ok { + Ok(out.stdout) + } else { + Err(Error::Command { + program: format!("{program} {}", args.join(" ")), + detail: out.stderr.trim().to_string(), + }) + } +} + +/// Run a program with the terminal attached, so its output and prompts show. +pub fn run_inherit(program: &str, args: &[&str]) -> Result<()> { + let status = Command::new(program) + .args(args) + .status() + .map_err(|e| Error::Command { + program: program.to_string(), + detail: format!("cannot start it: {e}"), + })?; + if status.success() { + Ok(()) + } else { + Err(Error::Command { + program: format!("{program} {}", args.join(" ")), + detail: format!("failed with {status}"), + }) + } +} + +pub fn which(program: &str) -> Option { + use std::os::unix::fs::PermissionsExt; + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(program)) + .find(|p| { + p.metadata() + .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + }) +} + +/// 1536 KiB -> "1.5 MiB". +pub fn human_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + match UNITS.get(unit) { + Some(&"B") | None => format!("{bytes} B"), + Some(u) => format!("{value:.1} {u}"), + } +} + +/// 8100 s -> "2h 15m". +pub fn human_duration(secs: u64) -> String { + let (d, h, m) = (secs / 86_400, secs % 86_400 / 3600, secs % 3600 / 60); + match (d, h, m) { + (0, 0, 0) => format!("{secs}s"), + (0, 0, m) => format!("{m}m"), + (0, h, m) => format!("{h}h {m}m"), + (d, h, _) => format!("{d}d {h}h"), + } +} + +/// Home directory of `user` from passwd-formatted text. +pub fn parse_passwd_home(passwd: &str, user: &str) -> Option { + passwd.lines().find_map(|line| { + let mut f = line.split(':'); + (f.next()? == user).then(|| f.nth(4).map(PathBuf::from))? + }) +} + +/// The home that `seg` reports on: the user who ran `sudo`, else the caller's. +pub fn invoking_home() -> Option { + if let Some(user) = std::env::var_os("SUDO_USER").and_then(|u| u.into_string().ok()) + && user != "root" + && let Ok(passwd) = fs::read_to_string("/etc/passwd") + && let Some(home) = parse_passwd_home(&passwd, &user) + { + return Some(home); + } + std::env::var_os("HOME").map(PathBuf::from) +} + +/// Total size of the files under `path`, not following links. Unreadable +/// entries count as zero: this is for reports, not for accounting. +pub fn dir_size(path: &Path) -> u64 { + let Ok(meta) = fs::symlink_metadata(path) else { + return 0; + }; + if meta.is_file() { + return meta.len(); + } + if !meta.is_dir() { + return 0; + } + let Ok(entries) = fs::read_dir(path) else { + return 0; + }; + entries.flatten().map(|e| dir_size(&e.path())).sum() +} + +/// Write to stdout; a closed pipe ends the program quietly, as other tools do. +pub fn emit(args: std::fmt::Arguments, newline: bool) { + use std::io::{ErrorKind, Write}; + let mut out = std::io::stdout().lock(); + let result = out.write_fmt(args).and_then(|()| { + if newline { + out.write_all(b"\n") + } else { + Ok(()) + } + }); + if let Err(e) = result + && e.kind() == ErrorKind::BrokenPipe + { + std::process::exit(0); + } +} + +/// Write a line to stderr, ignoring a stderr that cannot be written. +pub fn emit_err(args: std::fmt::Arguments) { + use std::io::Write; + let _ = writeln!(std::io::stderr().lock(), "{args}"); +} + +/// Colors only on a terminal, and not when NO_COLOR is set. +pub fn use_color() -> bool { + std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +pub fn bold(text: &str) -> String { + if use_color() { + format!("\x1b[1m{text}\x1b[0m") + } else { + text.to_string() + } +} + +#[cfg(test)] +pub mod testutil { + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// A directory under the system temp dir, removed on drop. + pub struct TempDir(PathBuf); + + impl TempDir { + pub fn new() -> Self { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!( + "seg-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + + pub fn path(&self) -> &Path { + &self.0 + } + + /// Write `contents` to `rel` below the directory, creating parents. + pub fn put(&self, rel: &str, contents: &str) -> PathBuf { + let p = self.0.join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(&p, contents).unwrap(); + p + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } +} + +#[cfg(test)] +mod tests { + use super::testutil::TempDir; + use super::*; + + #[test] + fn euid_is_the_second_uid_field() { + let status = "Name:\tsh\nUid:\t1000\t0\t0\t0\nGid:\t1000\t1000\t1000\t1000\n"; + assert_eq!(parse_euid(status), Some(0)); + assert_eq!(parse_euid("Uid:\t1000\t1000\t1000\t1000\n"), Some(1000)); + assert_eq!(parse_euid("Name:\tsh\n"), None); + assert_eq!(parse_euid("Uid:\tx\ty\n"), None); + } + + #[test] + fn sizes_and_durations() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(1023), "1023 B"); + assert_eq!(human_bytes(1536), "1.5 KiB"); + assert_eq!(human_bytes(5 * 1024 * 1024 * 1024), "5.0 GiB"); + assert_eq!(human_duration(42), "42s"); + assert_eq!(human_duration(600), "10m"); + assert_eq!(human_duration(8100), "2h 15m"); + assert_eq!(human_duration(90_000), "1d 1h"); + } + + #[test] + fn home_from_passwd() { + let passwd = + "root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/zsh\n"; + assert_eq!( + parse_passwd_home(passwd, "alice"), + Some(PathBuf::from("/home/alice")) + ); + assert_eq!(parse_passwd_home(passwd, "bob"), None); + assert_eq!(parse_passwd_home("broken", "broken"), None); + } + + #[test] + fn dir_size_adds_files_and_ignores_links() { + let t = TempDir::new(); + t.put("a/one", "12345"); + t.put("a/b/two", "123"); + std::os::unix::fs::symlink(t.path().join("a"), t.path().join("link")).unwrap(); + assert_eq!(dir_size(&t.path().join("a")), 8); + assert_eq!(dir_size(t.path()), 8); + assert_eq!(dir_size(&t.path().join("missing")), 0); + } + + #[test] + fn atomic_write_replaces_and_creates_parents() { + let t = TempDir::new(); + let p = t.path().join("etc/seg/tune.conf"); + write_atomic(&p, "mode = light\n").unwrap(); + write_atomic(&p, "mode = balanced\n").unwrap(); + assert_eq!(fs::read_to_string(&p).unwrap(), "mode = balanced\n"); + assert!(!t.path().join("etc/seg/tune.conf.seg-tmp").exists()); + } + + #[test] + fn missing_program_is_an_error_not_a_panic() { + let e = run("definitely-not-a-program-seg", &[]).err().unwrap(); + assert!(matches!(e, Error::Command { .. })); + assert!(run("true", &[]).unwrap().ok); + assert!(!run("false", &[]).unwrap().ok); + assert!(run_ok("false", &[]).is_err()); + } +} diff --git a/tools/seg/segctl/src/tune.rs b/tools/seg/segctl/src/tune.rs new file mode 100644 index 0000000..49a5d81 --- /dev/null +++ b/tools/seg/segctl/src/tune.rs @@ -0,0 +1,866 @@ +//! `seg tune light|balanced|performance`: memory and writeback sysctls, the +//! CPU governor and the I/O scheduler of every disk, applied now and saved +//! for the next boot (/etc/sysctl.d/99-seg.conf, /etc/seg/tune.conf, and the +//! `seg-tune` init service that runs `seg tune apply`). +//! +//! All paths are looked up below a root directory, `/` on a real system, so +//! the whole thing can be tested against a fake /sys and /proc. + +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use crate::error::{Context, Error, Result}; +use crate::sys::{self, bold}; + +pub const HELP: &str = "\ +Usage: seg tune [light|balanced|performance|apply|status] [--dry-run] + + light save memory and battery: aggressive reclaim, small dirty + limits, the most frugal CPU governor + balanced kernel defaults for memory, a dynamic governor + performance keep caches, short writeback bursts, governor `performance` + apply apply the saved profile (the seg-tune service does this at boot) + status show the saved profile and the current values (default) + +Also sets the I/O scheduler of every disk: none for NVMe, mq-deadline for +SSDs, bfq for spinning disks. With zram swap on, swappiness is at least 100 +(compressed memory is cheaper to swap to than disk is to read from). + +The profile is saved in /etc/seg/tune.conf and /etc/sysctl.d/99-seg.conf. +Needs root, except `status` and --dry-run. +"; + +pub const CONF: &str = "/etc/seg/tune.conf"; +pub const SYSCTL_CONF: &str = "/etc/sysctl.d/99-seg.conf"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Light, + Balanced, + Performance, +} + +impl Mode { + pub fn name(self) -> &'static str { + match self { + Mode::Light => "light", + Mode::Balanced => "balanced", + Mode::Performance => "performance", + } + } +} + +impl fmt::Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +impl FromStr for Mode { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "light" => Ok(Mode::Light), + "balanced" => Ok(Mode::Balanced), + "performance" => Ok(Mode::Performance), + other => Err(Error::Usage(format!( + "unknown profile `{other}` (light, balanced or performance)" + ))), + } + } +} + +struct Profile { + swappiness: u32, + vfs_cache_pressure: u32, + dirty_ratio: u32, + dirty_background_ratio: u32, + /// Governors in order of preference; the first the CPU offers is used. + /// (intel_pstate offers only performance and powersave, the latter being + /// its dynamic mode.) + governors: &'static [&'static str], + /// Energy/performance preference of CPUs that have one (HWP, amd-pstate). + epp: &'static str, +} + +fn profile(mode: Mode) -> Profile { + match mode { + Mode::Light => Profile { + swappiness: 100, + vfs_cache_pressure: 150, + dirty_ratio: 10, + dirty_background_ratio: 5, + governors: &["powersave", "conservative", "schedutil", "ondemand"], + epp: "power", + }, + Mode::Balanced => Profile { + swappiness: 60, + vfs_cache_pressure: 100, + dirty_ratio: 20, + dirty_background_ratio: 10, + governors: &["schedutil", "ondemand", "powersave"], + epp: "balance_performance", + }, + Mode::Performance => Profile { + swappiness: 10, + vfs_cache_pressure: 50, + dirty_ratio: 15, + dirty_background_ratio: 5, + governors: &["performance"], + epp: "performance", + }, + } +} + +/// The sysctl keys and values of a mode. +pub fn sysctl_values(mode: Mode, zram_active: bool) -> [(&'static str, u32); 4] { + let p = profile(mode); + let swappiness = if zram_active { + p.swappiness.max(100) + } else { + p.swappiness + }; + [ + ("vm.swappiness", swappiness), + ("vm.vfs_cache_pressure", p.vfs_cache_pressure), + ("vm.dirty_ratio", p.dirty_ratio), + ("vm.dirty_background_ratio", p.dirty_background_ratio), + ] +} + +pub fn sysctl_conf(mode: Mode, zram_active: bool) -> String { + let mut text = format!( + "# Written by `seg tune {mode}`; change it with `sudo seg tune `.\n" + ); + for (key, value) in sysctl_values(mode, zram_active) { + text.push_str(&format!("{key} = {value}\n")); + } + text +} + +/// `mode = balanced` from /etc/seg/tune.conf. +pub fn parse_conf(text: &str) -> Option { + text.lines().find_map(|l| { + let l = l.trim(); + if l.starts_with('#') { + return None; + } + let (key, value) = l.split_once('=')?; + (key.trim() == "mode").then(|| value.trim().parse().ok())? + }) +} + +/// The first of `preferred` that `available` (space separated) contains. +pub fn pick_governor<'a>(available: &str, preferred: &[&'a str]) -> Option<&'a str> { + let have: Vec<&str> = available.split_whitespace().collect(); + preferred.iter().copied().find(|g| have.contains(g)) +} + +/// `mq-deadline kyber [bfq] none` -> (all schedulers, the active one). +pub fn parse_schedulers(text: &str) -> (Vec, Option) { + let mut all = Vec::new(); + let mut active = None; + for word in text.split_whitespace() { + match word.strip_prefix('[').and_then(|w| w.strip_suffix(']')) { + Some(name) => { + active = Some(name.to_string()); + all.push(name.to_string()); + } + None => all.push(word.to_string()), + } + } + (all, active) +} + +/// Block devices that have no I/O scheduler worth setting. +fn skip_device(name: &str) -> bool { + ["loop", "ram", "zram", "dm-", "md", "sr", "fd", "nbd", "mtd"] + .iter() + .any(|p| name.starts_with(p)) +} + +/// none for NVMe (and virtual disks), mq-deadline for SSDs, bfq for spinning +/// disks. +pub fn scheduler_for(name: &str, rotational: bool) -> Option<&'static str> { + if skip_device(name) { + return None; + } + Some( + if name.starts_with("nvme") || name.starts_with("vd") || name.starts_with("xvd") { + // NVMe schedules itself; so does the host of a virtual disk. + "none" + } else if rotational { + "bfq" + } else { + "mq-deadline" + }, + ) +} + +/// A system's files, below `root`. +pub struct Fs { + root: PathBuf, +} + +#[derive(Debug, Default)] +pub struct Report { + pub lines: Vec, + pub warnings: Vec, +} + +impl Fs { + pub fn new(root: impl Into) -> Self { + Fs { root: root.into() } + } + + fn p(&self, rel: &str) -> PathBuf { + self.root.join(rel.trim_start_matches('/')) + } + + pub fn zram_active(&self) -> bool { + sys::read_trimmed(self.p("/proc/swaps")) + .is_some_and(|t| t.lines().any(|l| l.starts_with("/dev/zram"))) + } + + pub fn saved_mode(&self) -> Option { + sys::read_trimmed(self.p(CONF)).and_then(|t| parse_conf(&t)) + } + + fn sysctl_path(&self, key: &str) -> PathBuf { + self.p(&format!("/proc/sys/{}", key.replace('.', "/"))) + } + + fn sysctl_now(&self, key: &str) -> Option { + sys::read_trimmed(self.sysctl_path(key)) + } + + /// Set a file's value if it differs; the report says what changed. + fn set( + &self, + label: &str, + path: &Path, + value: &str, + dry_run: bool, + report: &mut Report, + ) -> Result<()> { + let Some(now) = sys::read_trimmed(path) else { + report + .warnings + .push(format!("{label}: not available on this system")); + return Ok(()); + }; + if now == value { + return Ok(()); + } + if !dry_run { + fs::write(path, value).context(format!("setting {label} ({})", path.display()))?; + } + report.lines.push(format!("{label:<28}{now} -> {value}")); + Ok(()) + } + + fn apply_sysctl(&self, mode: Mode, dry_run: bool, report: &mut Report) -> Result<()> { + for (key, value) in sysctl_values(mode, self.zram_active()) { + self.set( + key, + &self.sysctl_path(key), + &value.to_string(), + dry_run, + report, + )?; + } + Ok(()) + } + + fn apply_cpu(&self, mode: Mode, dry_run: bool, report: &mut Report) -> Result<()> { + let p = profile(mode); + let Ok(entries) = fs::read_dir(self.p("/sys/devices/system/cpu")) else { + return Ok(()); + }; + let mut cpus: Vec = entries + .flatten() + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.strip_prefix("cpu") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + }) + .map(|e| e.path().join("cpufreq")) + .filter(|d| d.is_dir()) + .collect(); + cpus.sort(); + if cpus.is_empty() { + report + .warnings + .push("cpu governor: this CPU has no frequency scaling (virtual machine?)".into()); + return Ok(()); + } + let mut changed = 0; + let mut used = None; + for dir in &cpus { + let available = + sys::read_trimmed(dir.join("scaling_available_governors")).unwrap_or_default(); + let Some(governor) = pick_governor(&available, p.governors) else { + continue; + }; + used = Some(governor); + let path = dir.join("scaling_governor"); + if sys::read_trimmed(&path).as_deref() != Some(governor) { + if !dry_run { + fs::write(&path, governor) + .context(format!("setting the governor ({})", path.display()))?; + } + changed += 1; + } + // Best effort: with the performance governor the CPU refuses. + let epp = dir.join("energy_performance_preference"); + if epp.exists() && sys::read_trimmed(&epp).as_deref() != Some(p.epp) && !dry_run { + let _ = fs::write(&epp, p.epp); + } + } + match used { + Some(g) if changed > 0 => report.lines.push(format!( + "{:<28}{g} on {changed} of {} CPUs", + "cpu governor", + cpus.len() + )), + Some(_) => {} + None => report.warnings.push(format!( + "cpu governor: none of {} is available on this CPU", + p.governors.join(", ") + )), + } + Ok(()) + } + + fn apply_io(&self, dry_run: bool, report: &mut Report) -> Result<()> { + let Ok(entries) = fs::read_dir(self.p("/sys/block")) else { + return Ok(()); + }; + let mut devices: Vec = entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + devices.sort(); + for name in devices { + let queue = self.p(&format!("/sys/block/{name}/queue")); + let Some(text) = sys::read_trimmed(queue.join("scheduler")) else { + continue; + }; + let rotational = sys::read_trimmed(queue.join("rotational")).as_deref() != Some("0"); + let Some(want) = scheduler_for(&name, rotational) else { + continue; + }; + let (all, active) = parse_schedulers(&text); + if active.as_deref() == Some(want) || text == want { + continue; + } + if !all.iter().any(|s| s == want) { + report.warnings.push(format!( + "{name}: scheduler {want} is not built into this kernel (has {})", + all.join(", ") + )); + continue; + } + if !dry_run { + fs::write(queue.join("scheduler"), want) + .context(format!("setting the scheduler of {name}"))?; + } + report.lines.push(format!( + "{:<28}{} -> {want}", + format!("{name} scheduler"), + active.as_deref().unwrap_or("?") + )); + } + Ok(()) + } + + /// Apply `mode` now; unless `dry_run`, also save it for the next boot. + pub fn apply(&self, mode: Mode, save: bool, dry_run: bool) -> Result { + let mut report = Report::default(); + self.apply_sysctl(mode, dry_run, &mut report)?; + self.apply_cpu(mode, dry_run, &mut report)?; + self.apply_io(dry_run, &mut report)?; + if save && !dry_run { + sys::write_atomic(&self.p(SYSCTL_CONF), &sysctl_conf(mode, self.zram_active()))?; + sys::write_atomic( + &self.p(CONF), + &format!("# seg tune profile\nmode = {mode}\n"), + )?; + report.lines.push(format!("saved {CONF} and {SYSCTL_CONF}")); + } + Ok(report) + } + + /// The memory sysctls again for the saved profile: zram swap coming or + /// going changes the swappiness it wants. Does nothing without a profile. + pub fn reapply_memory(&self) -> Result<()> { + if let Some(mode) = self.saved_mode() { + let mut report = Report::default(); + self.apply_sysctl(mode, false, &mut report)?; + } + Ok(()) + } + + pub fn status(&self) -> String { + let mut out = String::new(); + match self.saved_mode() { + Some(mode) => out.push_str(&format!("profile: {mode}\n")), + None => out.push_str("profile: none saved (`sudo seg tune balanced` saves one)\n"), + } + for (key, _) in sysctl_values(Mode::Balanced, false) { + out.push_str(&format!( + " {key:<28}{}\n", + self.sysctl_now(key).unwrap_or_else(|| "?".into()) + )); + } + let governor = + sys::read_trimmed(self.p("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")); + out.push_str(&format!( + " {:<28}{}\n", + "cpu governor", + governor.unwrap_or_else(|| "- (no frequency scaling)".into()) + )); + if let Ok(entries) = fs::read_dir(self.p("/sys/block")) { + let mut devices: Vec = entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| !skip_device(n)) + .collect(); + devices.sort(); + for name in devices { + if let Some(text) = + sys::read_trimmed(self.p(&format!("/sys/block/{name}/queue/scheduler"))) + { + let (_, active) = parse_schedulers(&text); + out.push_str(&format!( + " {:<28}{}\n", + format!("{name} scheduler"), + active.unwrap_or_else(|| "?".into()) + )); + } + } + } + out.push_str(&format!( + " {:<28}{}\n", + "zram swap", + if self.zram_active() { "on" } else { "off" } + )); + out + } +} + +pub enum Command { + Set(Mode), + Apply, + Status, +} + +pub struct Options { + pub command: Command, + pub dry_run: bool, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut command = None; + let mut dry_run = false; + while let Some(arg) = parser.next()? { + match arg { + Long("dry-run") => dry_run = true, + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + Value(v) if command.is_none() => { + let v = v.string()?; + command = Some(match v.as_str() { + "apply" => Command::Apply, + "status" => Command::Status, + other => Command::Set(other.parse()?), + }); + } + other => return Err(other.unexpected().into()), + } + } + Ok(Options { + command: command.unwrap_or(Command::Status), + dry_run, + }) +} + +fn print_report(report: &Report) { + for line in &report.lines { + outln!(" {line}"); + } + for warning in &report.warnings { + outln!(" note: {warning}"); + } +} + +pub fn run(o: Options) -> Result<()> { + let fs = Fs::new("/"); + match o.command { + Command::Status => { + out!("{}", fs.status()); + Ok(()) + } + Command::Set(mode) => { + if !o.dry_run { + sys::require_root(&format!("`seg tune {mode}`"))?; + } + outln!( + "{}", + bold(&format!( + "seg tune {mode}{}", + if o.dry_run { " (dry run)" } else { "" } + )) + ); + let report = fs.apply(mode, true, o.dry_run)?; + if report.lines.is_empty() && report.warnings.is_empty() { + outln!(" already set"); + } + print_report(&report); + Ok(()) + } + Command::Apply => { + if !o.dry_run { + sys::require_root("`seg tune apply`")?; + } + // Nothing saved is fine: the service is enabled in the image + // before anyone has chosen a profile. + let Some(mode) = fs.saved_mode() else { + outln!("seg tune: no profile saved ({CONF}); nothing to apply"); + return Ok(()); + }; + outln!("{}", bold(&format!("seg tune apply ({mode})"))); + print_report(&fs.apply(mode, false, o.dry_run)?); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[test] + fn modes_parse_and_print() { + for (text, mode) in [ + ("light", Mode::Light), + ("balanced", Mode::Balanced), + ("performance", Mode::Performance), + ] { + assert_eq!(text.parse::().unwrap(), mode); + assert_eq!(mode.to_string(), text); + } + let e = "turbo".parse::().unwrap_err(); + assert!(matches!(e, Error::Usage(_))); + assert!(e.to_string().contains("light, balanced or performance")); + } + + #[test] + fn profiles_are_ordered_from_frugal_to_fast() { + let swap = |m| sysctl_values(m, false)[0].1; + assert!(swap(Mode::Light) > swap(Mode::Balanced)); + assert!(swap(Mode::Balanced) > swap(Mode::Performance)); + let vfs = |m| sysctl_values(m, false)[1].1; + assert!( + vfs(Mode::Light) > vfs(Mode::Balanced) && vfs(Mode::Balanced) > vfs(Mode::Performance) + ); + } + + #[test] + fn zram_raises_swappiness_to_at_least_100() { + assert_eq!( + sysctl_values(Mode::Performance, true)[0], + ("vm.swappiness", 100) + ); + assert_eq!( + sysctl_values(Mode::Balanced, true)[0], + ("vm.swappiness", 100) + ); + assert_eq!( + sysctl_values(Mode::Performance, false)[0], + ("vm.swappiness", 10) + ); + // Never lowered. + assert_eq!(sysctl_values(Mode::Light, true)[0].1, 100); + } + + #[test] + fn sysctl_file_is_plain_key_value() { + let text = sysctl_conf(Mode::Balanced, false); + assert!(text.starts_with('#')); + assert!(text.contains("\nvm.swappiness = 60\n")); + assert!(text.contains("\nvm.vfs_cache_pressure = 100\n")); + assert!(text.contains("\nvm.dirty_ratio = 20\n")); + assert!(text.contains("\nvm.dirty_background_ratio = 10\n")); + } + + #[test] + fn the_shipped_defaults_are_the_balanced_profile() { + // dist/ is what the package installs as /etc/sysctl.d/99-seg.conf and + // /etc/seg/tune.conf: it must say what `seg tune balanced` writes. + assert_eq!( + include_str!("../../dist/99-seg.conf"), + sysctl_conf(Mode::Balanced, false) + ); + assert_eq!( + parse_conf(include_str!("../../dist/tune.conf")), + Some(Mode::Balanced) + ); + } + + #[test] + fn saved_mode_file() { + assert_eq!( + parse_conf("# seg tune profile\nmode = performance\n"), + Some(Mode::Performance) + ); + assert_eq!(parse_conf("mode=light"), Some(Mode::Light)); + assert_eq!(parse_conf("mode = turbo\n"), None); + assert_eq!(parse_conf("# mode = light\n"), None); + assert_eq!(parse_conf(""), None); + } + + #[test] + fn governors_follow_the_preference_order() { + let modern = "performance powersave"; + let acpi = "conservative ondemand userspace powersave performance schedutil"; + assert_eq!( + pick_governor(modern, profile(Mode::Balanced).governors), + Some("powersave") + ); + assert_eq!( + pick_governor(modern, profile(Mode::Performance).governors), + Some("performance") + ); + assert_eq!( + pick_governor(acpi, profile(Mode::Balanced).governors), + Some("schedutil") + ); + assert_eq!( + pick_governor(acpi, profile(Mode::Light).governors), + Some("powersave") + ); + assert_eq!( + pick_governor("userspace", profile(Mode::Performance).governors), + None + ); + assert_eq!(pick_governor("", profile(Mode::Light).governors), None); + } + + #[test] + fn scheduler_lists() { + assert_eq!( + parse_schedulers("mq-deadline kyber [bfq] none"), + ( + vec![ + "mq-deadline".into(), + "kyber".into(), + "bfq".into(), + "none".into() + ], + Some("bfq".into()) + ) + ); + assert_eq!(parse_schedulers("none"), (vec!["none".into()], None)); + assert_eq!(parse_schedulers(""), (vec![], None)); + } + + #[test] + fn scheduler_by_disk_type() { + assert_eq!(scheduler_for("nvme0n1", false), Some("none")); + assert_eq!(scheduler_for("sda", false), Some("mq-deadline")); + assert_eq!(scheduler_for("sdb", true), Some("bfq")); + assert_eq!(scheduler_for("mmcblk0", false), Some("mq-deadline")); + // Virtual disks claim to spin; their host does the scheduling. + assert_eq!(scheduler_for("vda", true), Some("none")); + assert_eq!(scheduler_for("xvda", true), Some("none")); + for skipped in ["loop0", "zram0", "dm-0", "md127", "sr0", "ram0", "nbd0"] { + assert_eq!(scheduler_for(skipped, true), None, "{skipped}"); + } + } + + /// A machine with two CPUs, an NVMe drive, an SSD, a hard disk and a loop + /// device. + fn fake_system() -> TempDir { + let t = TempDir::new(); + for cpu in ["cpu0", "cpu1"] { + let d = format!("sys/devices/system/cpu/{cpu}/cpufreq"); + t.put( + &format!("{d}/scaling_available_governors"), + "performance schedutil powersave\n", + ); + t.put(&format!("{d}/scaling_governor"), "powersave\n"); + t.put( + &format!("{d}/energy_performance_preference"), + "balance_performance\n", + ); + } + t.put("sys/devices/system/cpu/cpufreq/policy0/x", ""); + t.put("sys/devices/system/cpu/online", "0-1\n"); + t.put("sys/block/nvme0n1/queue/scheduler", "[none] mq-deadline\n"); + t.put("sys/block/nvme0n1/queue/rotational", "0\n"); + t.put( + "sys/block/sda/queue/scheduler", + "[none] mq-deadline kyber bfq\n", + ); + t.put("sys/block/sda/queue/rotational", "0\n"); + t.put( + "sys/block/sdb/queue/scheduler", + "mq-deadline [none] kyber bfq\n", + ); + t.put("sys/block/sdb/queue/rotational", "1\n"); + t.put("sys/block/loop0/queue/scheduler", "[none]\n"); + t.put("proc/sys/vm/swappiness", "60\n"); + t.put("proc/sys/vm/vfs_cache_pressure", "100\n"); + t.put("proc/sys/vm/dirty_ratio", "20\n"); + t.put("proc/sys/vm/dirty_background_ratio", "10\n"); + t.put("proc/swaps", "Filename Type Size Used Priority\n"); + t + } + + fn read(t: &TempDir, rel: &str) -> String { + fs::read_to_string(t.path().join(rel)) + .unwrap() + .trim() + .to_string() + } + + #[test] + fn performance_changes_everything_and_saves_it() { + let t = fake_system(); + let report = Fs::new(t.path()) + .apply(Mode::Performance, true, false) + .unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); + assert_eq!(read(&t, "proc/sys/vm/vfs_cache_pressure"), "50"); + assert_eq!(read(&t, "proc/sys/vm/dirty_ratio"), "15"); + assert_eq!(read(&t, "proc/sys/vm/dirty_background_ratio"), "5"); + for cpu in ["cpu0", "cpu1"] { + assert_eq!( + read( + &t, + &format!("sys/devices/system/cpu/{cpu}/cpufreq/scaling_governor") + ), + "performance" + ); + assert_eq!( + read( + &t, + &format!("sys/devices/system/cpu/{cpu}/cpufreq/energy_performance_preference") + ), + "performance" + ); + } + // NVMe already had none; the SSD and the hard disk change. + assert_eq!( + read(&t, "sys/block/nvme0n1/queue/scheduler"), + "[none] mq-deadline" + ); + assert_eq!(read(&t, "sys/block/sda/queue/scheduler"), "mq-deadline"); + assert_eq!(read(&t, "sys/block/sdb/queue/scheduler"), "bfq"); + assert_eq!(read(&t, "sys/block/loop0/queue/scheduler"), "[none]"); + assert!( + report + .lines + .iter() + .any(|l| l.contains("performance on 2 of 2 CPUs")) + ); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + + assert_eq!( + read(&t, "etc/seg/tune.conf").lines().last(), + Some("mode = performance") + ); + let sysctl = read(&t, "etc/sysctl.d/99-seg.conf"); + assert!(sysctl.contains("vm.swappiness = 10")); + assert_eq!(Fs::new(t.path()).saved_mode(), Some(Mode::Performance)); + } + + #[test] + fn applying_twice_changes_nothing_the_second_time() { + let t = fake_system(); + let fs_ = Fs::new(t.path()); + fs_.apply(Mode::Balanced, true, false).unwrap(); + let again = fs_.apply(Mode::Balanced, false, false).unwrap(); + assert!(again.lines.is_empty(), "{:?}", again.lines); + } + + #[test] + fn a_dry_run_touches_nothing() { + let t = fake_system(); + let report = Fs::new(t.path()).apply(Mode::Light, true, true).unwrap(); + assert!(!report.lines.is_empty()); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "60"); + assert_eq!( + read(&t, "sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"), + "powersave" + ); + assert_eq!( + read(&t, "sys/block/sdb/queue/scheduler"), + "mq-deadline [none] kyber bfq" + ); + assert!(!t.path().join("etc/seg/tune.conf").exists()); + assert!(!t.path().join("etc/sysctl.d/99-seg.conf").exists()); + } + + #[test] + fn zram_swap_lifts_swappiness() { + let t = fake_system(); + t.put( + "proc/swaps", + "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 0 100\n", + ); + let fs_ = Fs::new(t.path()); + assert!(fs_.zram_active()); + fs_.apply(Mode::Performance, true, false).unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "100"); + assert!(read(&t, "etc/sysctl.d/99-seg.conf").contains("vm.swappiness = 100")); + // zram goes away: the saved profile's own value comes back. + t.put("proc/swaps", "Filename Type Size Used Priority\n"); + fs_.reapply_memory().unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); + } + + #[test] + fn missing_pieces_are_notes_not_failures() { + // A virtual machine: no cpufreq, a disk without bfq, no vm sysctl. + let t = TempDir::new(); + t.put("sys/devices/system/cpu/cpu0/online", "1\n"); + t.put("sys/block/sdz/queue/scheduler", "[none] mq-deadline\n"); + t.put("sys/block/sdz/queue/rotational", "1\n"); + let report = Fs::new(t.path()).apply(Mode::Light, false, false).unwrap(); + let notes = report.warnings.join("\n"); + assert!(notes.contains("no frequency scaling"), "{notes}"); + assert!( + notes.contains("sdz: scheduler bfq is not built into this kernel"), + "{notes}" + ); + assert!(notes.contains("vm.swappiness: not available"), "{notes}"); + } + + #[test] + fn reapply_without_a_profile_does_nothing() { + let t = fake_system(); + Fs::new(t.path()).reapply_memory().unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "60"); + } + + #[test] + fn status_lists_the_current_values() { + let t = fake_system(); + let text = Fs::new(t.path()).status(); + assert!(text.contains("profile: none saved")); + assert!(text.contains("vm.swappiness")); + assert!(text.contains("nvme0n1 scheduler")); + assert!(!text.contains("loop0")); + assert!(text.contains("zram swap") && text.contains("off")); + } +} diff --git a/tools/seg/segctl/src/vercmp.rs b/tools/seg/segctl/src/vercmp.rs new file mode 100644 index 0000000..35993fe --- /dev/null +++ b/tools/seg/segctl/src/vercmp.rs @@ -0,0 +1,244 @@ +//! Package versions and cache file names, as pacman orders and writes them. +//! `vercmp` follows libalpm's `rpmvercmp`, so `seg clean` keeps the same +//! "newest" packages `paccache` would. + +use std::cmp::Ordering; + +fn is_alnum(b: u8) -> bool { + b.is_ascii_alphanumeric() +} + +/// Compare two version strings without epoch or release (libalpm's +/// `rpmvercmp`). +fn rpmvercmp(a: &str, b: &str) -> Ordering { + if a == b { + return Ordering::Equal; + } + let (a, b) = (a.as_bytes(), b.as_bytes()); + let (mut i, mut j) = (0, 0); + let at = |s: &[u8], k: usize| s.get(k).copied(); + while at(a, i).is_some() && at(b, j).is_some() { + let (start_i, start_j) = (i, j); + while at(a, i).is_some_and(|c| !is_alnum(c)) { + i += 1; + } + while at(b, j).is_some_and(|c| !is_alnum(c)) { + j += 1; + } + if at(a, i).is_none() || at(b, j).is_none() { + break; + } + // Different separator lengths decide it. + if i - start_i != j - start_j { + return if i - start_i < j - start_j { + Ordering::Less + } else { + Ordering::Greater + }; + } + // A whole numeric or whole alphabetic segment from each side. + let numeric = at(a, i).is_some_and(|c| c.is_ascii_digit()); + let (mut ei, mut ej) = (i, j); + if numeric { + while at(a, ei).is_some_and(|c| c.is_ascii_digit()) { + ei += 1; + } + while at(b, ej).is_some_and(|c| c.is_ascii_digit()) { + ej += 1; + } + } else { + while at(a, ei).is_some_and(|c| c.is_ascii_alphabetic()) { + ei += 1; + } + while at(b, ej).is_some_and(|c| c.is_ascii_alphabetic()) { + ej += 1; + } + } + let (mut seg_a, mut seg_b) = (a.get(i..ei).unwrap_or(&[]), b.get(j..ej).unwrap_or(&[])); + // The other side has a segment of the other kind: numbers win. + if seg_b.is_empty() { + return if numeric { + Ordering::Greater + } else { + Ordering::Less + }; + } + if numeric { + while seg_a.first() == Some(&b'0') { + seg_a = seg_a.get(1..).unwrap_or(&[]); + } + while seg_b.first() == Some(&b'0') { + seg_b = seg_b.get(1..).unwrap_or(&[]); + } + // More digits is the bigger number. + match seg_a.len().cmp(&seg_b.len()) { + Ordering::Equal => {} + other => return other, + } + } + match seg_a.cmp(seg_b) { + Ordering::Equal => {} + other => return other, + } + i = ei; + j = ej; + } + // Everything compared equal; only separators may differ. + match (at(a, i), at(b, j)) { + (None, None) => Ordering::Equal, + // The longer version wins, unless what is left of it is letters + // (1.5b is older than 1.5). + (a_rest, b_rest) => { + let a_empty_b_not_alpha = + a_rest.is_none() && !b_rest.is_some_and(|c| c.is_ascii_alphabetic()); + if a_empty_b_not_alpha || a_rest.is_some_and(|c| c.is_ascii_alphabetic()) { + Ordering::Less + } else { + Ordering::Greater + } + } + } +} + +/// Split `[epoch:]version[-release]`. +fn split_evr(evr: &str) -> (&str, &str, Option<&str>) { + let digits = evr.bytes().take_while(u8::is_ascii_digit).count(); + let (epoch, rest) = match evr.get(digits..).and_then(|r| r.strip_prefix(':')) { + Some(rest) => (if digits == 0 { "0" } else { &evr[..digits] }, rest), + None => ("0", evr), + }; + match rest.rsplit_once('-') { + Some((version, release)) => (epoch, version, Some(release)), + None => (epoch, rest, None), + } +} + +/// Compare two package versions such as `1:2.4-1` and `2.3.9-3` the way +/// `vercmp` does. +pub fn vercmp(a: &str, b: &str) -> Ordering { + if a == b { + return Ordering::Equal; + } + let (e1, v1, r1) = split_evr(a); + let (e2, v2, r2) = split_evr(b); + rpmvercmp(e1, e2) + .then_with(|| rpmvercmp(v1, v2)) + .then_with(|| match (r1, r2) { + (Some(r1), Some(r2)) => rpmvercmp(r1, r2), + _ => Ordering::Equal, + }) +} + +/// A package file of the pacman cache. +#[derive(Debug, PartialEq, Eq)] +pub struct CacheFile { + pub name: String, + /// `[epoch:]pkgver-pkgrel`. + pub version: String, + pub arch: String, + /// A detached signature (`.sig`) of the package file. + pub is_sig: bool, +} + +/// `name-1:2.0-3-x86_64.pkg.tar.zst[.sig]` -> its parts. Other files in the +/// cache (partial downloads, `download-*` folders) give None. +pub fn parse_cache_file(file_name: &str) -> Option { + let (stem, is_sig) = match file_name.strip_suffix(".sig") { + Some(stem) => (stem, true), + None => (file_name, false), + }; + let (base, ext) = stem.split_once(".pkg.tar")?; + // The compressors makepkg knows; a bare ".pkg.tar" is also valid. Anything + // else (".zst.part", a partial download) is not a finished package. + if !matches!( + ext, + "" | ".zst" | ".xz" | ".gz" | ".bz2" | ".lz4" | ".lz" | ".lrz" | ".lzo" | ".Z" + ) { + return None; + } + let (rest, arch) = base.rsplit_once('-')?; + let (rest, release) = rest.rsplit_once('-')?; + let (name, version) = rest.rsplit_once('-')?; + if name.is_empty() || version.is_empty() || release.is_empty() || arch.is_empty() { + return None; + } + Some(CacheFile { + name: name.to_string(), + version: format!("{version}-{release}"), + arch: arch.to_string(), + is_sig, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use Ordering::*; + + // Cases from pacman's own vercmp test suite (test/util/vercmptest.sh). + #[test] + fn matches_pacmans_vercmp() { + let cases = [ + ("1.5.0", "1.5.0", Equal), + ("1.5.1", "1.5.0", Greater), + ("1.5.1", "1.5", Greater), + ("1.5.0-1", "1.5.0-1", Equal), + ("1.5.0-1", "1.5.0-2", Less), + ("1.5.0-1", "1.5.1-1", Less), + ("1.5.0-2", "1.5.1-1", Less), + ("1.5-1", "1.5.b2-1", Less), + ("1.5b-1", "1.5-1", Less), + ("1.5-1", "1.5b-1", Greater), + ("1.5.0", "1.5", Greater), + ("1.0", "1.0.1", Less), + ("1.0", "1.0a", Greater), + ("1.0a", "1.0b", Less), + ("1.0a", "1.0.1", Less), + ("1.9", "1.10", Less), + ("1.010", "1.10", Equal), + ("1.1000", "1.999", Greater), + ("1:1.0", "2.0", Greater), + ("1:1.0", "0:2.0", Greater), + ("2.0", "0:2.0", Equal), + ("1:1.0-1", "1:1.0-2", Less), + ("1.0_1", "1.0.1", Equal), + ("1.0+1", "1.0", Greater), + ]; + for (a, b, want) in cases { + assert_eq!(vercmp(a, b), want, "vercmp {a} {b}"); + assert_eq!(vercmp(b, a), want.reverse(), "vercmp {b} {a}"); + } + } + + #[test] + fn evr_parts() { + assert_eq!(split_evr("1:2.0-3"), ("1", "2.0", Some("3"))); + assert_eq!(split_evr("2.0-3"), ("0", "2.0", Some("3"))); + assert_eq!(split_evr("2.0"), ("0", "2.0", None)); + assert_eq!(split_evr(":2.0-3"), ("0", "2.0", Some("3"))); + assert_eq!(split_evr("a:2.0"), ("0", "a:2.0", None)); + } + + #[test] + fn cache_file_names() { + let f = parse_cache_file("linux-zen-headers-6.12.4.zen1-1-x86_64.pkg.tar.zst").unwrap(); + assert_eq!(f.name, "linux-zen-headers"); + assert_eq!(f.version, "6.12.4.zen1-1"); + assert_eq!(f.arch, "x86_64"); + assert!(!f.is_sig); + + let f = parse_cache_file("qt6-base-1:6.8.1-2-x86_64.pkg.tar.xz.sig").unwrap(); + assert_eq!(f.name, "qt6-base"); + assert_eq!(f.version, "1:6.8.1-2"); + assert!(f.is_sig); + + let f = parse_cache_file("filesystem-2024.11-1-any.pkg.tar.zst").unwrap(); + assert_eq!((f.name.as_str(), f.arch.as_str()), ("filesystem", "any")); + + assert!(parse_cache_file("download-abc123").is_none()); + assert!(parse_cache_file("foo-1.0-1-x86_64.pkg.tar.zst.part").is_none()); + assert!(parse_cache_file("foo.pkg.tar.zst").is_none()); + assert!(parse_cache_file("-1.0-1-x86_64.pkg.tar.zst").is_none()); + assert!(parse_cache_file("readme.txt").is_none()); + } +} diff --git a/tools/seg/segctl/src/zram.rs b/tools/seg/segctl/src/zram.rs new file mode 100644 index 0000000..15ee644 --- /dev/null +++ b/tools/seg/segctl/src/zram.rs @@ -0,0 +1,573 @@ +//! `seg zram on|off|status`: compressed swap in memory, without systemd. +//! zstd, half the RAM (at most 8 GiB: the table of a larger device costs +//! memory of its own), priority 100 so it is used before any disk swap. + +use std::fs; +use std::path::PathBuf; +use std::thread::sleep; +use std::time::Duration; + +use crate::error::{Error, Result}; +use crate::sys::{self, human_bytes}; +use crate::tune; + +pub const HELP: &str = "\ +Usage: seg zram on|off|status [--size SIZE] + + on set up /dev/zram0 as swap: zstd, half of the RAM (at most 8G) + off stop using it and free its memory + status show whether it is on, its size and how well it compresses + +--size SIZE size of the device instead of half the RAM: 2G, 4096M, 512M + +`on` and `off` need root. The seg-zram service runs `seg zram on` at boot. +"; + +const MAX_AUTO_BYTES: u64 = 8 << 30; +const DEVICE: &str = "/dev/zram0"; + +pub enum Action { + On, + Off, + Status, +} + +pub struct Options { + pub action: Action, + pub size: Option, +} + +/// `2G`, `4096M`, `512m`, `1048576` (bytes) -> bytes. +pub fn parse_size(text: &str) -> Result { + let bad = || Error::Usage(format!("`{text}` is not a size (try 2G or 4096M)")); + let text = text.trim(); + let (digits, shift) = match text.chars().last() { + Some('G' | 'g') => (&text[..text.len() - 1], 30), + Some('M' | 'm') => (&text[..text.len() - 1], 20), + Some('K' | 'k') => (&text[..text.len() - 1], 10), + _ => (text, 0), + }; + let n: u64 = digits.parse().map_err(|_| bad())?; + let bytes = n.checked_mul(1 << shift).ok_or_else(bad)?; + if bytes == 0 { + return Err(bad()); + } + Ok(bytes) +} + +/// Half the RAM, at most 8 GiB. +pub fn auto_size(mem_total_kib: u64) -> u64 { + (mem_total_kib.saturating_mul(1024) / 2).min(MAX_AUTO_BYTES) +} + +pub fn mem_total_kib(meminfo: &str) -> Option { + meminfo + .lines() + .find(|l| l.starts_with("MemTotal:"))? + .split_whitespace() + .nth(1)? + .parse() + .ok() +} + +#[derive(Debug, PartialEq, Eq)] +pub struct SwapEntry { + pub path: String, + pub size_kib: u64, + pub used_kib: u64, + pub priority: i64, +} + +/// /proc/swaps: `Filename Type Size Used Priority`, sizes in KiB. +pub fn parse_swaps(text: &str) -> Vec { + text.lines() + .skip(1) + .filter_map(|l| { + let mut f = l.split_whitespace(); + let path = f.next()?.to_string(); + let _kind = f.next()?; + Some(SwapEntry { + path, + size_kib: f.next()?.parse().ok()?, + used_kib: f.next()?.parse().ok()?, + priority: f.next()?.parse().ok()?, + }) + }) + .collect() +} + +/// `lzo lzo-rle [lz4] zstd` -> (available, selected). +pub fn parse_algorithms(text: &str) -> (Vec, Option) { + let mut all = Vec::new(); + let mut selected = None; + for w in text.split_whitespace() { + match w.strip_prefix('[').and_then(|w| w.strip_suffix(']')) { + Some(name) => { + selected = Some(name.to_string()); + all.push(name.to_string()); + } + None => all.push(w.to_string()), + } + } + (all, selected) +} + +/// The first three numbers of mm_stat: original size, compressed size and +/// the memory used, in bytes. +pub fn parse_mm_stat(text: &str) -> Option<(u64, u64, u64)> { + let mut f = text.split_whitespace().map(|w| w.parse::()); + Some((f.next()?.ok()?, f.next()?.ok()?, f.next()?.ok()?)) +} + +/// Runs the helper programs; replaced by a recorder in tests. +pub trait Runner { + fn run(&mut self, program: &str, args: &[&str]) -> Result<()>; +} + +pub struct RealRunner; + +impl Runner for RealRunner { + fn run(&mut self, program: &str, args: &[&str]) -> Result<()> { + sys::run_ok(program, args).map(|_| ()) + } +} + +pub struct Zram { + root: PathBuf, +} + +impl Zram { + pub fn new(root: impl Into) -> Self { + Zram { root: root.into() } + } + + fn p(&self, rel: &str) -> PathBuf { + self.root.join(rel.trim_start_matches('/')) + } + + fn dev(&self, file: &str) -> PathBuf { + self.p(&format!("/sys/block/zram0/{file}")) + } + + fn swap(&self) -> Option { + let text = fs::read_to_string(self.p("/proc/swaps")).ok()?; + parse_swaps(&text).into_iter().find(|e| e.path == DEVICE) + } + + /// The module provides /sys/block/zram0; load it when it is missing. + fn ensure_device(&self, runner: &mut dyn Runner, wait: Duration) -> Result<()> { + if self.dev("disksize").exists() { + return Ok(()); + } + runner + .run("modprobe", &["zram"]) + .map_err(|e| Error::Unsupported(format!("this kernel has no zram module ({e})")))?; + for _ in 0..20 { + if self.dev("disksize").exists() { + return Ok(()); + } + sleep(wait); + } + Err(Error::Unsupported( + "the zram module is loaded but /sys/block/zram0 did not appear".into(), + )) + } + + pub fn on(&self, runner: &mut dyn Runner, size: Option, wait: Duration) -> Result { + if let Some(swap) = self.swap() { + return Ok(format!( + "zram swap is already on ({})", + human_bytes(swap.size_kib * 1024) + )); + } + self.ensure_device(runner, wait)?; + let bytes = match size { + Some(b) => b, + None => { + let meminfo = sys::read(self.p("/proc/meminfo"))?; + auto_size(mem_total_kib(&meminfo).ok_or_else(|| { + Error::other("cannot read the memory size from /proc/meminfo") + })?) + } + }; + // A configured but unused device must be reset before it can change. + if sys::read_trimmed(self.dev("disksize")).is_some_and(|s| s != "0") { + sys::write(self.dev("reset"), "1")?; + } + let mut note = String::new(); + let algorithms = sys::read_trimmed(self.dev("comp_algorithm")).unwrap_or_default(); + if parse_algorithms(&algorithms).0.iter().any(|a| a == "zstd") { + sys::write(self.dev("comp_algorithm"), "zstd")?; + } else { + note = " (zstd is not available: using the kernel's default compression)".into(); + } + sys::write(self.dev("disksize"), &bytes.to_string())?; + runner.run("mkswap", &["-U", "clear", DEVICE])?; + runner.run("swapon", &["-p", "100", DEVICE])?; + + // zswap would compress the same pages a second time on their way here. + let zswap = self.p("/sys/module/zswap/parameters/enabled"); + if zswap.exists() { + let _ = fs::write(zswap, "0"); + } + // Swapping to memory needs no read-ahead. + let _ = fs::write(self.p("/proc/sys/vm/page-cluster"), "0"); + // A saved `seg tune` profile decides the swappiness; without one, + // swap to memory early, as SteamOS and Fedora do. + let tuned = tune::Fs::new(&self.root); + if tuned.saved_mode().is_some() { + tuned.reapply_memory()?; + } else { + let _ = fs::write(self.p("/proc/sys/vm/swappiness"), "100"); + } + Ok(format!( + "zram swap is on: {} of zstd-compressed memory{note}", + human_bytes(bytes) + )) + } + + pub fn off(&self, runner: &mut dyn Runner) -> Result { + let was_on = self.swap().is_some(); + if was_on { + runner.run("swapoff", &[DEVICE])?; + } + if self.dev("reset").exists() + && sys::read_trimmed(self.dev("disksize")).is_some_and(|s| s != "0") + { + sys::write(self.dev("reset"), "1")?; + } + if was_on { + // Without zram the saved profile's own swappiness applies again. + tune::Fs::new(&self.root).reapply_memory()?; + Ok("zram swap is off and its memory is freed".into()) + } else { + Ok("zram swap is already off".into()) + } + } + + pub fn status(&self) -> String { + if !self.dev("disksize").exists() { + return "zram: off (the module is not loaded; `sudo seg zram on` loads it)\n".into(); + } + let Some(swap) = self.swap() else { + return "zram: off\n".into(); + }; + let mut out = format!( + "zram: on\n device {DEVICE}\n size {}\n swap used {}\n priority {}\n", + human_bytes(swap.size_kib * 1024), + human_bytes(swap.used_kib * 1024), + swap.priority + ); + if let Some(text) = sys::read_trimmed(self.dev("comp_algorithm")) { + out.push_str(&format!( + " compression {}\n", + parse_algorithms(&text).1.unwrap_or_else(|| "?".into()) + )); + } + if let Some((orig, compressed, memory)) = + sys::read_trimmed(self.dev("mm_stat")).and_then(|t| parse_mm_stat(&t)) + { + if compressed > 0 { + out.push_str(&format!( + " stored {} as {} ({:.1}:1), {} of memory\n", + human_bytes(orig), + human_bytes(compressed), + orig as f64 / compressed as f64, + human_bytes(memory) + )); + } else { + out.push_str(" stored nothing yet\n"); + } + } + out + } +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut action = None; + let mut size = None; + while let Some(arg) = parser.next()? { + match arg { + Long("size") => size = Some(parse_size(&parser.value()?.string()?)?), + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + Value(v) if action.is_none() => { + action = Some(match v.string()?.as_str() { + "on" => Action::On, + "off" => Action::Off, + "status" => Action::Status, + other => { + return Err(Error::Usage(format!( + "unknown zram command `{other}` (on, off or status)" + ))); + } + }); + } + other => return Err(other.unexpected().into()), + } + } + Ok(Options { + action: action.unwrap_or(Action::Status), + size, + }) +} + +pub fn run(o: Options) -> Result<()> { + let zram = Zram::new("/"); + match o.action { + Action::Status => out!("{}", zram.status()), + Action::On => { + sys::require_root("`seg zram on`")?; + outln!( + "{}", + zram.on(&mut RealRunner, o.size, Duration::from_millis(100))? + ); + } + Action::Off => { + sys::require_root("`seg zram off`")?; + outln!("{}", zram.off(&mut RealRunner)?); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[derive(Default)] + struct Recorder(Vec); + + impl Runner for Recorder { + fn run(&mut self, program: &str, args: &[&str]) -> Result<()> { + self.0.push(format!("{program} {}", args.join(" "))); + Ok(()) + } + } + + const NO_WAIT: Duration = Duration::ZERO; + + fn fake(disksize: &str, algos: &str) -> TempDir { + let t = TempDir::new(); + t.put("sys/block/zram0/disksize", disksize); + t.put("sys/block/zram0/comp_algorithm", algos); + t.put("sys/block/zram0/reset", ""); + t.put( + "proc/meminfo", + "MemTotal: 8000000 kB\nMemFree: 1 kB\n", + ); + t.put("proc/swaps", "Filename Type Size Used Priority\n"); + t.put("proc/sys/vm/swappiness", "60\n"); + t.put("proc/sys/vm/page-cluster", "3\n"); + t.put("sys/module/zswap/parameters/enabled", "Y\n"); + t + } + + fn read(t: &TempDir, rel: &str) -> String { + fs::read_to_string(t.path().join(rel)) + .unwrap() + .trim() + .to_string() + } + + #[test] + fn sizes() { + assert_eq!(parse_size("2G").unwrap(), 2 << 30); + assert_eq!(parse_size("4096M").unwrap(), 4096 << 20); + assert_eq!(parse_size("512m").unwrap(), 512 << 20); + assert_eq!(parse_size("1048576").unwrap(), 1_048_576); + for bad in [ + "", + "G", + "0", + "0M", + "-1G", + "1.5G", + "2T", + "99999999999999999999G", + ] { + assert!(parse_size(bad).is_err(), "{bad}"); + } + } + + #[test] + fn half_the_ram_up_to_8_gib() { + assert_eq!(auto_size(8_000_000), 8_000_000 * 1024 / 2); + assert_eq!(auto_size(4 * 1024 * 1024), 2 << 30); + assert_eq!(auto_size(64 * 1024 * 1024), 8 << 30); + assert_eq!(auto_size(0), 0); + assert_eq!(mem_total_kib("MemTotal: 42 kB\n"), Some(42)); + assert_eq!(mem_total_kib("nothing"), None); + } + + #[test] + fn swap_table_and_stats() { + let swaps = "Filename\tType\tSize\tUsed\tPriority\n/dev/zram0 partition 4194300 1024 100\n/swapfile file 2097148 0 -2\n"; + let list = parse_swaps(swaps); + assert_eq!(list.len(), 2); + assert_eq!( + list[0], + SwapEntry { + path: "/dev/zram0".into(), + size_kib: 4_194_300, + used_kib: 1024, + priority: 100 + } + ); + assert_eq!(list[1].priority, -2); + assert!(parse_swaps("Filename Type Size Used Priority\n").is_empty()); + + assert_eq!( + parse_algorithms("lzo lzo-rle lz4 [zstd]"), + ( + vec!["lzo".into(), "lzo-rle".into(), "lz4".into(), "zstd".into()], + Some("zstd".into()) + ) + ); + assert_eq!( + parse_mm_stat("40960 10240 16384 0 16384 0 0 0 0"), + Some((40960, 10240, 16384)) + ); + assert_eq!(parse_mm_stat("1 2"), None); + assert_eq!(parse_mm_stat("a b c"), None); + } + + #[test] + fn on_configures_zstd_half_the_ram_and_swaps_to_it() { + let t = fake("0\n", "lzo lzo-rle [lz4] zstd\n"); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + assert!(msg.starts_with("zram swap is on: "), "{msg}"); + assert_eq!(read(&t, "sys/block/zram0/comp_algorithm"), "zstd"); + assert_eq!( + read(&t, "sys/block/zram0/disksize"), + (8_000_000u64 * 1024 / 2).to_string() + ); + assert_eq!( + r.0, + ["mkswap -U clear /dev/zram0", "swapon -p 100 /dev/zram0"] + ); + assert_eq!(read(&t, "sys/module/zswap/parameters/enabled"), "0"); + assert_eq!(read(&t, "proc/sys/vm/page-cluster"), "0"); + // No saved `seg tune` profile: swap to memory early. + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "100"); + } + + #[test] + fn on_uses_an_explicit_size_and_resets_a_configured_device() { + let t = fake("1048576\n", "[lzo] zstd\n"); + let mut r = Recorder::default(); + Zram::new(t.path()) + .on(&mut r, Some(2 << 30), NO_WAIT) + .unwrap(); + assert_eq!(read(&t, "sys/block/zram0/reset"), "1"); + assert_eq!( + read(&t, "sys/block/zram0/disksize"), + (2u64 << 30).to_string() + ); + } + + #[test] + fn on_falls_back_when_the_kernel_lacks_zstd() { + let t = fake("0\n", "[lzo] lzo-rle lz4\n"); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + assert!(msg.contains("zstd is not available"), "{msg}"); + assert_eq!( + read(&t, "sys/block/zram0/comp_algorithm"), + "[lzo] lzo-rle lz4" + ); + } + + #[test] + fn on_when_already_on_does_nothing() { + let t = fake("0\n", "[zstd]\n"); + t.put( + "proc/swaps", + "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 0 100\n", + ); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + assert!(msg.starts_with("zram swap is already on"), "{msg}"); + assert!(r.0.is_empty()); + } + + #[test] + fn on_loads_the_module_when_the_device_is_missing() { + let t = TempDir::new(); + t.put("proc/meminfo", "MemTotal: 1000 kB\n"); + t.put("proc/swaps", "Filename Type Size Used Priority\n"); + let mut r = Recorder::default(); + // The recorder does not create the device, so this gives up after + // asking for the module. + let e = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap_err(); + assert_eq!(r.0, ["modprobe zram"]); + assert!(matches!(e, Error::Unsupported(_)), "{e}"); + } + + #[test] + fn a_saved_tune_profile_decides_the_swappiness() { + let t = fake("0\n", "[zstd]\n"); + t.put("etc/seg/tune.conf", "mode = performance\n"); + t.put("proc/sys/vm/vfs_cache_pressure", "100\n"); + t.put("proc/sys/vm/dirty_ratio", "20\n"); + t.put("proc/sys/vm/dirty_background_ratio", "10\n"); + let mut r = Recorder::default(); + Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + // The recorder cannot add the swap, so the profile sees no zram and + // wants its own value; what matters is that tune decided, not 100. + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); + } + + #[test] + fn off_swaps_off_and_resets() { + let t = fake("4294967296\n", "[zstd]\n"); + t.put( + "proc/swaps", + "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 0 100\n", + ); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()).off(&mut r).unwrap(); + assert_eq!(r.0, ["swapoff /dev/zram0"]); + assert_eq!(read(&t, "sys/block/zram0/reset"), "1"); + assert!(msg.contains("off"), "{msg}"); + + let t = fake("0\n", "[zstd]\n"); + let mut r = Recorder::default(); + assert_eq!( + Zram::new(t.path()).off(&mut r).unwrap(), + "zram swap is already off" + ); + assert!(r.0.is_empty()); + } + + #[test] + fn status_reports_the_ratio() { + let t = fake("4294967296\n", "lzo [zstd]\n"); + t.put( + "proc/swaps", + "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 2048 100\n", + ); + t.put( + "sys/block/zram0/mm_stat", + "4194304 1048576 1310720 0 1310720 0 0 0 0\n", + ); + let text = Zram::new(t.path()).status(); + assert!(text.starts_with("zram: on"), "{text}"); + assert!(text.contains("zstd")); + assert!(text.contains("4.0 MiB as 1.0 MiB (4.0:1)"), "{text}"); + + let t = fake("0\n", "[zstd]\n"); + assert_eq!(Zram::new(t.path()).status(), "zram: off\n"); + let t = TempDir::new(); + assert!( + Zram::new(t.path()) + .status() + .contains("module is not loaded") + ); + } +} From 593b379c0b7d75c8c620d4ba7c3a61543edd4128 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:18 +0000 Subject: [PATCH 4/9] feat(seg): segctl package, services and image wiring - pkg/segctl: PKGBUILD, install script (enables seg-zram and seg-tune, choosing the init by what is installed, so it works in the build's chroot), Makefile. Ships seg, the services for OpenRC, runit and dinit, the balanced profile and the weekly fstrim job. - build-in-container.sh builds it from tools/seg into the same local [seglinux] repository; both profiles list segctl; check-packages.sh and online-packages.sh know it is built locally. - Setup enables seg-zram and seg-tune on every installation, and no longer cupsd (installed, not started). - zram-setup (run by udev at module load) hands over to seg zram on when seg is installed, so systems updated before segctl keep their zram. - Fixes a stale path in a comment of the fstab code, and the swap partition test now looks for the fstab line instead of the word zram. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- crates/seglinux-setup/src/answer.rs | 3 + crates/seglinux-setup/src/disk.rs | 3 +- crates/seglinux-setup/src/plan.rs | 3 +- data/system/zram-setup | 7 ++ iso/iso-profiles/seglinux-lite/profile.yaml | 2 + iso/iso-profiles/seglinux/profile.yaml | 4 ++ iso/scripts/build-in-container.sh | 21 +++++- iso/scripts/check-packages.sh | 6 +- iso/scripts/online-packages.sh | 4 +- pkg/segctl/Makefile | 16 +++++ pkg/segctl/PKGBUILD | 78 +++++++++++++++++++++ pkg/segctl/segctl.install | 25 +++++++ 12 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 pkg/segctl/Makefile create mode 100644 pkg/segctl/PKGBUILD create mode 100644 pkg/segctl/segctl.install diff --git a/crates/seglinux-setup/src/answer.rs b/crates/seglinux-setup/src/answer.rs index bcf3541..3b002a2 100644 --- a/crates/seglinux-setup/src/answer.rs +++ b/crates/seglinux-setup/src/answer.rs @@ -339,6 +339,9 @@ pub const DEFAULT_SERVICES: &[&str] = &[ "ntpd", "acpid", "power-profiles-daemon", + // From the segctl package: zram swap and the saved `seg tune` profile. + "seg-zram", + "seg-tune", ]; /// Groups every user joins (missing ones are skipped). diff --git a/crates/seglinux-setup/src/disk.rs b/crates/seglinux-setup/src/disk.rs index d65e6b6..ae9b85e 100644 --- a/crates/seglinux-setup/src/disk.rs +++ b/crates/seglinux-setup/src/disk.rs @@ -270,7 +270,8 @@ impl Layout { )); } if self.zram.is_some() { - // Created and formatted by /etc/udev/rules.d/99-seglinux-zram.rules. + // Created and formatted at boot: the udev rule 60-seglinux-zram.rules runs + // seg zram on (the seg-zram service does too). out.push_str("/dev/zram0 none swap defaults,discard,pri=100 0 0\n"); } out diff --git a/crates/seglinux-setup/src/plan.rs b/crates/seglinux-setup/src/plan.rs index caea182..1f1295d 100644 --- a/crates/seglinux-setup/src/plan.rs +++ b/crates/seglinux-setup/src/plan.rs @@ -1281,7 +1281,8 @@ mod tests { "{text}" ); assert!(text.contains("/dev/vda4")); - assert!(!text.contains("zram")); + // No zram line in the fstab (the seg-zram service is on either way). + assert!(!text.contains("/dev/zram0"), "{text}"); assert!( text.contains( "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0\"" diff --git a/data/system/zram-setup b/data/system/zram-setup index 26d29e4..8d5396c 100755 --- a/data/system/zram-setup +++ b/data/system/zram-setup @@ -4,8 +4,15 @@ # nothing is written to the disk. Half the memory, at most 8 GB; zstd # usually fits it in a quarter of that. A swap partition or file stays, for # what zram cannot hold. +# +# seg (the segctl package) does the work when it is installed: `seg zram on` +# is the same thing, and the seg-zram service runs it too. The rest of this +# file is for systems that were updated to this script before segctl. set -eu PATH=/usr/bin:/usr/sbin:/bin:/sbin +if command -v seg >/dev/null 2>&1; then + exec seg zram on +fi dev=/dev/zram0 sys=/sys/block/zram0 # Already in use (a second run, or set up by hand): leave it. diff --git a/iso/iso-profiles/seglinux-lite/profile.yaml b/iso/iso-profiles/seglinux-lite/profile.yaml index 87b1676..62193df 100644 --- a/iso/iso-profiles/seglinux-lite/profile.yaml +++ b/iso/iso-profiles/seglinux-lite/profile.yaml @@ -25,6 +25,8 @@ rootfs: - seglinux # The seglinux package brings the boot menu theme; buildiso-hooks.sh # puts it on the image in place of Artix's. + # seg: zram swap, tuning and checks (tools/seg); also built locally. + - segctl - labwc - gtk4 - gtk4-layer-shell diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index af8885a..337abea 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -37,6 +37,10 @@ rootfs: - seglinux # The seglinux package brings the boot menu theme; buildiso-hooks.sh # puts it on the image in place of Artix's. + # seg: services on any init, cleaning, tuning, zram swap and checks. It + # enables the seg-zram and seg-tune services. Built from tools/seg into + # the same local repository. + - segctl # Chaotic-AUR's key and mirrors: Brave and its updates come from there # (see iso/scripts/chaotic-aur.sh). diff --git a/iso/scripts/build-in-container.sh b/iso/scripts/build-in-container.sh index 130818d..577d898 100755 --- a/iso/scripts/build-in-container.sh +++ b/iso/scripts/build-in-container.sh @@ -9,8 +9,8 @@ # PACKAGE_ONLY=true stops after the package (OUT_DIR/packages/): the update # the SegLinux update workflow publishes. # -# Steps: build the seglinux package from this checkout into a local pacman -# repository, point artools at it, check the package lists, run buildiso +# Steps: build the seglinux and segctl packages from this checkout into a +# local pacman repository, point artools at it, check the package lists, run buildiso # and copy the ISO (renamed seglinux-*.iso or seglinux-lite-*.iso) plus its # checksum to OUT_DIR. set -euo pipefail @@ -92,6 +92,23 @@ if [[ ${PACKAGE_ONLY:-false} == true ]]; then exit 0 fi +step "Building the segctl package" +# seg, from tools/seg: a package of its own that goes into the same local +# repository (the update workflow publishes only the seglinux package). +segctl_version=$(sed -n 's/^pkgver=//p' "$repo/pkg/segctl/PKGBUILD") +segwork=/var/tmp/segctl-pkg +rm -rf "$segwork" +mkdir -p "$segwork" +cp "$repo/pkg/segctl/PKGBUILD" "$repo/pkg/segctl/segctl.install" "$segwork/" +tar -C "$repo" --exclude=./tools/seg/target \ + --transform "s,^\./tools/seg,segctl-$segctl_version/tools/seg," \ + -czf "$segwork/segctl-$segctl_version.tar.gz" ./tools/seg +chown -R "$build_user:" "$segwork" +(cd "$segwork" && sudo -H -u "$build_user" env CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-2}" \ + ${RUSTUP_HOME:+"RUSTUP_HOME=$RUSTUP_HOME"} makepkg -f --noconfirm) +cp "$segwork"/segctl-*.pkg.tar.zst "$local_repo/" +repo-add -q "$local_repo/seglinux.db.tar.gz" "$local_repo"/segctl-*.pkg.tar.zst + step "Configuring artools" conf="iso-x86_64.conf" # Use one explicit config location under both sudo and pkexec. artools diff --git a/iso/scripts/check-packages.sh b/iso/scripts/check-packages.sh index 0661c69..55a9c57 100755 --- a/iso/scripts/check-packages.sh +++ b/iso/scripts/check-packages.sh @@ -67,10 +67,10 @@ cp "$conf" "$work_conf" skip=() if [[ ! -f $local_repo/seglinux.db ]]; then - # The seglinux package is built during the ISO build; when checking - # before that, leave the local repository out. + # The seglinux and segctl packages are built during the ISO build; when + # checking before that, leave the local repository out. awk '/^\[seglinux\]/{skip=1; next} /^\[/{skip=0} !skip' "$conf" > "$work_conf" - skip=(seglinux) + skip=(seglinux segctl) echo "note: $local_repo has no repository yet; not checking: ${skip[*]}" fi diff --git a/iso/scripts/online-packages.sh b/iso/scripts/online-packages.sh index 9986bcc..2b6edae 100755 --- a/iso/scripts/online-packages.sh +++ b/iso/scripts/online-packages.sh @@ -46,5 +46,5 @@ lite=$(rootfs "$profiles/seglinux-lite/common.yaml" "$profiles/seglinux-lite/pro echo "# Packages SegLinux Setup downloads when it installs the Lite edition:" echo "# the full edition's ($init, $kernel) minus what the Lite image has." -# seglinux itself is built locally and already on the image. -comm -23 <(echo "$full") <(echo "$lite") | grep -vx seglinux +# seglinux and segctl are built locally and already on the image. +comm -23 <(echo "$full") <(echo "$lite") | grep -vxE 'seglinux|segctl' || true diff --git a/pkg/segctl/Makefile b/pkg/segctl/Makefile new file mode 100644 index 0000000..49bd942 --- /dev/null +++ b/pkg/segctl/Makefile @@ -0,0 +1,16 @@ +# Build the segctl package from the working tree on an Arch/Artix host: +# make -C pkg/segctl package +VERSION := $(shell sed -n 's/^pkgver=//p' PKGBUILD) +ROOT := $(abspath ../..) + +source: + tar -C $(ROOT) --exclude=./tools/seg/target \ + --transform 's,^\./tools/seg,segctl-$(VERSION)/tools/seg,' -czf segctl-$(VERSION).tar.gz ./tools/seg + +package: source + makepkg -f --noconfirm + +clean: + rm -rf src pkg segctl-*.tar.gz *.pkg.tar.* + +.PHONY: source package clean diff --git a/pkg/segctl/PKGBUILD b/pkg/segctl/PKGBUILD new file mode 100644 index 0000000..63dd517 --- /dev/null +++ b/pkg/segctl/PKGBUILD @@ -0,0 +1,78 @@ +# Maintainer: SegLinux contributors +# +# seg, the SegLinux system tool (tools/seg in the repository): services on +# any Artix init, cleaning, tuning, zram swap and checks. The source tarball is +# made by iso/scripts/build-in-container.sh (or by hand with +# `make -C pkg/segctl source`) from the repository's tools/seg folder. + +pkgname=segctl +pkgver=0.1.0 +pkgrel=1 +pkgdesc="seg: services, cleaning, tuning, zram swap and health checks for SegLinux, on OpenRC, runit, dinit and s6" +arch=('x86_64') +url="https://github.com/SegMind25/SegLinux" +# No license has been chosen for the SegLinux repository yet. +license=('LicenseRef-SegLinux-unspecified') +# util-linux: mkswap, swapon, swapoff, dmesg, fstrim, logger and ionice; +# kmod: modprobe for the zram module; coreutils: df. +depends=('gcc-libs' 'glibc' 'util-linux' 'kmod' 'coreutils') +makedepends=('cargo') +optdepends=( + 'cronie: the weekly fstrim job (/etc/cron.d/seg-fstrim)' + 'pacman: seg clean and seg doctor' +) +# The profile the user chose, and the sysctl values it wrote. +backup=( + 'etc/seg/tune.conf' + 'etc/sysctl.d/99-seg.conf' +) +install=segctl.install +options=('!lto') +source=("$pkgname-$pkgver.tar.gz") +sha256sums=('SKIP') + +prepare() { + cd "$pkgname-$pkgver/tools/seg" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "$pkgname-$pkgver/tools/seg" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --frozen --release +} + +check() { + cd "$pkgname-$pkgver/tools/seg" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo test --frozen --release +} + +package() { + cd "$pkgname-$pkgver/tools/seg" + + install -Dm755 target/release/seg "$pkgdir/usr/bin/seg" + + # The seg-tune and seg-zram services for each init the project builds + # (they do nothing on one that is not installed). + install -Dm755 -t "$pkgdir/etc/init.d" dist/openrc/seg-tune dist/openrc/seg-zram + local svc + for svc in seg-tune seg-zram; do + install -Dm755 "dist/runit/$svc/run" "$pkgdir/etc/runit/sv/$svc/run" + done + install -Dm755 dist/runit/seg-zram/finish "$pkgdir/etc/runit/sv/seg-zram/finish" + install -Dm644 -t "$pkgdir/etc/dinit.d" dist/dinit/seg-tune dist/dinit/seg-zram + + # Defaults: the balanced profile. + install -Dm644 dist/tune.conf "$pkgdir/etc/seg/tune.conf" + install -Dm644 dist/99-seg.conf "$pkgdir/etc/sysctl.d/99-seg.conf" + + # TRIM for SSDs once a week. + install -Dm755 dist/fstrim-weekly "$pkgdir/usr/lib/seg/fstrim-weekly" + install -Dm644 dist/cron-fstrim "$pkgdir/etc/cron.d/seg-fstrim" + + install -Dm644 README.md "$pkgdir/usr/share/doc/seg/README.md" +} diff --git a/pkg/segctl/segctl.install b/pkg/segctl/segctl.install new file mode 100644 index 0000000..55c5e66 --- /dev/null +++ b/pkg/segctl/segctl.install @@ -0,0 +1,25 @@ +# Start the zram swap and the tuning profile at every boot. +# +# This may run in a chroot (the image build) whose init is not the host's, so +# the init is chosen by what is installed here (--offline), not by PID 1. +enable_services() { + command -v seg >/dev/null 2>&1 || return 0 + local svc + for svc in seg-zram seg-tune; do + seg svc --offline enable "$svc" >/dev/null 2>&1 || true + done +} + +post_install() { + enable_services +} + +# Not on upgrade: someone who disabled a service keeps it disabled. + +pre_remove() { + command -v seg >/dev/null 2>&1 || return 0 + local svc + for svc in seg-tune seg-zram; do + seg svc --offline disable "$svc" >/dev/null 2>&1 || true + done +} From 61e3e6c257e1f7467586a8f4668c587b436a8993 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:30 +0000 Subject: [PATCH 5/9] perf: makepkg builds with every thread; I/O scheduler rule skips partitions - /etc/makepkg.conf.d/10-seglinux.conf sets MAKEFLAGS="-j$(nproc)" (the generic x86-64 code generation stays, no -march=native). - udev-ioschedulers.rules: the KERNEL globs (sd[a-z]*, nvme[0-9]*n[0-9]*) also matched partitions, which have no queue/scheduler to write, so udev logged an error for every partition. Only whole disks are matched now, and virtual disks (vd*, xvd*) get none like seg tune sets, since their host does the scheduling. Checked with udevadm verify. pacman.conf already has Color and ParallelDownloads = 5. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- data/system/makepkg-seglinux.conf | 4 ++++ data/system/udev-ioschedulers.rules | 20 ++++++++++++++------ packaging/seglinux/PKGBUILD | 4 +++- 3 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 data/system/makepkg-seglinux.conf diff --git a/data/system/makepkg-seglinux.conf b/data/system/makepkg-seglinux.conf new file mode 100644 index 0000000..1536f28 --- /dev/null +++ b/data/system/makepkg-seglinux.conf @@ -0,0 +1,4 @@ +# SegLinux: build with every CPU thread (read after /etc/makepkg.conf). +# The generic x86-64 code generation is kept (no -march=native), so packages +# built here also run on other computers. +MAKEFLAGS="-j$(nproc)" diff --git a/data/system/udev-ioschedulers.rules b/data/system/udev-ioschedulers.rules index f44adac..efd03ed 100644 --- a/data/system/udev-ioschedulers.rules +++ b/data/system/udev-ioschedulers.rules @@ -1,6 +1,14 @@ -# I/O schedulers for low latency while games stream assets: -# NVMe drives schedule themselves, other SSDs use mq-deadline and -# spinning disks use BFQ, which keeps the desktop responsive. -ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", ATTR{queue/scheduler}="none" -ACTION=="add|change", KERNEL=="sd[a-z]*|mmcblk[0-9]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline" -ACTION=="add|change", KERNEL=="sd[a-z]*", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq" +# I/O schedulers for low latency while games stream assets (seg tune picks +# the same ones): NVMe drives and virtual disks leave scheduling to their +# controller or host, other SSDs use mq-deadline and spinning disks use +# BFQ, which keeps the desktop responsive. Only whole disks have a queue: +# partitions (nvme0n1p1, sda1) are skipped. +SUBSYSTEM!="block", GOTO="seglinux_ioschedulers_end" +ENV{DEVTYPE}!="disk", GOTO="seglinux_ioschedulers_end" +ACTION!="add|change", GOTO="seglinux_ioschedulers_end" + +KERNEL=="nvme[0-9]*n[0-9]*|vd[a-z]|xvd[a-z]", ATTR{queue/scheduler}="none" +KERNEL=="sd[a-z]|sd[a-z][a-z]|mmcblk[0-9]", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline" +KERNEL=="sd[a-z]|sd[a-z][a-z]", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq" + +LABEL="seglinux_ioschedulers_end" diff --git a/packaging/seglinux/PKGBUILD b/packaging/seglinux/PKGBUILD index b4eead0..f23cf37 100644 --- a/packaging/seglinux/PKGBUILD +++ b/packaging/seglinux/PKGBUILD @@ -6,7 +6,7 @@ pkgname=seglinux pkgver=0.1.0 -pkgrel=8 +pkgrel=9 pkgdesc="SegLinux: lightweight Rust desktop, Windows program and game launcher, graphical installer and gaming tuning" arch=('x86_64') url="https://github.com/SegMind25/SegLinux" @@ -51,6 +51,7 @@ backup=( 'etc/binfmt.d/wine.conf' 'etc/sysctl.d/80-seglinux-gaming.conf' 'etc/sysctl.d/81-seglinux-network.conf' + 'etc/makepkg.conf.d/10-seglinux.conf' 'etc/xdg/xdg-desktop-portal-wlr/config' 'etc/gamemode.ini' 'etc/security/limits.d/90-seglinux-esync.conf' @@ -165,6 +166,7 @@ package() { install -Dm644 "$data/system/modules-zram.conf" "$pkgdir/usr/lib/modules-load.d/seglinux-zram.conf" install -Dm644 "$data/system/udev-zram.rules" "$pkgdir/usr/lib/udev/rules.d/60-seglinux-zram.rules" install -Dm755 "$data/system/zram-setup" "$pkgdir/usr/lib/seglinux/zram-setup" + install -Dm644 "$data/system/makepkg-seglinux.conf" "$pkgdir/etc/makepkg.conf.d/10-seglinux.conf" install -Dm644 "$data/desktop/xdpw-config" "$pkgdir/etc/xdg/xdg-desktop-portal-wlr/config" install -Dm644 "$data/system/udev-ntsync.rules" "$pkgdir/usr/lib/udev/rules.d/70-seglinux-ntsync.rules" From 775db511308ab46ba91da70dd1d92ea9034f757f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:30 +0000 Subject: [PATCH 6/9] feat: ripgrep, fd, eza and zoxide in the full edition Rust command-line tools from the Artix repositories (no AUR), chosen because each is small and replaces a daily habit: - ripgrep (rg) searches folders far faster than grep -r and skips what .gitignore skips; fd finds files by name without find's syntax. - eza is ls with colors and git status (aliased as ll and la only); zoxide gives z, which jumps to the folder you use most. They add a few MiB (the next build report gives the exact figure) against the 438 MiB the earlier commits take off (linux-zen-headers 325.1 MiB and opencv 112.8 MiB, from the 19 October build report, plus the X server). Not added, with reasons in docs/lean-audit.md: bat, dust, bottom, procs and sd (they replace nothing that is installed), helix (a runtime of grammars next to nano), alacritty (the terminal is the maintainers' call), zellij, sudo-rs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- data/system/rust-tools.bashrc | 12 ++++++++++++ iso/iso-profiles/seglinux/profile.yaml | 6 ++++++ packaging/seglinux/PKGBUILD | 2 ++ 3 files changed, 20 insertions(+) create mode 100644 data/system/rust-tools.bashrc diff --git a/data/system/rust-tools.bashrc b/data/system/rust-tools.bashrc new file mode 100644 index 0000000..f9e804c --- /dev/null +++ b/data/system/rust-tools.bashrc @@ -0,0 +1,12 @@ +# SegLinux: conveniences for the Rust command-line tools, when they are +# installed (ripgrep and fd need none). Aliases only for new names: ls, cd, +# grep and find keep working as everyone expects, in scripts and out. +[[ $- == *i* ]] || return 0 +if command -v eza >/dev/null; then + alias ll='eza -l --group-directories-first' + alias la='eza -la --group-directories-first' +fi +if command -v zoxide >/dev/null; then + # `z folder` jumps to the folder you use most that matches. + eval "$(zoxide init bash)" +fi diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index 337abea..4405251 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -80,6 +80,12 @@ rootfs: - discover # Microsoft Store - flatpak + # Command-line tools written in Rust (see docs/lean-audit.md) + - ripgrep # rg: searches folders far faster than grep -r + - fd # fd: find with sane defaults + - eza # ls with colors and git status (ll, la) + - zoxide # z: cd to the folders you use most + # Accessories - dolphin # File Explorer - kitty # Terminal diff --git a/packaging/seglinux/PKGBUILD b/packaging/seglinux/PKGBUILD index f23cf37..772cb95 100644 --- a/packaging/seglinux/PKGBUILD +++ b/packaging/seglinux/PKGBUILD @@ -63,6 +63,7 @@ backup=( 'etc/xdg/mimeapps.list' 'etc/xdg/fastfetch/config.jsonc' 'etc/bash/bashrc.d/seglinux-fastfetch.bashrc' + 'etc/bash/bashrc.d/seglinux-rust-tools.bashrc' ) # The project was called Wincraft before. provides=('wincraft') @@ -184,6 +185,7 @@ package() { install -Dm644 "$data/fastfetch/config.jsonc" "$pkgdir/etc/xdg/fastfetch/config.jsonc" install -Dm644 "$data/fastfetch/logo.txt" "$pkgdir/usr/share/seglinux/fastfetch/logo.txt" install -Dm644 "$data/system/fastfetch.bashrc" "$pkgdir/etc/bash/bashrc.d/seglinux-fastfetch.bashrc" + install -Dm644 "$data/system/rust-tools.bashrc" "$pkgdir/etc/bash/bashrc.d/seglinux-rust-tools.bashrc" install -Dm755 "$data/system/apply-branding" "$pkgdir/usr/lib/seglinux/apply-branding" install -Dm755 "$data/system/pipewire-session" "$pkgdir/usr/lib/seglinux/pipewire-session" install -Dm755 "$data/system/restore-trimmed-files" "$pkgdir/usr/lib/seglinux/restore-trimmed-files" From 749fe5ea912c5a38dea6f3d3070d5d87eadc55f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:55:18 +0000 Subject: [PATCH 7/9] chore(ci): check seg, the udev rules, repeated packages and more scripts - A seg job: cargo fmt --check, clippy with -D warnings (with and without the test cfg, so the unwrap/expect ban is enforced), tests, and the release binary's size in the job summary. It fails if seg gains a dependency besides lexopt. - udevadm verify on data/system/*.rules. - A package may be listed only once per profile and init; the five repeats this found in the full profile are gone. - shellcheck also covers zram-setup (it was missing), the seg services, the weekly fstrim script, segctl.install and rust-tools.bashrc. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a9efbf..0214499 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,37 @@ jobs: - name: Validate the default segrun configuration run: cargo run --locked -q -p segrun -- --config data/seglinux/segrun.toml config + # seg (tools/seg) is a workspace of its own with its own release profile. + seg: + name: seg (fmt, clippy, tests, size) + runs-on: ubuntu-latest + defaults: + run: + working-directory: tools/seg + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tools/seg + - run: cargo fmt --check + - run: cargo clippy --locked --all-targets -- -D warnings + # Without the test cfg, where unwrap and expect are compile errors. + - run: cargo clippy --locked -- -D warnings + - run: cargo test --locked + - name: Dependencies stay minimal (seg and lexopt only) + run: | + cargo tree --locked -e normal --prefix none | sort -u + test "$(cargo tree --locked -e normal --prefix none | sort -u | wc -l)" -le 2 + - name: Release binary size + run: | + cargo build --locked --release + ls -l target/release/seg + target/release/seg --version + echo "seg is $(stat -c %s target/release/seg) bytes" >> "$GITHUB_STEP_SUMMARY" + scripts: name: Shell scripts and profile files runs-on: ubuntu-latest @@ -68,12 +99,15 @@ jobs: run: | shellcheck -s bash iso/build.sh iso/scripts/*.sh packaging/seglinux/seglinux.install data/system/seglinux-upgrade data/system/seglinux-update \ packaging/make-signing-key.sh crates/seglinux-shell/tests/desktop-services.sh \ - data/system/fastfetch.bashrc + data/system/fastfetch.bashrc data/system/rust-tools.bashrc shellcheck -s sh data/system/apply-branding data/system/pipewire-session \ iso/iso-profiles/seglinux/live-overlay/usr/lib/seglinux/live-autostart \ data/desktop/seglinux-session data/desktop/seglinux-gaming-session \ data/desktop/labwc/autostart data/system/seglinux-terminal data/system/restore-trimmed-files \ - data/system/profile-gaming.sh data/greeter/seglinux-greeter-session + data/system/profile-gaming.sh data/greeter/seglinux-greeter-session data/system/zram-setup \ + tools/seg/dist/fstrim-weekly tools/seg/dist/runit/*/run tools/seg/dist/runit/seg-zram/finish \ + tools/seg/dist/openrc/seg-tune tools/seg/dist/openrc/seg-zram + shellcheck -s bash pkg/segctl/segctl.install shellcheck -s bash iso/iso-profiles/seglinux-lite/live-overlay/etc/skel/.bash_profile - name: YAML, JSON and XML syntax run: | @@ -85,6 +119,30 @@ jobs: sed '/^ *\/\//d' data/fastfetch/config.jsonc | python3 -m json.tool > /dev/null python3 -c 'import sys, xml.dom.minidom as m; [m.parse(f) for f in sys.argv[1:]]' data/desktop/labwc/rc.xml \ data/greeter/labwc/rc.xml data/greeter/labwc/menu.xml + - name: Package lists have no repeats + run: | + python3 - <<'EOF' + import collections, yaml + failed = False + for profile, common in (("seglinux", "iso/iso-profiles/common/common.yaml"), + ("seglinux-lite", "iso/iso-profiles/seglinux-lite/common.yaml")): + c = yaml.safe_load(open(common)) + p = yaml.safe_load(open(f"iso/iso-profiles/{profile}/profile.yaml")) + xorg = "packages-xlibre" if p["live-session"].get("use-xlibre") else "packages-xorg" + for init in ("openrc", "runit", "dinit"): + names = [] + for key in ("packages-base", "packages-apps", xorg, "packages-misc"): + names += c.get(key) or [] + names += (c.get("packages-init") or {}).get(init) or [] + names += p["rootfs"]["packages"] + ((p["rootfs"].get("packages-init") or {}).get(init) or []) + repeats = [n for n, k in collections.Counter(names).items() if k > 1] + if repeats: + failed = True + print(f"{profile} ({init}): listed more than once: {', '.join(repeats)}") + raise SystemExit(1 if failed else 0) + EOF + - name: udev rules + run: udevadm verify --no-style data/system/*.rules - name: Overlay links resolve run: | broken=$(find iso/iso-profiles -xtype l) From 67dc6e0321a64fcc4e43d917281a3bbfcdb97c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:57:20 +0000 Subject: [PATCH 8/9] docs: Rust tools, bug log, README and building notes for seg and the audit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- README.md | 6 +++++ docs/building.md | 3 ++- docs/lean-audit.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 37c04ae..1d71170 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ published. starts it, under GameMode when it is a game. * **Fast, systemd-free base.** OpenRC by default (parallel boot), or runit or dinit; rolling release. +* **`seg`, one small tool for the system.** `seg info`, `seg svc` (the same + commands on OpenRC, runit, dinit and s6), `seg clean`, `seg tune` + (light, balanced or performance), `seg zram` and `seg doctor`, which finds + problems and prints their fixes. A single Rust binary of about 550 KiB; see + [tools/seg](tools/seg/README.md). What the images weigh and what was taken + off them: [docs/lean-audit.md](docs/lean-audit.md). ## Website diff --git a/docs/building.md b/docs/building.md index dc70747..4cf6f61 100644 --- a/docs/building.md +++ b/docs/building.md @@ -3,7 +3,8 @@ SegLinux is built with Artix Linux's own ISO tooling: `buildiso` from [artools](https://gitea.artixlinux.org/artix/artools) reads `iso/iso-profiles/seglinux/profile.yaml`. The Rust tools are packaged with -`packaging/seglinux/PKGBUILD` and served to `buildiso` from a local pacman +`packaging/seglinux/PKGBUILD` (and `seg`, the system tool, with +`pkg/segctl/PKGBUILD`) and served to `buildiso` from a local pacman repository during the build. ## Quick start (any Linux host with docker or podman) diff --git a/docs/lean-audit.md b/docs/lean-audit.md index 1fddc33..2d95e83 100644 --- a/docs/lean-audit.md +++ b/docs/lean-audit.md @@ -75,6 +75,62 @@ Protected, never touched: `base`, the kernel, `openrc` and the `-openrc` / `-runit` / `-dinit` service packages, `elogind`, `artix-keyring` (through `base`), `pacman`, `sudo`, NetworkManager, the firmware packages. +## Rust command-line tools + +Only packages from the Artix repositories; none from the AUR. Sizes are not +given because they could not be measured here (no repository access); the +build report ("What takes the space") of the next ISO shows them. + +| Tool | Added | Replaces | Why | +|---|---|---|---| +| `ripgrep` (`rg`) | full edition | `grep -r` for searching folders | far faster on a tree, skips `.gitignore`d files and binaries | +| `fd` | full edition | `find -name` | sane defaults, readable syntax | +| `eza` | full edition | `ls -l` (as the new names `ll`, `la`; `ls` itself is untouched) | colors, git status, folders first | +| `zoxide` | full edition | `cd` by typing paths (`z folder`) | learns the folders you use | +| `bat`, `dust`, `bottom`, `procs`, `sd` | no | nothing installed | they would only add to the image | +| `helix` | no | `nano` | it needs a runtime of grammars; nano is already there and small | +| `alacritty` | no | `kitty`/`foot` | the terminal is the maintainers' call (see below) | +| `zellij` | no | no multiplexer is wanted | | +| `sudo-rs` | no | `sudo` | `sudo` is protected (the live user, Setup and `artools` use it) and its availability in the Artix repositories could not be checked | +| `greetd` + `tuigreet` | no | | SegLinux already signs in through greetd with its own Rust greeter | + +They are installed in the full edition only (the Lite image stays as it +was); Lite's Setup downloads them with the rest. `ll`, `la` and `z` come from +`/etc/bash/bashrc.d/seglinux-rust-tools.bashrc`. + +## Bugs found + +File and line are those of the commit before this work (`4ca2f45`). + +| file:line | problem | fix | +|---|---|---| +| `data/system/udev-ioschedulers.rules:4-6` | the globs `nvme[0-9]*n[0-9]*` and `sd[a-z]*` also match partitions (`nvme0n1p1`, `sda1`), which have no `queue/scheduler`: udev logged an error for each. Virtual disks (`vda`) got no rule. | only whole disks (`DEVTYPE=="disk"`); `vd*`/`xvd*` get `none`; checked with `udevadm verify` | +| `iso/iso-profiles/seglinux/profile.yaml:79-82,123` | `xorg-server`, `xorg-xwayland`, `xdg-user-dirs`, `xdg-utils` and `ttf-liberation` also in `common.yaml:55,77-79,82` | listed once; CI now fails on a repeat | +| `crates/seglinux-setup/src/plan.rs:824` | the NVIDIA DKMS driver could only build because 325 MiB of kernel headers were on every image | Setup installs the running kernel's headers in that step | +| `iso/scripts/build-in-container.sh:157-160` | the artools patch that reads the kernel version without headers applied to non-stock kernels only; the stock kernel relied on `linux-headers` | applies to every kernel | +| `crates/seglinux-setup/src/disk.rs:273` | comment names `/etc/udev/rules.d/99-seglinux-zram.rules`, which does not exist (it is `/usr/lib/udev/rules.d/60-seglinux-zram.rules`) | corrected | +| `.github/workflows/ci.yml:72-76` | `data/system/zram-setup` was not shellchecked; the udev rules were not checked at all | both are now; new job for `seg` | +| `crates/seglinux-setup/src/plan.rs:1273` | the swap partition test asserted that the plan never contains the word `zram` | asserts there is no `/dev/zram0` fstab line, which is what it means | + +Checked and fine: `shellcheck` is clean on every shell script of the +repository (also with the style level on); every file the PKGBUILD installs +exists; the executable bits are right; the init-suffixed packages are in the +list of their init; there is no systemd unit anywhere; the only hard-coded +account is the live session's `seglinux` user (in `sudoers.d`, live only); +pacman.conf lists the Artix repositories first and Chaotic-AUR last, with +`SigLevel = Required DatabaseOptional`, `Color` and `ParallelDownloads = 5`. + +Found, not changed: + +* `PKGBUILD:14`: `license=('LicenseRef-SegLinux-unspecified')`: the project has + no license yet; that is the owner's decision. +* `swap = "none"` in an answer file still gets zram: the module is loaded and + the udev rule starts it whatever the answer says. +* The two greetd `config.toml` and `pacman.conf` copies in the root overlays + are identical to each other (and the first to `data/greeter/greetd.toml`). +* `seglinux-upgrade` updates only the `seglinux` package, so a system + installed before this work does not get `segctl` that way yet. + ## Decisions for the maintainers These are product choices, so they were measured and left alone: From 57a1b1f53246050244b92ab9a36cd5960f5e182a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:59:48 +0000 Subject: [PATCH 9/9] chore(ci): temporary ISO check for the work branch [iso check] Builds both editions on seg/rust-lean-overhaul without publishing anything (the ISO workflow cannot be started from here), lists what the system image contains and does not contain, and boots Lite in QEMU, logs in on a text console and runs seg. Removed again once the branch has been checked. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .github/iso-check/inspect.sh | 36 +++++++++ .github/iso-check/qemu_check.py | 130 ++++++++++++++++++++++++++++++++ .github/workflows/iso-check.yml | 51 +++++++++++++ 3 files changed, 217 insertions(+) create mode 100755 .github/iso-check/inspect.sh create mode 100644 .github/iso-check/qemu_check.py create mode 100644 .github/workflows/iso-check.yml diff --git a/.github/iso-check/inspect.sh b/.github/iso-check/inspect.sh new file mode 100755 index 0000000..7c20c1d --- /dev/null +++ b/.github/iso-check/inspect.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Looks inside a built ISO's system image (without booting it) and prints +# what this branch changed: sizes, what is and is not installed, which +# services are enabled. Usage: inspect.sh ISO +set -uo pipefail +iso=$1 +sudo apt-get install -y -qq squashfs-tools >/dev/null +sudo mkdir -p /mnt/iso +sudo mount -o loop,ro "$iso" /mnt/iso +echo "== ISO: $(stat -c %s "$iso") bytes" +ls -l /mnt/iso/LiveOS /mnt/iso/boot | grep -E 'img|vmlinuz' +list=$(mktemp) +sudo unsquashfs -ll /mnt/iso/LiveOS/rootfs.img > "$list" 2>/dev/null +echo "== listing: $(wc -l < "$list") entries" +has() { grep -qE "squashfs-root/$1( ->.*)?\$" "$list" && echo "present $1" || echo "ABSENT $1"; } +echo "== packages in the image: $(grep -cE 'squashfs-root/var/lib/pacman/local/[^/]+/desc$' "$list")" +echo "== should be there" +for f in usr/bin/seg etc/init.d/seg-zram etc/init.d/seg-tune etc/runlevels/default/seg-zram etc/runlevels/default/seg-tune \ + etc/seg/tune.conf etc/sysctl.d/99-seg.conf etc/cron.d/seg-fstrim usr/lib/seg/fstrim-weekly \ + etc/makepkg.conf.d/10-seglinux.conf usr/lib/seglinux/zram-setup usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules \ + etc/runit/sv/seg-zram/run etc/dinit.d/seg-zram; do has "$f"; done +if [[ $EDITION == full ]]; then + for f in usr/bin/rg usr/bin/fd usr/bin/eza usr/bin/zoxide etc/bash/bashrc.d/seglinux-rust-tools.bashrc usr/bin/xwayland usr/bin/Xwayland; do has "$f"; done +fi +echo "== should be gone" +for f in usr/bin/Xorg usr/bin/spectacle usr/bin/inxi; do has "$f"; done +echo "kernel header folders: $(grep -cE 'squashfs-root/usr/src/[^/]+$' "$list") (0 expected)" +echo "== enabled in the image" +grep -E 'squashfs-root/etc/runlevels/[a-z]+/[^/]+ ->' "$list" | sed -E 's#.*squashfs-root/etc/runlevels/##; s# ->.*##' | sort | tr '\n' ' ' +echo +echo "== packages that need the most room (installed size, MiB)" +sudo unsquashfs -f -d /tmp/rt /mnt/iso/LiveOS/rootfs.img var/lib/pacman/local >/dev/null 2>&1 +for d in /tmp/rt/var/lib/pacman/local/*/; do + n=$(basename "$d"); size=$(awk '/^%SIZE%/{getline; print $1}' "$d/desc"); echo "$((size / 1048576)) $n" +done | sort -rn | head -12 +echo "total installed MiB: $(for d in /tmp/rt/var/lib/pacman/local/*/; do awk '/^%SIZE%/{getline; s+=$1} END{print s+0}' "$d/desc"; done | awk '{t+=$1} END{printf "%d", t / 1048576}')" diff --git a/.github/iso-check/qemu_check.py b/.github/iso-check/qemu_check.py new file mode 100644 index 0000000..40b5914 --- /dev/null +++ b/.github/iso-check/qemu_check.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Boot an ISO headless in QEMU, log in on a text console and run seg. + +Best effort and for the log only: it drives QEMU's monitor (screendump and +sendkey), reads the screen with tesseract and prints what it saw. It always +exits 0; the image checks before it are what fail the job. + +usage: qemu_check.py ISO [boot_seconds] +""" +import os +import socket +import subprocess +import sys +import time + +from PIL import Image + +ISO = sys.argv[1] +BOOT_WAIT = int(sys.argv[2]) if len(sys.argv) > 2 else 240 +MON = "/tmp/qemu-mon.sock" +WORK = "/tmp/qemu-check" +os.makedirs(WORK, exist_ok=True) + +KEYS = {" ": "spc", "-": "minus", ".": "dot", ";": "semicolon", "/": "slash", "\n": "ret", "_": "shift-minus", "=": "equal"} + + +def log(msg): + print(msg, flush=True) + + +class Monitor: + def __init__(self, path): + deadline = time.time() + 30 + while True: + try: + self.sock = socket.socket(socket.AF_UNIX) + self.sock.connect(path) + break + except OSError: + if time.time() > deadline: + raise + time.sleep(0.5) + self.sock.settimeout(5) + self.read() + + def read(self): + data = b"" + try: + while True: + chunk = self.sock.recv(4096) + if not chunk: + break + data += chunk + if data.rstrip().endswith(b"(qemu)"): + break + except socket.timeout: + pass + return data.decode(errors="replace") + + def cmd(self, text): + self.sock.sendall(text.encode() + b"\n") + return self.read() + + def key(self, name): + self.cmd(f"sendkey {name}") + time.sleep(0.12) + + def type(self, text): + for ch in text: + self.key(KEYS.get(ch, ch)) + + def screen_text(self, tag): + ppm = f"{WORK}/{tag}.ppm" + self.cmd(f"screendump {ppm}") + time.sleep(1) + png = f"{WORK}/{tag}.png" + try: + img = Image.open(ppm) + img = img.resize((img.width * 2, img.height * 2)) + img.save(png) + out = subprocess.run(["tesseract", png, "-", "--psm", "6"], capture_output=True, text=True, timeout=60).stdout + except Exception as e: # the screen may not be written yet + return f"(no screen: {e})" + return "\n".join(line for line in out.splitlines() if line.strip()) + + +def main(): + args = ["qemu-system-x86_64", "-m", "3072", "-smp", "2", "-cdrom", ISO, "-boot", "d", + "-display", "none", "-vga", "std", "-monitor", f"unix:{MON},server,nowait", + "-netdev", "user,id=n0", "-device", "virtio-net-pci,netdev=n0"] + if os.access("/dev/kvm", os.R_OK | os.W_OK): + args += ["-enable-kvm", "-cpu", "host"] + log("qemu: using KVM") + else: + args += ["-cpu", "max"] + log("qemu: no KVM, emulating (slow)") + boot_wait = BOOT_WAIT if "-enable-kvm" in args else max(BOOT_WAIT, 420) + qemu = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + try: + mon = Monitor(MON) + start = time.time() + n = 0 + while time.time() - start < boot_wait: + time.sleep(30) + n += 1 + text = mon.screen_text(f"boot{n}") + log(f"--- screen after {int(time.time() - start)} s:\n" + "\n".join(text.splitlines()[:12])) + # Switch to the second console, where agetty waits for a login. + mon.cmd("sendkey ctrl-alt-f2") + time.sleep(5) + log("--- console 2:\n" + mon.screen_text("tty2")) + mon.type("seglinux\n") + time.sleep(4) + mon.type("seglinux\n") + time.sleep(8) + for i, command in enumerate(["seg info", "seg zram status", "seg tune", "seg svc list --enabled", "seg doctor"]): + mon.type("clear\n") + time.sleep(1) + mon.type(command + "\n") + time.sleep(12) + log(f"--- $ {command}:\n" + mon.screen_text(f"cmd{i}")) + except Exception as e: + log(f"qemu check stopped: {type(e).__name__}: {e}") + finally: + qemu.kill() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/iso-check.yml b/.github/workflows/iso-check.yml new file mode 100644 index 0000000..d939bad --- /dev/null +++ b/.github/workflows/iso-check.yml @@ -0,0 +1,51 @@ +name: ISO check + +# Builds an image on a branch WITHOUT publishing anything, looks inside it +# and boots the Lite one in QEMU. Starts on a push to the work branch whose +# commit message contains "[iso check]". Temporary: removed once the +# seg/rust-lean-overhaul branch is checked. +on: + push: + branches: [seg/rust-lean-overhaul] + +permissions: + contents: read + +jobs: + iso: + name: Build and inspect (${{ matrix.edition }}, openrc, linux-zen) + if: contains(github.event.head_commit.message, '[iso check]') + runs-on: ubuntu-latest + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + edition: [lite, full] + env: + EDITION: ${{ matrix.edition }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + sudo docker system prune -af > /dev/null + df -h / + - name: Load the loop module + run: sudo modprobe loop + - name: Build + run: | + edition=() + [[ $EDITION == lite ]] && edition=(--lite) + SEGLINUX_PKGVER="0.1.0.$(git rev-list --count origin/main)" \ + ./iso/build.sh "${edition[@]}" -e docker -i openrc -k linux-zen + - name: Inside the image + run: .github/iso-check/inspect.sh "$(ls iso/out/seglinux-*.iso)" + - name: Boot it in QEMU (Lite) + if: matrix.edition == 'lite' + continue-on-error: true + run: | + sudo apt-get install -y -qq qemu-system-x86 tesseract-ocr python3-pil >/dev/null + [[ -e /dev/kvm ]] && sudo chmod 666 /dev/kvm || true + python3 .github/iso-check/qemu_check.py "$(ls iso/out/seglinux-*.iso)" 150