Files
dotfiles/system-config/scripts/verify.py
T

169 lines
5.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read-only invariant checks for the active Decman configuration."""
from __future__ import annotations
import json
import os
import runpy
import shutil
import subprocess
import sys
from collections import Counter
from collections.abc import Callable
from pathlib import Path
from typing import cast
ROOT = Path(__file__).resolve().parents[1]
PACKAGES = ROOT / "packages"
def lines(path: Path) -> set[str]:
return {
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
}
def actual(command: str) -> set[str]:
return set(
subprocess.run(
["pacman", command], check=True, text=True, stdout=subprocess.PIPE
).stdout.split()
)
def npm_actual() -> set[str]:
if shutil.which("npm") is None:
return set()
raw = subprocess.run(
["npm", "list", "--global", "--depth=0", "--json"],
check=True,
text=True,
stdout=subprocess.PIPE,
).stdout
try:
result = json.loads(raw)
except json.JSONDecodeError as error:
raise SystemExit("npm returned invalid JSON") from error
dependencies = result.get("dependencies", {})
if not isinstance(dependencies, dict):
raise SystemExit("npm dependencies must be an object")
return set(dependencies)
def optional_lines(path: Path) -> set[str]:
return lines(path) if path.exists() else set()
def main() -> None:
repo_files = list(PACKAGES.glob("**/*-repo.txt"))
aur_files = list(PACKAGES.glob("**/*-aur.txt"))
npm_files = list(PACKAGES.glob("**/*-npm.txt"))
repo = [name for path in repo_files for name in lines(path)]
aur = [name for path in aur_files for name in lines(path)]
npm = [name for path in npm_files for name in lines(path)]
duplicate = {name for name, count in Counter(repo + aur).items() if count > 1}
duplicate_npm = {name for name, count in Counter(npm).items() if count > 1}
if duplicate:
raise SystemExit(f"duplicate package ownership: {sorted(duplicate)}")
if duplicate_npm:
raise SystemExit(f"duplicate npm package ownership: {sorted(duplicate_npm)}")
installed_repo = actual("-Qqen")
installed_aur = actual("-Qqem")
installed_npm = npm_actual()
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
import decman
runpy.run_path(str(ROOT / "source.py"))
active_concerns = {
concern
for module in decman.modules
if isinstance((concern := getattr(module, "concern", None)), str)
}
expected_repo = set().union(
*(lines(PACKAGES / f"{concern}-repo.txt") for concern in active_concerns)
)
expected_aur = set().union(
*(lines(PACKAGES / f"{concern}-aur.txt") for concern in active_concerns)
)
expected_npm = set().union(
*(optional_lines(PACKAGES / f"{concern}-npm.txt") for concern in active_concerns)
)
loaded_repo: set[str] = set()
loaded_aur: set[str] = set()
loaded_npm: set[str] = set()
for module in decman.modules:
native = getattr(module, "native_packages", None)
foreign = getattr(module, "aur_packages", None)
global_npm = getattr(module, "global_npm_packages", None)
if callable(native):
loaded_repo |= cast(Callable[[], set[str]], native)()
if callable(foreign):
loaded_aur |= cast(Callable[[], set[str]], foreign)()
if callable(global_npm):
loaded_npm |= cast(Callable[[], set[str]], global_npm)()
if (
loaded_repo != expected_repo
or loaded_aur != expected_aur
or loaded_npm != expected_npm
):
raise SystemExit("active profile does not compose its package concerns")
system_manifests = {
name
for module in decman.modules
for name in getattr(module, "_system_manifests", ())
}
user_manifests = {
name
for module in decman.modules
for name in getattr(module, "_user_manifests", ())
}
expected_system = set().union(
*(lines(ROOT / "manifests" / name) for name in system_manifests)
)
expected_user = set().union(
*(lines(ROOT / "manifests" / name) for name in user_manifests)
)
loaded_system: set[str] = set()
loaded_user: set[str] = set()
for module in decman.modules:
system = getattr(module, "system_units", None)
user = getattr(module, "user_units", None)
if callable(system):
loaded_system |= cast(Callable[[], set[str]], system)()
if callable(user):
loaded_user |= cast(Callable[[], dict[str, set[str]]], user)().get(
"alex", set()
)
if loaded_system != expected_system or loaded_user != expected_user:
raise SystemExit("active profile does not compose its unit manifests")
host_scopes = active_concerns & {"laptop/hardware", "desktop/local"}
if len(host_scopes) != 1:
raise SystemExit(f"active profile has ambiguous host concerns: {host_scopes}")
profile = next(iter(host_scopes)).split("/", maxsplit=1)[0]
pending_remove = sorted(
(installed_repo - expected_repo) | (installed_aur - expected_aur)
)
pending_install = sorted(
(expected_repo - installed_repo) | (expected_aur - installed_aur)
)
pending_npm_install = sorted(expected_npm - installed_npm)
print(
f"OK: repo={len(expected_repo)} aur={len(expected_aur)} "
f"npm={len(expected_npm)} system={len(loaded_system)} "
f"user={len(loaded_user)} concerns={len(active_concerns)} profile={profile}"
)
print(f"PENDING REMOVE: {' '.join(pending_remove) or 'none'}")
print(f"PENDING INSTALL: {' '.join(pending_install) or 'none'}")
print(f"PENDING NPM INSTALL: {' '.join(pending_npm_install) or 'none'}")
if __name__ == "__main__":
main()