Last active
December 19, 2025 13:58
-
-
Save kidpixo/5b4178b0ccd0c02b4cd554ab1e9ad43a to your computer and use it in GitHub Desktop.
Health checkups for the sophisticated Arch user: sanity checks for your rolling-release setup written in pure python3 stdlib, no dependencies (hell).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import subprocess | |
| import os | |
| import sys | |
| import argparse | |
| import shutil | |
| import platform | |
| # --- Configuration & Colors --- | |
| BLUE = '\033[34m' | |
| CYAN = '\033[36m' | |
| RED = '\033[31m' | |
| GREEN = '\033[32m' | |
| YELLOW = '\033[33m' | |
| BOLD = '\033[1m' | |
| RESET = '\033[0m' | |
| # The high-detail ASCII logo provided | |
| ARCH_LOGO = [ | |
| f"{CYAN}⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣷⣤⣙⢻⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⡿⠛⠛⠿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⠏⠀⠀⠀⠀⠙⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⠀⣰⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⢿⣿⣿⣿⣿⠿⣆⠀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣷⣦⡀⠀⠀⠀{RESET}", | |
| f"{CYAN}⠀⢀⣾⣿⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠉⠉⠙⠛⠻⠿⣿⣿⣷⡀⠀{RESET}", | |
| f"{CYAN}⣠⠟⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⣄{RESET}", | |
| ] | |
| # Shared counter for the final summary | |
| ISSUE_COUNT = 0 | |
| def print_header(title): | |
| print(f"\n{BOLD}{'='*10} {title} {'='*10}{RESET}") | |
| # --- Helper: Device Origin --- | |
| def get_device_origin(mount_point): | |
| try: | |
| # findmnt gets the source (e.g., /dev/mapper/volume-home) | |
| result = subprocess.check_output(["findmnt", "-nno", "SOURCE", mount_point], text=True).strip() | |
| dev_name = os.path.basename(result) | |
| # lsblk gets the parent (e.g., cryptlvm or nvme0n1) | |
| lineage = subprocess.check_output(["lsblk", "-no", "PKNAME", result], text=True).strip().split('\n')[-1] | |
| return dev_name, lineage if lineage else dev_name | |
| except: | |
| return "unknown", "unknown" | |
| # --- Main Check Functions --- | |
| def print_logo_info(): | |
| # Gather System Info | |
| info = { | |
| 'User': os.getlogin(), | |
| 'Host': platform.node(), | |
| 'OS': "Arch Linux", | |
| 'Kernel': platform.release(), | |
| 'Shell': os.environ.get('SHELL', 'N/A').split('/')[-1], | |
| } | |
| try: | |
| with open('/proc/cpuinfo', 'r') as f: | |
| cpu = [line.split(':')[1].strip() for line in f if "model name" in line][0] | |
| info['CPU'] = cpu.split('@')[0].strip() | |
| except: info['CPU'] = "Unknown" | |
| try: | |
| with open('/proc/meminfo', 'r') as f: | |
| lines = f.readlines() | |
| total = int(lines[0].split()[1]) // 1024 | |
| avail = int(lines[2].split()[1]) // 1024 | |
| info['Memory'] = f"{total - avail}MiB / {total}MiB" | |
| except: info['Memory'] = "Unknown" | |
| data_lines = [ | |
| f"{CYAN}{BOLD}{info['User']}@{info['Host']}{RESET}", | |
| f"{'─' * (len(info['User']) + len(info['Host']) + 1)}", | |
| f"{BOLD}OS:{RESET} {info['OS']}", | |
| f"{BOLD}Kernel:{RESET} {info['Kernel']}", | |
| f"{BOLD}Shell:{RESET} {info['Shell']}", | |
| f"{BOLD}CPU:{RESET} {info['CPU']}", | |
| f"{BOLD}Memory:{RESET} {info['Memory']}" | |
| ] | |
| print("") | |
| for i in range(max(len(ARCH_LOGO), len(data_lines))): | |
| logo = ARCH_LOGO[i] if i < len(ARCH_LOGO) else " " * 20 | |
| text = data_lines[i] if i < len(data_lines) else "" | |
| print(f" {logo} {text}") | |
| def check_disk(): | |
| global ISSUE_COUNT | |
| print_header("Disk Usage & Origins") | |
| critical_mounts = ['/', '/boot', '/home', '/var'] | |
| print(f"{BOLD}{'Mount':<10} : {'Usage':<8} : {'Free':<10} : {'Type':<8} : {'Origin'}{RESET}") | |
| print("─" * 85) | |
| with open('/proc/mounts', 'r') as f: | |
| mount_data = {l.split()[1]: l.split()[2] for l in f if l.split()[1] in critical_mounts} | |
| for mount in critical_mounts: | |
| if mount not in mount_data: continue | |
| try: | |
| total, used, free = shutil.disk_usage(mount) | |
| percent = (used / total) * 100 | |
| dev, parent = get_device_origin(mount) | |
| color = GREEN | |
| if percent > 90: | |
| color = RED | |
| ISSUE_COUNT += 1 | |
| elif percent > 75: color = YELLOW | |
| origin = f"{dev} ({parent})" if dev != parent else dev | |
| print(f"{mount:<10} : {color}{percent:>6.1f}%{RESET} : {free/(2**30):>7.2f} GB : {mount_data[mount]:<8} : {origin}") | |
| except: continue | |
| def check_kernel(): | |
| global ISSUE_COUNT | |
| print_header("Kernel Version Check") | |
| try: | |
| pac_out = subprocess.check_output(["pacman", "-Qi", "linux"], text=True) | |
| installed = next(l.split(":")[1].strip() for l in pac_out.splitlines() if l.startswith("Version")) | |
| running = subprocess.check_output(["uname", "-r"], text=True).strip() | |
| p_v, r_v = installed.replace('-', '.').split('.'), running.replace('-', '.').split('.') | |
| mismatch = False | |
| print(f"{'Component':<12} : {'Installed':<12} : {'Running'}") | |
| print("─" * 45) | |
| for lbl, p, r in zip(['Major', 'Minor', 'Patch', 'Arch Rel'], p_v, r_v): | |
| if p != r: mismatch = True | |
| print(f"{GREEN if p==r else RED}{lbl:<12} : {p:<12} {'==' if p==r else '!='} {r}{RESET}") | |
| if mismatch: | |
| ISSUE_COUNT += 1 | |
| print(f"\n{RED}{BOLD}![REBOOT REQUIRED]: Running kernel mismatch.{RESET}") | |
| except: print(f"{RED}Kernel check failed.{RESET}") | |
| def check_pacnew(): | |
| global ISSUE_COUNT | |
| print_header("Config Files (.pacnew)") | |
| found = [os.path.join(r, f) for r, _, fs in os.walk('/etc') for f in fs if f.endswith(('.pacnew', '.pacsave'))] | |
| if found: | |
| ISSUE_COUNT += len(found) | |
| for f in found: print(f"{YELLOW} -> {f}{RESET}") | |
| else: print(f"{GREEN}No pending merges.{RESET}") | |
| def check_failed_services(): | |
| global ISSUE_COUNT | |
| print_header("Failed Services") | |
| try: | |
| out = subprocess.check_output(["systemctl", "list-units", "--state=failed", "--plain", "--no-legend"], text=True).strip() | |
| if out: | |
| lines = out.splitlines() | |
| ISSUE_COUNT += len(lines) | |
| for line in lines: print(f"{RED} -> {line.split()[0]}{RESET}") | |
| else: print(f"{GREEN}All units OK.{RESET}") | |
| except: pass | |
| def check_orphans(): | |
| print_header("Orphaned Packages") | |
| try: | |
| out = subprocess.check_output(["pacman", "-Qdtq"], text=True).strip() | |
| if out: print(f"{YELLOW}Orphans: {out.replace(chr(10), ', ')}{RESET}") | |
| else: print(f"{GREEN}No orphans.{RESET}") | |
| except: print(f"{GREEN}No orphans.{RESET}") | |
| def check_stats(): | |
| print_header("Pacman Statistics") | |
| try: | |
| def get_count(flags): | |
| try: | |
| out = subprocess.check_output(["pacman", flags], text=True, stderr=subprocess.DEVNULL) | |
| return len(out.strip().splitlines()) | |
| except subprocess.CalledProcessError: | |
| return 0 | |
| total = get_count("-Q") | |
| explicit = get_count("-Qe") | |
| deps = get_count("-Qd") | |
| foreign = get_count("-Qm") | |
| native = total - foreign | |
| # Calculate Pacman Cache Size | |
| cache_path = "/var/cache/pacman/pkg" | |
| cache_size_str = "Unknown" | |
| if os.path.exists(cache_path): | |
| # Using du -sh is the easiest way to get human-readable directory size | |
| try: | |
| cache_out = subprocess.check_output(["du", "-sh", cache_path], text=True).split()[0] | |
| cache_size_str = cache_out | |
| except: pass | |
| print(f"{BOLD}{'Category':<18} : {'Count/Size'}{RESET}") | |
| print("─" * 35) | |
| print(f"{'Total Packages':<18} : {total}") | |
| print(f"{' ┗━ Native':<18} : {native}") | |
| print(f"{' ┗━ Foreign/AUR':<18} : {CYAN}{foreign}{RESET}") | |
| print("-" * 35) | |
| print(f"{'Explicitly Sourced':<18} : {explicit}") | |
| print(f"{'As Dependencies':<18} : {deps}") | |
| print("-" * 35) | |
| print(f"{'Pacman Cache Size':<18} : {YELLOW}{cache_size_str}{RESET}") | |
| except Exception as e: | |
| print(f"{RED}Could not retrieve stats: {e}{RESET}") | |
| # --- Main --- | |
| def main(): | |
| # 1. Setup Parser | |
| # Use RawDescriptionHelpFormatter to preserve newlines in descriptions | |
| parser = argparse.ArgumentParser( | |
| description=f"{CYAN}{BOLD}Arch Linux System Health Utility{RESET}", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=f""" | |
| {BOLD}Usage Examples:{RESET} | |
| arch-health -a Run every check available. | |
| arch-health -k -d Check only kernel and disk status. | |
| arch-health -p Scan for configuration merges. | |
| {BOLD}Extended Descriptions:{RESET} | |
| {BOLD}--kernel{RESET} Compares 'uname -r' with the version in the pacman DB. | |
| If they differ, your system cannot load new modules until reboot. | |
| {BOLD}--pacnew{RESET} Scans /etc for .pacnew and .pacsave files. These are created | |
| when an update has a new default config but you've modified yours. | |
| {BOLD}--services{RESET} Queries systemd for any units in a 'failed' state. Useful for | |
| catching silent background daemon crashes. | |
| {BOLD}--orphans{RESET} Lists packages installed as dependencies but no longer required | |
| by any other package. Helps keep the system lean. | |
| {BOLD}--disk{RESET} Analyzes usage for /, /boot, and /home. Specifically tracks | |
| LVM/LUKS lineage to show you the physical origin of each mount. | |
| {BOLD}--stats{RESET} Show pacman package statistics (Native vs AUR) | |
| """ | |
| ) | |
| parser.add_argument("-l", "--logo", action="store_true", help="Print the Arch logo and hardware summary") | |
| parser.add_argument("-k", "--kernel", action="store_true", help="Check for kernel/running version mismatch") | |
| parser.add_argument("-p", "--pacnew", action="store_true", help="Scan for unmerged .pacnew config files") | |
| parser.add_argument("-s", "--services", action="store_true", help="List failed systemd services") | |
| parser.add_argument("-o", "--orphans", action="store_true", help="List orphaned packages (unused dependencies)") | |
| parser.add_argument("-d", "--disk", action="store_true", help="Show usage, filesystem type, and LVM/LUKS origin") | |
| parser.add_argument("-a", "--all", action="store_true", help="Perform all health checks and show logo") | |
| parser.add_argument("-t", "--stats", action="store_true", help="Show pacman package statistics (Native vs AUR)") | |
| args = parser.parse_args() | |
| # 2. Help/Early Exit Check | |
| if len(sys.argv) == 1: | |
| parser.print_help() | |
| sys.exit(0) | |
| # 3. THE ARCH CHECK (The Gatekeeper) | |
| if not os.path.exists("/etc/arch-release"): | |
| print(f"{RED}{BOLD}Error:{RESET} This script requires Arch Linux.") | |
| print("Required file '/etc/arch-release' not found.") | |
| sys.exit(1) | |
| # 4. Proceed with checks... | |
| checks = [ | |
| (args.logo, print_logo_info), | |
| (args.kernel, check_kernel), | |
| (args.pacnew, check_pacnew), | |
| (args.services, check_failed_services), | |
| (args.orphans, check_orphans), | |
| (args.disk, check_disk), | |
| (args.stats, check_stats), | |
| ] | |
| for selected, func in checks: | |
| if args.all or selected: | |
| func() | |
| print_header("Summary") | |
| if ISSUE_COUNT == 0: | |
| print(f"{GREEN}{BOLD}✔ System Healthy: No issues detected.{RESET}") | |
| else: | |
| print(f"{RED}{BOLD}✘ Attention Required: {ISSUE_COUNT} potential issue(s) found.{RESET}") | |
| print("") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment