aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/import-wireguard-configs.sh59
-rw-r--r--scripts/testing/tests/test_boot.py16
-rw-r--r--scripts/testing/tests/test_desktop.py29
-rw-r--r--scripts/testing/tests/test_packages.py49
-rwxr-xr-xscripts/zfs-pre-snapshot43
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