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/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) 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 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/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/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..3b002a2 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,12 +334,14 @@ pub const DEFAULT_SERVICES: &[&str] = &[ "NetworkManager", "greetd", "bluetoothd", - "cupsd", "cronie", "syslog-ng", "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/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..1f1295d 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, @@ -1270,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\"" @@ -1288,10 +1300,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/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/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/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/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/docs/building.md b/docs/building.md index a7f0716..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) @@ -22,12 +23,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/lean-audit.md b/docs/lean-audit.md new file mode 100644 index 0000000..2d95e83 --- /dev/null +++ b/docs/lean-audit.md @@ -0,0 +1,146 @@ +# 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. + +## 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: + +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. 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/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-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 c0e19d6..4405251 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 @@ -38,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). @@ -76,17 +79,18 @@ rootfs: - print-manager # Devices and Printers - discover # Microsoft Store - flatpak - - xorg-server - - xorg-xwayland - - xdg-user-dirs - - xdg-utils + + # 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 - kate # Notepad - kcalc # Calculator - - spectacle # Snipping Tool - gwenview # Photos - okular # PDF reader - ark # compressed folders @@ -120,7 +124,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..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 @@ -131,9 +148,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 +174,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 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/packaging/seglinux/PKGBUILD b/packaging/seglinux/PKGBUILD index b4eead0..772cb95 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' @@ -62,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') @@ -165,6 +167,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" @@ -182,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" 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 +} 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") + ); + } +}