diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/import-wireguard-configs.sh | 59 | ||||
| -rw-r--r-- | scripts/testing/tests/test_boot.py | 16 | ||||
| -rw-r--r-- | scripts/testing/tests/test_desktop.py | 29 | ||||
| -rw-r--r-- | scripts/testing/tests/test_packages.py | 49 | ||||
| -rwxr-xr-x | scripts/zfs-pre-snapshot | 43 |
5 files changed, 196 insertions, 0 deletions
diff --git a/scripts/import-wireguard-configs.sh b/scripts/import-wireguard-configs.sh new file mode 100755 index 0000000..ae6ca7e --- /dev/null +++ b/scripts/import-wireguard-configs.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Import the assets/wireguard-config Proton configs into NetworkManager as +# wireguard connections with autoconnect off. Two NM quirks handled here: +# +# - The import filename must be a valid interface name (<= 15 chars), and +# several config names are longer — so every file imports through a temp +# copy named wgpvpn.conf and the connection is renamed to the real config +# name right after (by the UUID parsed from the import output, so a stray +# same-named connection can't be hit). All profiles share the wgpvpn +# interface, which is fine (they're mutually exclusive full-tunnel +# configs), and the wg prefix keeps the net doctor's tunnel-down repair +# on the NM path. +# - Imports default to autoconnect yes, and these are full-tunnel +# (AllowedIPs 0.0.0.0/0) — a VPN that arms itself on boot is not a default +# anyone chose, so the modify runs immediately after each import. +# +# A connection still literally named wgpvpn means an earlier run died +# between import and rename — and it still has autoconnect on. The script +# refuses to run until that's cleaned up rather than guessing. +# +# Idempotent: already-imported names skip. +# +# Usage: import-wireguard-configs.sh [config-dir] +set -euo pipefail + +dir="${1:-$(cd "$(dirname "$0")/.." && pwd)/assets/wireguard-config}" +[ -d "$dir" ] || { echo "no such config dir: $dir" >&2; exit 1; } + +if nmcli -t -f NAME connection show | grep -Fxq "wgpvpn"; then + echo "stale 'wgpvpn' connection found (an earlier run died mid-import; it has autoconnect ON)" >&2 + echo "inspect and remove it first: nmcli connection delete wgpvpn" >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +shopt -s nullglob +found=0 +for conf in "$dir"/*.conf; do + found=1 + name="$(basename "$conf" .conf)" + if nmcli -t -f NAME connection show | grep -Fxq "$name"; then + echo "skip: $name (already imported)" + continue + fi + cp "$conf" "$tmp/wgpvpn.conf" + out="$(nmcli connection import type wireguard file "$tmp/wgpvpn.conf")" + uuid="$(grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' <<<"$out" | head -1 || true)" + if [ -z "$uuid" ]; then + echo "could not parse a UUID from the import output for $name:" >&2 + echo " $out" >&2 + exit 1 + fi + nmcli connection modify "$uuid" connection.id "$name" \ + connection.autoconnect no + echo "imported: $name (autoconnect off, iface wgpvpn)" +done +[ "$found" = 1 ] || { echo "no .conf files in $dir" >&2; exit 1; } diff --git a/scripts/testing/tests/test_boot.py b/scripts/testing/tests/test_boot.py index 78b4404..e442682 100644 --- a/scripts/testing/tests/test_boot.py +++ b/scripts/testing/tests/test_boot.py @@ -65,3 +65,19 @@ def test_zfs_has_sanoid(host): if not host.exists("zfs"): pytest.skip("ZFS not installed (non-ZFS system)") assert host.exists("sanoid"), "ZFS system should have sanoid installed" + + +def test_zfs_pre_pacman_snapshot_hook(host): + # archsetup installs a PreTransaction pacman hook + a self-pruning script so + # every pacman transaction is preceded by a rollback snapshot (configure_ + # pre_pacman_snapshots, run late in boot_ux). ZFS-root only. + if not host.exists("zfs"): + pytest.skip("ZFS not installed (non-ZFS system)") + script = host.file("/usr/local/bin/zfs-pre-snapshot") + assert script.exists and script.is_file, "pre-pacman snapshot script missing" + assert script.mode & 0o111, "pre-pacman snapshot script is not executable" + hook = host.file("/etc/pacman.d/hooks/zfs-snapshot.hook") + assert hook.exists and hook.is_file, "zfs-snapshot.hook missing" + assert "PreTransaction" in hook.content_string, "hook not PreTransaction" + assert "/usr/local/bin/zfs-pre-snapshot" in hook.content_string, \ + "hook does not exec the snapshot script" diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py index c02d2b6..6f79bfd 100644 --- a/scripts/testing/tests/test_desktop.py +++ b/scripts/testing/tests/test_desktop.py @@ -109,3 +109,32 @@ def test_autologin_configured(host): if not conf.exists: pytest.skip("autologin not configured (AUTOLOGIN=no, may be intentional)") assert conf.exists + + +BT_PANEL_BINS = ["bt", "bt-panel", "bt-priv", "waybar-bt"] + + +@pytest.mark.attribution("archsetup") +@pytest.mark.parametrize("name", BT_PANEL_BINS) +def test_bt_panel_bin_stowed(host, hyprland_installed, home, name): + # Executable via either stow shape (per-file symlink or folded dir). + if not hyprland_installed: + pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)") + path = "%s/.local/bin/%s" % (home, name) + assert host.file(path).exists, "%s missing from ~/.local/bin" % name + assert host.run("test -x %s" % path).rc == 0, "%s not executable" % name + + +@pytest.mark.attribution("archsetup") +def test_bt_panel_wired(host, hyprland_installed, home): + # A fresh install lands the panel reachable: bar module, keybind, css. + if not hyprland_installed: + pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)") + waybar = host.file("%s/.config/waybar/config" % home) + assert "custom/bluetooth" in waybar.content_string, \ + "waybar config lacks the custom/bluetooth module" + hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home) + assert "bt-panel" in hyprconf.content_string, \ + "hyprland.conf lacks the bt-panel keybind" + assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \ + "shared panel css missing from the stowed theme" diff --git a/scripts/testing/tests/test_packages.py b/scripts/testing/tests/test_packages.py index f237088..e0387d6 100644 --- a/scripts/testing/tests/test_packages.py +++ b/scripts/testing/tests/test_packages.py @@ -58,3 +58,52 @@ def test_git_installed(host): @pytest.mark.parametrize("tool", DEV_TOOLS) def test_dev_tool_present(host, tool): assert host.exists(tool), "dev tool %s missing from PATH" % tool + + +BLUETOOTH_STACK = ["bluez", "bluez-utils"] +VPN_STACK = ["wireguard-tools", "proton-vpn-cli", "tailscale"] + + +@pytest.mark.attribution("archsetup") +@pytest.mark.parametrize("pkg", BLUETOOTH_STACK) +def test_bluetooth_stack_installed(host, pkg): + assert host.package(pkg).is_installed + + +# bt panel replaced blueman; zoom-web replaced zoom; the net panel's Tunnels +# view + proton-vpn-cli replaced the GTK app (they can't run concurrently). +RETIRED_PACKAGES = ["blueman", "zoom", "proton-vpn-gtk-app"] + + +@pytest.mark.attribution("archsetup") +@pytest.mark.parametrize("pkg", RETIRED_PACKAGES) +def test_retired_package_not_installed(host, pkg): + # A reappearance means an install step regressed. + assert not host.package(pkg).is_installed + + +@pytest.mark.attribution("archsetup") +@pytest.mark.parametrize("pkg", VPN_STACK) +def test_vpn_stack_installed(host, pkg): + assert host.package(pkg).is_installed + + +@pytest.mark.attribution("archsetup") +def test_tailscale_operator_granted(host, target_user): + # The installer grants operator so the net panel can toggle tailscale + # without sudo. Prefs only answer when the daemon is up. + if not host.service("tailscaled").is_running: + pytest.skip("tailscaled not running") + out = host.run("tailscale debug prefs") + assert out.rc == 0, "tailscale debug prefs failed" + assert '"OperatorUser": "%s"' % target_user in out.stdout + + +@pytest.mark.attribution("archsetup") +def test_eask_installed_user_local(host, home): + # Installed via npm -g --prefix ~/.local as the user; chime and + # linear-emacs shell out to it. + f = host.file("%s/.local/bin/eask" % home) + assert f.exists, "eask missing from ~/.local/bin" + npmrc = host.file("%s/.npmrc" % home) + assert npmrc.exists, ".npmrc (user npm prefix) not stowed" diff --git a/scripts/zfs-pre-snapshot b/scripts/zfs-pre-snapshot new file mode 100755 index 0000000..ed914d0 --- /dev/null +++ b/scripts/zfs-pre-snapshot @@ -0,0 +1,43 @@ +#!/bin/bash +# Snapshot the root dataset before a pacman transaction, then prune to the most +# recent $KEEP pre-pacman snapshots. Run from the zfs-snapshot.hook pacman hook +# (PreTransaction). Sanoid doesn't manage these (they aren't autosnap_ names), +# so retention is enforced here at creation time. +# +# Defaults match the live zroot layout; the ZFS_PRE_* env vars override them so +# the pruning logic is unit-testable against a fake zfs on PATH. + +POOL="${ZFS_PRE_POOL:-zroot}" +DATASET="${ZFS_PRE_DATASET:-$POOL/ROOT/default}" +LOCKFILE="${ZFS_PRE_LOCKFILE:-/tmp/.zfs-pre-snapshot.lock}" +MIN_INTERVAL="${ZFS_PRE_MIN_INTERVAL:-60}" +KEEP="${ZFS_PRE_KEEP:-10}" # pre-pacman snapshots to retain (recent-transaction rollback) + +# Skip if a snapshot was created within the last $MIN_INTERVAL seconds. A single +# pacman invocation can fire several transactions; this stops a burst of them +# from each cutting a near-identical snapshot. +if [ -f "$LOCKFILE" ]; then + last=$(stat -c %Y "$LOCKFILE" 2>/dev/null || echo 0) + now=$(date +%s) + if (( now - last < MIN_INTERVAL )); then + exit 0 + fi +fi + +TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) +SNAPSHOT_NAME="pre-pacman_$TIMESTAMP" + +if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then + echo "Created snapshot: $DATASET@$SNAPSHOT_NAME" + touch "$LOCKFILE" + + # Keep only the most recent $KEEP pre-pacman snapshots; destroy older ones. + zfs list -H -o name -t snapshot -s creation "$DATASET" 2>/dev/null \ + | grep '@pre-pacman_' \ + | head -n -"$KEEP" \ + | while read -r old; do + zfs destroy "$old" && echo "Pruned old snapshot: $old" + done +else + echo "Warning: Failed to create snapshot" >&2 +fi |
