From 3ad7407e0782f6cbf0e8319012cf729561fad191 Mon Sep 17 00:00:00 2001 From: Gabriel Gonzalez Pineda Date: Fri, 16 Oct 2026 15:12:14 +0100 Subject: [PATCH] fix(post-install): prioritize ZRAM ahead of disk swap --- AppImage/scripts/flask_proxmenux_routes.py | 2 +- .../tests/test_zram_profile_helpers.py | 250 +++++++++++++-- .../post_install/customizable_post_install.sh | 91 +++--- .../tests/test_zram_profile_lifecycle.sh | 286 ++++++++++++++++++ scripts/post_install/uninstall-tools.sh | 21 +- scripts/post_install/zram_profile_helpers.sh | 103 +++++-- 6 files changed, 646 insertions(+), 107 deletions(-) create mode 100644 scripts/post_install/tests/test_zram_profile_lifecycle.sh diff --git a/AppImage/scripts/flask_proxmenux_routes.py b/AppImage/scripts/flask_proxmenux_routes.py index 8f1f0d6c..48da2fbe 100644 --- a/AppImage/scripts/flask_proxmenux_routes.py +++ b/AppImage/scripts/flask_proxmenux_routes.py @@ -26,7 +26,7 @@ TOOL_METADATA = { 'system_limits': {'name': 'System Limits Increase', 'function': 'increase_system_limits', 'version': '1.1'}, # entropy removed — modern kernels 5.6+ have built-in entropy generation, haveged no longer needed 'memory_settings': {'name': 'Memory Settings Optimization', 'function': 'optimize_memory_settings', 'version': '1.1'}, - 'zram_swap': {'name': 'ZRAM-only Swap Profile', 'function': 'configure_zram_swap', 'version': '1.0'}, + 'zram_swap': {'name': 'ZRAM Swap Profile', 'function': 'configure_zram_swap', 'version': '1.0'}, 'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'}, 'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'}, 'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'}, diff --git a/AppImage/scripts/tests/test_zram_profile_helpers.py b/AppImage/scripts/tests/test_zram_profile_helpers.py index 47b14842..584af666 100644 --- a/AppImage/scripts/tests/test_zram_profile_helpers.py +++ b/AppImage/scripts/tests/test_zram_profile_helpers.py @@ -1,5 +1,6 @@ """Contract tests for the opt-in ZRAM profile's pure Bash helpers.""" +import os import subprocess import tempfile import unittest @@ -14,6 +15,16 @@ class HelperAvailabilityTests(unittest.TestCase): def test_helper_file_is_available(self): self.assertTrue(HELPERS.is_file(), f"ZRAM helper is missing: {HELPERS}") + def test_helper_can_be_sourced_twice(self): + result = subprocess.run( + ["bash", "-c", 'source "$1" && source "$1"', "bash", str(HELPERS)], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + def call_helper(function, *arguments): return subprocess.run( @@ -44,7 +55,7 @@ class ZramGeneratorConfigTests(unittest.TestCase): } self.assertIn("[zram0]", result.stdout.splitlines()) self.assertEqual(settings.get("zram-size", "").strip(), "ram / 10") - self.assertEqual(settings.get("swap-priority", "").strip(), "100") + self.assertEqual(settings.get("swap-priority", "").strip(), "32767") self.assertEqual(settings.get("compression-algorithm", "").strip(), expected_algorithm) self.assertNotIn("writeback", result.stdout.lower()) @@ -55,44 +66,107 @@ class ZramGeneratorConfigTests(unittest.TestCase): self.assert_config("true", "zstd(level=9)") -class NonZramSwapDetectionTests(unittest.TestCase): +class ForeignActiveZramSwapTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) directory = Path(self.temporary.name) + self.root = directory self.swaps_file = directory / "swaps" - self.fstab_file = directory / "fstab" - def detect(self, swaps, fstab): + def detect(self, swaps, owned_device=""): self.swaps_file.write_text(swaps, encoding="utf-8") - self.fstab_file.write_text(fstab, encoding="utf-8") - return call_helper("zram_has_non_zram_swap", self.swaps_file, self.fstab_file) + return call_helper( + "zram_list_foreign_active_zram_swap", self.swaps_file, owned_device + ) - def test_active_disk_swap_is_detected(self): + def test_active_disk_swap_is_allowed(self): result = self.detect( SWAPS_HEADER + "/dev/nvme0n1p3\tpartition\t1048576\t0\t-2\n", - "", ) self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") - def test_persistent_swapfile_is_detected(self): - result = self.detect(SWAPS_HEADER, "/swapfile none swap sw 0 0\n") + def test_active_unowned_zram0_is_detected(self): + result = self.detect(SWAPS_HEADER + "/dev/zram0\tpartition\t1048576\t0\t100\n") self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "active zram: /dev/zram0\n") - def test_commented_disk_swap_is_ignored(self): - result = self.detect(SWAPS_HEADER, " # /swapfile none swap sw 0 0\n") - self.assertEqual(result.returncode, 1, result.stderr) - - def test_active_and_persistent_zram_are_ignored(self): + def test_active_owned_zram0_is_allowed_alongside_disk_swap(self): result = self.detect( - SWAPS_HEADER + "/dev/zram0\tpartition\t1048576\t0\t100\n", - "/dev/zram0 none swap defaults 0 0\n", + SWAPS_HEADER + + "/dev/nvme0n1p3\tpartition\t1048576\t0\t-2\n" + + "/dev/zram0\tpartition\t1048576\t0\t100\n", + "/dev/zram0", ) - self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") - def test_regular_fstab_entry_does_not_count_as_swap(self): - result = self.detect(SWAPS_HEADER, "/dev/nvme0n1p2 / ext4 defaults 0 1\n") - self.assertEqual(result.returncode, 1, result.stderr) + def test_another_active_zram_device_is_rejected(self): + result = self.detect( + SWAPS_HEADER + + "/dev/zram0\tpartition\t1048576\t0\t100\n" + + "/dev/zram1\tpartition\t1048576\t0\t90\n", + "/dev/zram0", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "active zram: /dev/zram1\n") + + def test_swap_table_read_failure_is_reported(self): + fake_bin = self.root / "bin" + fake_bin.mkdir() + fake_awk = fake_bin / "awk" + fake_awk.write_text("#!/bin/sh\nexit 2\n", encoding="utf-8") + fake_awk.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}" + + result = subprocess.run( + [ + "bash", + "-c", + 'source "$1" || exit; shift; "$@"', + "bash", + str(HELPERS), + "zram_list_foreign_active_zram_swap", + str(self.swaps_file), + ], + capture_output=True, + text=True, + check=False, + env=env, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + + +class ForeignFstabZramSwapTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.fstab_file = Path(self.temporary.name) / "fstab" + + def detect(self, text): + self.fstab_file.write_text(text, encoding="utf-8") + return call_helper("zram_list_foreign_fstab_zram_swap", self.fstab_file) + + def test_disk_swap_entries_are_allowed(self): + result = self.detect( + "UUID=swap-partition none swap defaults 0 0\n" + "/swapfile none swap sw 0 0\n" + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + def test_zram_swap_entry_is_reported(self): + result = self.detect( + "UUID=swap-partition none swap defaults 0 0\n" + "/dev/zram1 none swap pri=100 0 0\n" + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "fstab zram: /dev/zram1\n") class ForeignZramConfigTests(unittest.TestCase): @@ -132,7 +206,7 @@ class ForeignZramConfigTests(unittest.TestCase): self.assertEqual(self.foreign_configs(), []) -class SystemdSwapDetectionTests(unittest.TestCase): +class ForeignSystemdZramUnitTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) @@ -140,27 +214,143 @@ class SystemdSwapDetectionTests(unittest.TestCase): self.system_dir = self.root / "etc/systemd/system" self.system_dir.mkdir(parents=True) - def test_administrator_defined_zram_and_other_swap_units_are_reported(self): - (self.system_dir / "dev-zram0.swap").write_text("", encoding="utf-8") - (self.system_dir / "custom.swap").write_text("", encoding="utf-8") + def test_disk_swap_unit_is_allowed_and_zram_units_are_reported(self): + (self.system_dir / "pve-swap.swap").write_text( + "[Swap]\nWhat=/dev/mapper/pve-swap\n", encoding="utf-8" + ) + (self.system_dir / "dev-zram0.swap").write_text( + "[Swap]\nWhat=/dev/zram0\n", encoding="utf-8" + ) + external = self.system_dir / "external.swap" + external.write_text("[Swap]\nWhat=/dev/zram1\n", encoding="utf-8") + vendor_dir = self.root / "usr/lib/systemd/system" + vendor_dir.mkdir(parents=True) + vendor = vendor_dir / "vendor-zram.swap" + vendor.write_text("[Swap]\nWhat=/dev/zram2\n", encoding="utf-8") - result = call_helper("zram_list_defined_systemd_swap_units", self.root) + result = call_helper("zram_list_foreign_systemd_zram_units", self.root) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( set(result.stdout.splitlines()), { - f"systemd: {self.system_dir / 'dev-zram0.swap'}", - f"systemd: {self.system_dir / 'custom.swap'}", + f"systemd zram unit: {self.system_dir / 'dev-zram0.swap'}", + f"systemd zram unit: {external}", + f"systemd zram unit: {vendor}", }, ) - def test_no_non_zram_systemd_swap_has_empty_output(self): - result = call_helper("zram_list_defined_systemd_swap_units", self.root) + def test_unrelated_custom_swap_unit_is_allowed(self): + (self.system_dir / "custom.swap").write_text( + "[Swap]\nWhat=/swapfile\n", encoding="utf-8" + ) + + result = call_helper("zram_list_foreign_systemd_zram_units", self.root) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + def test_reserved_zram0_unit_masked_with_dev_null_is_reported(self): + (self.system_dir / "dev-zram0.swap").symlink_to("/dev/null") + + result = call_helper("zram_list_foreign_systemd_zram_units", self.root) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout, + f"systemd zram unit masked: {self.system_dir / 'dev-zram0.swap'}\n", + ) + + def test_zram_swap_drop_in_is_reported(self): + dropins = self.system_dir / "custom.swap.d" + dropins.mkdir() + dropin = dropins / "10-zram.conf" + dropin.write_text("[Swap]\nWhat=/dev/zram1\n", encoding="utf-8") + + result = call_helper("zram_list_foreign_systemd_zram_units", self.root) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, f"systemd zram unit: {dropin}\n") + + def test_generator_wants_symlink_is_not_a_foreign_unit_file(self): + generator = self.root / "run/systemd/generator" + generator.mkdir(parents=True) + (generator / "dev-zram0.swap").write_text( + "[Swap]\nWhat=/dev/zram0\n", encoding="utf-8" + ) + wants = self.root / "run/systemd/system/swap.target.wants" + wants.mkdir(parents=True) + (wants / "dev-zram0.swap").symlink_to("../../generator/dev-zram0.swap") + + result = call_helper("zram_list_foreign_systemd_zram_units", self.root) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, "") +class ZramProfileActivationVerifierTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.block_root = self.root / "sys/block" + zram0 = self.block_root / "zram0" + zram0.mkdir(parents=True) + (zram0 / "disksize").write_text("3435973836\n", encoding="utf-8") + (zram0 / "comp_algorithm").write_text( + "lzo [zstd] lz4\n", encoding="utf-8" + ) + self.meminfo = self.root / "proc/meminfo" + self.meminfo.parent.mkdir(parents=True) + self.meminfo.write_text("MemTotal: 33554432 kB\n", encoding="utf-8") + self.swaps = self.root / "proc/swaps" + self.swaps.write_text(SWAPS_HEADER, encoding="utf-8") + + def verify(self): + return call_helper( + "zram_profile_verify_active", + "zstd", + "32767", + self.block_root, + self.meminfo, + self.swaps, + ) + + def test_profile_is_valid_with_lower_priority_disk_swap_active(self): + self.swaps.write_text( + SWAPS_HEADER + + "/dev/zram0\tpartition\t3355443\t0\t32767\n" + + "/dev/nvme0n1p3\tpartition\t1048576\t32\t-2\n", + encoding="utf-8", + ) + + result = self.verify() + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_profile_rejects_a_second_active_zram_device(self): + self.swaps.write_text( + SWAPS_HEADER + + "/dev/zram0\tpartition\t3355443\t0\t32767\n" + + "/dev/zram1\tpartition\t1048576\t0\t90\n", + encoding="utf-8", + ) + + result = self.verify() + + self.assertEqual(result.returncode, 1, result.stderr) + + def test_profile_rejects_zram_device_without_requested_priority(self): + self.swaps.write_text( + SWAPS_HEADER + + "/dev/zram0\tpartition\t3355443\t0\t100\n", + encoding="utf-8", + ) + + result = self.verify() + + self.assertEqual(result.returncode, 1, result.stderr) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index 86a8db17..49ed0f3c 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -2453,19 +2453,19 @@ EOF configure_zram_swap() { local FUNC_VERSION="1.0" - # description: Enable an opt-in ZRAM-only swap profile sized to one tenth of RAM. + # description: Enable an opt-in ZRAM swap profile sized to one tenth of RAM. pmx_journal_context "configure_zram_swap" "$FUNC_VERSION" local config=/etc/systemd/zram-generator.conf.d/90-proxmenux.conf local marker="$BASE_DIR/zram-swap.state" local package_added=false package_owned=false owned=false previous_active=false state_mutated=false - local previous_config="" previous_marker="" desired="" conflicts="" systemd_conflicts="" version="" algorithm="" - local supports_params=false failure="" old_umask swap_detection_status + local previous_config="" previous_marker="" desired="" conflicts="" fstab_conflicts="" systemd_conflicts="" version="" algorithm="" + local supports_params=false failure="" old_umask allowed_zram="" if ! declare -f zram_render_generator_config >/dev/null || - ! declare -f zram_has_non_zram_swap >/dev/null || - ! declare -f zram_list_non_zram_swap >/dev/null || - ! declare -f zram_list_defined_systemd_swap_units >/dev/null || + ! declare -f zram_list_foreign_active_zram_swap >/dev/null || + ! declare -f zram_list_foreign_fstab_zram_swap >/dev/null || + ! declare -f zram_list_foreign_systemd_zram_units >/dev/null || ! declare -f zram_foreign_generator_config >/dev/null || ! declare -f zram_profile_verify_active >/dev/null; then msg_error "$(translate "ZRAM profile helpers are missing")" @@ -2475,29 +2475,8 @@ configure_zram_swap() { msg_error "$(translate "Cannot inspect current swap configuration")" return 1 fi - if zram_has_non_zram_swap /proc/swaps /etc/fstab; then - conflicts="$(zram_list_non_zram_swap /proc/swaps /etc/fstab)" || return 1 - else - swap_detection_status=$? - if [[ "$swap_detection_status" -ne 1 ]]; then - msg_error "$(translate "Cannot inspect current swap configuration")" - return 1 - fi - fi - systemd_conflicts="$(zram_list_defined_systemd_swap_units)" - if [[ -n "$systemd_conflicts" ]]; then - [[ -n "$conflicts" ]] && conflicts+=$'\n' - conflicts+="$systemd_conflicts" - fi - if [[ -n "$conflicts" ]]; then - msg_error "$(translate "ZRAM-only profile requires removing these conflicting swap sources first:") $conflicts" - return 1 - fi - conflicts="$(zram_foreign_generator_config "$config")" - if [[ -n "$conflicts" ]]; then - msg_error "$(translate "Another ZRAM generator configuration exists; refusing to overwrite it:") $conflicts" - return 1 - fi + + # Validate ownership before allowing the existing /dev/zram0 device. if [[ -e "$marker" ]]; then if [[ ! -f "$config" || -L "$config" || -L "$marker" ]] || ! grep -qx '# ProxMenux ZRAM profile' "$config" || @@ -2509,8 +2488,37 @@ configure_zram_swap() { previous_config="$(cat "$config")" previous_marker="$(cat "$marker")" grep -qx 'installed_by_proxmenux=true' "$marker" && package_owned=true - elif [[ -e "$config" || -L "$config" ]] || awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' /proc/swaps; then - msg_error "$(translate "Existing zram0 configuration or swap is not owned by ProxMenux")" + allowed_zram=/dev/zram0 + fi + + if ! conflicts="$(zram_list_foreign_active_zram_swap /proc/swaps "$allowed_zram")" || + ! fstab_conflicts="$(zram_list_foreign_fstab_zram_swap /etc/fstab)" || + ! systemd_conflicts="$(zram_list_foreign_systemd_zram_units)"; then + msg_error "$(translate "Cannot inspect existing ZRAM configurations")" + return 1 + fi + if [[ -n "$fstab_conflicts" ]]; then + [[ -n "$conflicts" ]] && conflicts+=$'\n' + conflicts+="$fstab_conflicts" + fi + if [[ -n "$systemd_conflicts" ]]; then + [[ -n "$conflicts" ]] && conflicts+=$'\n' + conflicts+="$systemd_conflicts" + fi + if [[ -n "$conflicts" ]]; then + msg_error "$(translate "Another ZRAM swap setup exists; refusing to replace it:") $conflicts" + return 1 + fi + if ! conflicts="$(zram_foreign_generator_config "$config")"; then + msg_error "$(translate "Cannot inspect existing ZRAM generator configuration")" + return 1 + fi + if [[ -n "$conflicts" ]]; then + msg_error "$(translate "Another ZRAM generator configuration exists; refusing to overwrite it:") $conflicts" + return 1 + fi + if [[ "$owned" == false && ( -e "$config" || -L "$config" ) ]]; then + msg_error "$(translate "Existing zram0 configuration is not owned by ProxMenux")" return 1 fi @@ -2586,7 +2594,7 @@ configure_zram_swap() { fi if [[ -z "$failure" ]]; then register_tool "zram_swap" true "$FUNC_VERSION" - msg_success "$(translate "ZRAM-only swap profile enabled")" + msg_success "$(translate "ZRAM swap profile enabled")" return 0 fi @@ -2620,21 +2628,6 @@ configure_zram_swap() { return 1 } -zram_profile_verify_active() { - local expected_algorithm="$1" expected_bytes actual_bytes active_algorithm - [[ -r /sys/block/zram0/disksize && -r /sys/block/zram0/comp_algorithm ]] || return 1 - expected_bytes="$(awk '/^MemTotal:/ { print int($2 * 1024 / 10) }' /proc/meminfo)" - actual_bytes="$(cat /sys/block/zram0/disksize)" - [[ "$expected_bytes" =~ ^[0-9]+$ && "$actual_bytes" =~ ^[0-9]+$ ]] || return 1 - (( actual_bytes >= expected_bytes - 1048576 && actual_bytes <= expected_bytes + 1048576 )) || return 1 - active_algorithm="$(grep -oE '\[[^]]+\]' /sys/block/zram0/comp_algorithm | tr -d '[]')" - [[ "$active_algorithm" == "$expected_algorithm" ]] || return 1 - awk 'NR > 1 { if ($1 !~ /^\/dev\/zram[0-9]+$/) bad=1; if ($1 == "/dev/zram0") found=1 } END { exit !(found && !bad) }' /proc/swaps -} - - - - # ========================================================== @@ -3858,7 +3851,7 @@ custom_post_description_label() { "Enable High Availability services") translate "Enable High Availability services" ;; "Install Figurine") translate "Install Figurine" ;; "Install and configure Log2RAM") translate "Install and configure Log2RAM" ;; - "Enable ZRAM-only swap profile") translate "Enable ZRAM-only swap profile" ;; + "Enable ZRAM swap profile") translate "Enable ZRAM swap profile" ;; *) echo "$1" ;; esac } @@ -3954,7 +3947,7 @@ main_menu() { "System|Increase various system limits|LIMITS" # Entropy (haveged) removed — modern kernels 5.6+ have built-in entropy generation "System|Optimize Memory|MEMORYFIXES" - "Optional|Enable ZRAM-only swap profile|ZRAM_SWAP" + "Optional|Enable ZRAM swap profile|ZRAM_SWAP" "System|Enable fast reboots|KEXEC" "System|Enable restart on kernel panic|KERNELPANIC" "Optional|Apply AMD CPU fixes|AMDFIXES" diff --git a/scripts/post_install/tests/test_zram_profile_lifecycle.sh b/scripts/post_install/tests/test_zram_profile_lifecycle.sh new file mode 100644 index 00000000..fbe8d6ea --- /dev/null +++ b/scripts/post_install/tests/test_zram_profile_lifecycle.sh @@ -0,0 +1,286 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$SCRIPT_ROOT/../.." && pwd)" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +CONFIG="$TEST_ROOT/etc/systemd/zram-generator.conf.d/90-proxmenux.conf" +MARKER="$TEST_ROOT/state/zram-swap.state" +SWAPS="$TEST_ROOT/proc/swaps" +FSTAB="$TEST_ROOT/etc/fstab" +EXPECTED_FSTAB="$TEST_ROOT/fstab.expected" +VERIFY_MODE=0 +START_COUNT=0 +PURGE_COUNT=0 + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +load_language() { :; } +initialize_cache() { :; } +translate() { printf '%s' "$1"; } +msg_info() { :; } +msg_info2() { :; } +msg_ok() { :; } +msg_warn() { :; } +msg_error() { printf '%s\n' "$*" >&2; } +msg_success() { :; } +show_proxmenux_logo() { :; } +clear() { :; } +pmx_install_pkg() { return 1; } +pmx_record_uninstall() { :; } +apt-get() { + if [[ "$*" == "purge -y systemd-zram-generator" ]]; then + PURGE_COUNT=$((PURGE_COUNT + 1)) + fi + return 0 +} +pmx_journal_context() { :; } + +dpkg-query() { + case "$*" in + *'${Status}'*) printf 'install ok installed\n' ;; + *'${Version}'*) printf '1.2.0-1\n' ;; + *) return 1 ;; + esac +} + +dpkg() { + [[ "$1" == "--compare-versions" && "$3" == "ge" && "$2" == "1.2.0-1" && "$4" == "1.2.0" ]] +} + +systemctl() { + case "$1" in + daemon-reload|stop) return 0 ;; + start) + START_COUNT=$((START_COUNT + 1)) + if [[ "${2:-}" == "dev-zram0.swap" ]] && + ! awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' "$SWAPS"; then + printf '/dev/zram0\tpartition\t3355443\t0\t32767\n' >> "$SWAPS" + fi + return 0 + ;; + is-active) return 1 ;; + *) return 0 ;; + esac +} + +swapoff() { + local target="${1:-}" next="$TEST_ROOT/swaps.next" + [[ -n "$target" ]] || fail "swapoff was called without a target" + awk -v target="$target" 'NR == 1 || $1 != target' "$SWAPS" > "$next" + mv "$next" "$SWAPS" +} + +PMX_JOURNAL_ROOT="$TEST_ROOT/journal" +export PMX_JOURNAL_ROOT +source "$REPO_ROOT/scripts/global/pmx_journal.sh" + +mkdir -p "$TEST_ROOT/host/scripts/post_install" +cp "$SCRIPT_ROOT/zram_profile_helpers.sh" "$TEST_ROOT/host/scripts/post_install/" + +# Isolate only host paths; source the real installer function and helper library. +python3 - "$SCRIPT_ROOT/customizable_post_install.sh" "$TEST_ROOT/customizable.sh" "$TEST_ROOT" <<'PY' +from pathlib import Path +import sys + +source_path, output_path, root = map(Path, sys.argv[1:]) +text = source_path.read_text(encoding="utf-8") + +def replace_exact(old, new, expected=1): + global text + count = text.count(old) + if count != expected: + raise SystemExit(f"expected {expected} path substitutions, found {count}: {old}") + text = text.replace(old, new) + +replace_exact( + 'LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"', + f'LOCAL_SCRIPTS="{root}/host/scripts"', +) +replace_exact( + 'BASE_DIR="/usr/local/share/proxmenux"', + f'BASE_DIR="{root}/state"', +) +replace_exact( + 'UTILS_FILE="$BASE_DIR/utils.sh"', + f'UTILS_FILE="{root}/missing-utils.sh"', +) +replace_exact( + 'TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"', + f'TOOLS_JSON="{root}/state/installed_tools.json"', +) +replace_exact( + 'local config=/etc/systemd/zram-generator.conf.d/90-proxmenux.conf', + f'local config={root}/etc/systemd/zram-generator.conf.d/90-proxmenux.conf', +) +replace_exact( + 'if [[ ! -r /proc/swaps || ! -r /etc/fstab ]]; then', + f'if [[ ! -r "{root}/proc/swaps" || ! -r "{root}/etc/fstab" ]]; then', +) +replace_exact( + 'zram_list_foreign_active_zram_swap /proc/swaps "$allowed_zram"', + f'zram_list_foreign_active_zram_swap "{root}/proc/swaps" "$allowed_zram"', +) +replace_exact( + 'zram_list_foreign_fstab_zram_swap /etc/fstab', + f'zram_list_foreign_fstab_zram_swap "{root}/etc/fstab"', +) +replace_exact( + 'zram_list_foreign_systemd_zram_units)', + 'zram_list_foreign_systemd_zram_units "$TEST_ROOT")', +) +replace_exact( + 'zram_foreign_generator_config "$config"', + 'zram_foreign_generator_config "$config" "$TEST_ROOT"', + expected=3, +) +replace_exact( + "awk 'NR > 1 && $1 == \"/dev/zram0\" { found=1 } END { exit !found }' /proc/swaps", + f"awk 'NR > 1 && $1 == \"/dev/zram0\" {{ found=1 }} END {{ exit !found }}' \"{root}/proc/swaps\"", + expected=2, +) +output_path.write_text(text, encoding="utf-8") +PY + +# Isolate the real uninstall function and redirect only the paths it inspects. +python3 - "$SCRIPT_ROOT/uninstall-tools.sh" "$TEST_ROOT/uninstall-zram.sh" "$TEST_ROOT" <<'PY' +from pathlib import Path +import sys + +source_path, output_path, root = map(Path, sys.argv[1:]) +text = source_path.read_text(encoding="utf-8") +start = text.index("uninstall_zram_swap() {") +end = text.index("\n}\n", start) + 3 +function = text[start:end] +replacements = { + "local config=/etc/systemd/zram-generator.conf.d/90-proxmenux.conf": + f"local config={root}/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", + 'awk \'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }\' /proc/swaps': + f'awk \'NR > 1 && $1 == "/dev/zram0" {{ found=1 }} END {{ exit !found }}\' "{root}/proc/swaps"', + 'zram_foreign_generator_config "$config"': + 'zram_foreign_generator_config "$config" "$TEST_ROOT"', + 'zram_list_foreign_active_zram_swap /proc/swaps /dev/zram0': + f'zram_list_foreign_active_zram_swap "{root}/proc/swaps" /dev/zram0', + 'zram_list_foreign_fstab_zram_swap /etc/fstab': + f'zram_list_foreign_fstab_zram_swap "{root}/etc/fstab"', + 'zram_list_foreign_systemd_zram_units)': + 'zram_list_foreign_systemd_zram_units "$TEST_ROOT")', +} +for old, new in replacements.items(): + if function.count(old) != 1: + raise SystemExit(f"expected one uninstall path to rewrite: {old}") + function = function.replace(old, new) +output_path.write_text(function, encoding="utf-8") +PY + +source "$TEST_ROOT/customizable.sh" +source "$TEST_ROOT/uninstall-zram.sh" +BASE_DIR="$TEST_ROOT/state" +TOOLS_JSON="$TEST_ROOT/state/installed_tools.json" +mkdir -p "$BASE_DIR" +register_tool() { printf '%s|%s|%s\n' "$1" "$2" "${3:-}" >> "$TEST_ROOT/registry.log"; } +zram_profile_verify_active() { return "$VERIFY_MODE"; } + +reset_fixture() { + rm -rf "$TEST_ROOT/etc" "$TEST_ROOT/run" "$TEST_ROOT/proc" "$BASE_DIR" "$TEST_ROOT/journal" + mkdir -p "$TEST_ROOT/etc/systemd/system" "$TEST_ROOT/proc" "$BASE_DIR" "$TEST_ROOT/run/systemd/system" + printf 'Filename\tType\tSize\tUsed\tPriority\n/dev/nvme0n1p3\tpartition\t1048576\t8\t-2\n' > "$SWAPS" + printf '/dev/nvme0n1p3 none swap defaults 0 0\n/swapfile none swap sw 0 0\n' > "$FSTAB" + cp "$FSTAB" "$EXPECTED_FSTAB" + printf '[Swap]\nWhat=/dev/mapper/pve-swap\n' > "$TEST_ROOT/etc/systemd/system/pve-swap.swap" + PMX_JOURNAL_ROOT="$TEST_ROOT/journal" + START_COUNT=0 + PURGE_COUNT=0 + VERIFY_MODE=0 + : > "$TEST_ROOT/registry.log" +} + +assert_disk_swap_unchanged() { + cmp -s "$FSTAB" "$EXPECTED_FSTAB" || fail "installer changed fstab" + grep -Fq '/dev/nvme0n1p3' "$SWAPS" || fail "active disk swap was removed" +} + +reset_fixture +configure_zram_swap || fail "disk swap prevented ZRAM installation" +grep -Fq 'swap-priority = 32767' "$CONFIG" || fail "ZRAM priority is not maximal" +grep -Fq 'installed_by_proxmenux=false' "$MARKER" || fail "package ownership was not preserved" +grep -Fq '/dev/zram0' "$SWAPS" || fail "ZRAM did not become active" +assert_disk_swap_unchanged + +configure_zram_swap || fail "owned ZRAM profile could not be re-applied with disk swap" +[[ "$START_COUNT" -eq 1 ]] || fail "matching owned profile was not idempotent" +assert_disk_swap_unchanged +printf 'PASS: disk swap coexists and owned profile is idempotent\n' + +reset_fixture +printf '/dev/zram1 none swap defaults 0 0\n' >> "$FSTAB" +cp "$FSTAB" "$EXPECTED_FSTAB" +if configure_zram_swap; then + fail "foreign fstab ZRAM entry was accepted" +fi +[[ ! -e "$CONFIG" && ! -e "$MARKER" ]] || fail "foreign fstab ZRAM entry left partial profile state" +assert_disk_swap_unchanged +printf 'PASS: foreign fstab ZRAM setup is refused without changing disk swap\n' + +reset_fixture +printf '%s\n' '[Swap]' 'What=/dev/zram1' > "$TEST_ROOT/etc/systemd/system/external-zram.swap" +if configure_zram_swap; then + fail "foreign systemd ZRAM unit was accepted" +fi +[[ ! -e "$CONFIG" && ! -e "$MARKER" ]] || fail "foreign systemd ZRAM unit left partial profile state" +assert_disk_swap_unchanged +printf 'PASS: foreign systemd ZRAM unit is refused without changing disk swap\n' + +reset_fixture +VERIFY_MODE=1 +if configure_zram_swap; then + fail "activation verification failure was reported as success" +fi +[[ ! -e "$CONFIG" && ! -e "$MARKER" ]] || fail "failed activation left partial profile state" +if awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' "$SWAPS"; then + fail "rollback left the failed ZRAM device active" +fi +assert_disk_swap_unchanged +printf 'PASS: verification failure rolls back only the ProxMenux ZRAM state\n' + +reset_fixture +mkdir -p "$(dirname "$CONFIG")" +printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG" +printf 'installed_by_proxmenux=true\n' > "$MARKER" +printf '/dev/zram1 none swap defaults 0 0\n' >> "$FSTAB" +printf '[Swap]\nWhat=/dev/zram2\n' > "$TEST_ROOT/etc/systemd/system/external-zram.swap" +cp "$FSTAB" "$EXPECTED_FSTAB" +uninstall_zram_swap || fail "profile uninstall failed with foreign ZRAM configuration" +[[ "$PURGE_COUNT" -eq 0 ]] || fail "uninstaller purged generator while foreign ZRAM config exists" +[[ ! -e "$CONFIG" && ! -e "$MARKER" ]] || fail "uninstaller left ProxMenux profile files behind" +assert_disk_swap_unchanged +printf 'PASS: uninstall retains generator when foreign ZRAM config exists\n' + +reset_fixture +mkdir -p "$(dirname "$CONFIG")" +printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG" +printf 'installed_by_proxmenux=true\n' > "$MARKER" +printf '%s\n' '[Swap]' 'What=/dev/zram2' > "$TEST_ROOT/etc/systemd/system/external-zram.swap" +uninstall_zram_swap || fail "profile uninstall failed with foreign systemd ZRAM unit" +[[ "$PURGE_COUNT" -eq 0 ]] || fail "uninstaller purged generator while foreign systemd ZRAM unit exists" +assert_disk_swap_unchanged +printf 'PASS: uninstall retains generator for foreign systemd ZRAM unit\n' + +reset_fixture +mkdir -p "$(dirname "$CONFIG")" +printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG" +printf 'installed_by_proxmenux=true\n' > "$MARKER" +printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$SWAPS" +uninstall_zram_swap || fail "owned profile could not be uninstalled" +[[ "$PURGE_COUNT" -eq 1 ]] || fail "owned generator package was not purged" +if awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' "$SWAPS"; then + fail "uninstaller left ProxMenux ZRAM active" +fi +assert_disk_swap_unchanged +printf 'PASS: uninstall removes owned ZRAM and preserves disk swap\n' diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index 63d337c0..63b22fa1 100644 --- a/scripts/post_install/uninstall-tools.sh +++ b/scripts/post_install/uninstall-tools.sh @@ -63,10 +63,13 @@ uninstall_zram_swap() { pmx_journal_context "uninstall_zram_swap" "$FUNC_VERSION" local config=/etc/systemd/zram-generator.conf.d/90-proxmenux.conf local marker="$BASE_DIR/zram-swap.state" - local package_owned=false foreign="" other_active="" old_active=false + local package_owned=false foreign="" other_active="" fstab_zram="" systemd_zram="" inspection_failed=false old_active=false local old_config="" old_marker="" - if ! declare -f zram_foreign_generator_config >/dev/null; then + if ! declare -f zram_foreign_generator_config >/dev/null || + ! declare -f zram_list_foreign_active_zram_swap >/dev/null || + ! declare -f zram_list_foreign_fstab_zram_swap >/dev/null || + ! declare -f zram_list_foreign_systemd_zram_units >/dev/null; then msg_error "$(translate "ZRAM profile helpers are missing")" return 1 fi @@ -109,16 +112,20 @@ uninstall_zram_swap() { rmdir "$(dirname "$config")" 2>/dev/null || true if [[ "$package_owned" == true ]]; then - foreign="$(zram_foreign_generator_config "$config")" - other_active="$(awk 'NR > 1 && $1 ~ /^\/dev\/zram[0-9]+$/ { print $1 }' /proc/swaps)" - if [[ -z "$foreign" && -z "$other_active" ]]; then + if ! foreign="$(zram_foreign_generator_config "$config")" || + ! other_active="$(zram_list_foreign_active_zram_swap /proc/swaps /dev/zram0)" || + ! fstab_zram="$(zram_list_foreign_fstab_zram_swap /etc/fstab)" || + ! systemd_zram="$(zram_list_foreign_systemd_zram_units)"; then + inspection_failed=true + fi + if [[ "$inspection_failed" == false && -z "$foreign" && -z "$other_active" && -z "$fstab_zram" && -z "$systemd_zram" ]]; then if DEBIAN_FRONTEND=noninteractive apt-get purge -y systemd-zram-generator >/dev/null 2>&1; then pmx_record_uninstall "systemd-zram-generator" "$FUNC_VERSION" else msg_warn "$(translate "ZRAM profile removed, but package purge failed")" fi else - msg_warn "$(translate "systemd-zram-generator is still used by another configuration; package retained")" + msg_warn "$(translate "Another ZRAM configuration exists or could not be inspected; systemd-zram-generator was retained")" fi fi register_tool "zram_swap" false @@ -1343,7 +1350,7 @@ show_uninstall_menu() { system_limits) desc="System Limits Increase";; entropy) desc="Entropy Generation (haveged)";; memory_settings) desc="Memory Settings Optimization";; - zram_swap) desc="ZRAM-only swap profile";; + zram_swap) desc="ZRAM swap profile";; kernel_panic) desc="Kernel Panic Configuration";; apt_ipv4) desc="APT IPv4 Force";; kexec) desc="kexec for quick reboots";; diff --git a/scripts/post_install/zram_profile_helpers.sh b/scripts/post_install/zram_profile_helpers.sh index 947998a1..d6e6cfba 100644 --- a/scripts/post_install/zram_profile_helpers.sh +++ b/scripts/post_install/zram_profile_helpers.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash # Pure helpers shared by the ZRAM installer and its fixture tests. +# The kernel's maximum priority puts ZRAM ahead of standard Proxmox swap. +# A disk swap explicitly configured at this same maximum cannot be outranked. +if ! declare -p ZRAM_SWAP_PRIORITY >/dev/null 2>&1; then + readonly ZRAM_SWAP_PRIORITY=32767 +fi + zram_render_generator_config() { local algorithm=zstd [[ "${1:-false}" == true ]] && algorithm='zstd(level=9)' @@ -9,19 +15,32 @@ zram_render_generator_config() { [zram0] zram-size = ram / 10 compression-algorithm = $algorithm -swap-priority = 100 +swap-priority = $ZRAM_SWAP_PRIORITY EOF } -zram_list_non_zram_swap() { - local swaps_file="$1" fstab_file="$2" - awk 'NR > 1 && $1 !~ /^\/dev\/zram[0-9]+$/ { print "active: " $1 }' "$swaps_file" - awk '$0 !~ /^[[:space:]]*#/ && NF >= 3 && $3 == "swap" && $1 !~ /^\/dev\/zram[0-9]+$/ { print "fstab: " $1 }' "$fstab_file" +zram_list_foreign_active_zram_swap() { + local swaps_file="$1" owned_device="${2:-}" + local swap_paths swap_path real_device + [[ -r "$swaps_file" ]] || return 2 + swap_paths="$(awk 'NR > 1 { print $1 }' "$swaps_file")" || return 2 + while IFS= read -r swap_path; do + [[ -z "$swap_path" ]] && continue + real_device="$(readlink -f -- "$swap_path" 2>/dev/null)" || real_device="$swap_path" + if [[ "$real_device" =~ ^/dev/zram[0-9]+$ && "$real_device" != "$owned_device" ]]; then + printf 'active zram: %s\n' "$swap_path" + fi + done <<< "$swap_paths" } -zram_has_non_zram_swap() { - [[ -r "$1" && -r "$2" ]] || return 2 - [[ -n "$(zram_list_non_zram_swap "$1" "$2")" ]] +zram_list_foreign_fstab_zram_swap() { + local fstab_file="$1" + [[ -r "$fstab_file" ]] || return 2 + awk ' + $0 !~ /^[[:space:]]*#/ && NF >= 3 && $3 == "swap" && $1 ~ /^\/dev\/zram[0-9]+$/ { + print "fstab zram: " $1 + } + ' "$fstab_file" } # Print generator configuration outside this feature's dedicated drop-in. @@ -37,7 +56,8 @@ zram_foreign_generator_config() { [[ "$file" == "$owned_file" ]] && continue # Ignore package defaults containing only comments, whitespace and # section headers. Any directive or malformed active line is foreign. - if awk ' + local active + active="$(awk ' { line=$0 sub(/^[[:space:]]+/, "", line) @@ -45,27 +65,70 @@ zram_foreign_generator_config() { if (line == "" || line ~ /^[#;]/ || line ~ /^\[[^]]+\]$/) next found=1 } - END { exit !found } - ' "$file"; then + END { if (found) print "active" } + ' "$file")" || return 2 + if [[ -n "$active" ]]; then printf '%s\n' "$file" fi done done } -zram_list_defined_systemd_swap_units() { - local fixture_root="${1:-}" root file - local roots=(/etc/systemd/system /run/systemd/system) +zram_list_foreign_systemd_zram_units() { + local fixture_root="${1:-}" root file unit what unit_files + local roots=(/etc/systemd/system /run/systemd/system /usr/local/lib/systemd/system /usr/lib/systemd/system /lib/systemd/system) if [[ -n "$fixture_root" ]]; then - roots=("$fixture_root/etc/systemd/system" "$fixture_root/run/systemd/system") + roots=("$fixture_root/etc/systemd/system" "$fixture_root/run/systemd/system" "$fixture_root/usr/local/lib/systemd/system" "$fixture_root/usr/lib/systemd/system" "$fixture_root/lib/systemd/system") fi for root in "${roots[@]}"; do [[ -d "$root" ]] || continue + # Skip activation links; systemd-generator creates links under + # *.wants and they must not look like foreign unit definitions. + unit_files="$(find "$root" \( -type d \( -name '*.wants' -o -name '*.requires' \) -prune \) -o \( \( -name '*.swap' -o -path '*.swap.d/*.conf' \) \( -type f -o -type l \) -print \) 2>/dev/null)" || return 2 while IFS= read -r file; do - # Files in these roots are administrator-defined units. The - # generator's own output lives under /run/systemd/generator and - # is intentionally outside this scan. - printf 'systemd: %s\n' "$file" - done < <(find "$root" -name '*.swap' \( -type f -o -type l \) -print 2>/dev/null) + [[ -z "$file" ]] && continue + unit="$(basename "$file")" + if [[ -L "$file" && "$(readlink -f -- "$file" 2>/dev/null || true)" == /dev/null ]]; then + if [[ "$unit" == "dev-zram0.swap" ]]; then + printf 'systemd zram unit masked: %s\n' "$file" + fi + continue + fi + # A persistent unit named for a zram device could shadow the + # generated unit, even if it currently points at another device. + what="$(awk ' + /^[[:space:]]*\[Swap\][[:space:]]*$/ { in_swap=1; next } + /^[[:space:]]*\[/ { in_swap=0 } + in_swap && /^[[:space:]]*What[[:space:]]*=[[:space:]]*\/dev\/zram[0-9]+([[:space:]]|$)/ { + found=1 + } + END { if (found) print "zram" } + ' "$file")" || return 2 + if [[ "$unit" =~ ^dev-zram[0-9]+\.swap$ || -n "$what" ]]; then + printf 'systemd zram unit: %s\n' "$file" + fi + done <<< "$unit_files" done } + +zram_profile_verify_active() { + local expected_algorithm="$1" expected_priority="${2:-$ZRAM_SWAP_PRIORITY}" + local sys_block_root="${3:-/sys/block}" meminfo_path="${4:-/proc/meminfo}" swaps_path="${5:-/proc/swaps}" + local zram0="$sys_block_root/zram0" expected_bytes actual_bytes active_algorithm + + [[ -r "$zram0/disksize" && -r "$zram0/comp_algorithm" && -r "$meminfo_path" && -r "$swaps_path" ]] || return 1 + expected_bytes="$(awk '/^MemTotal:/ { print int($2 * 1024 / 10) }' "$meminfo_path")" + actual_bytes="$(cat "$zram0/disksize")" + [[ "$expected_bytes" =~ ^[0-9]+$ && "$actual_bytes" =~ ^[0-9]+$ ]] || return 1 + (( actual_bytes >= expected_bytes - 1048576 && actual_bytes <= expected_bytes + 1048576 )) || return 1 + active_algorithm="$(grep -oE '\[[^]]+\]' "$zram0/comp_algorithm" | tr -d '[]')" + [[ "$active_algorithm" == "$expected_algorithm" ]] || return 1 + awk -v priority="$expected_priority" ' + NR > 1 && $1 == "/dev/zram0" { + found=1 + if ($5 + 0 != priority + 0) bad=1 + } + NR > 1 && $1 ~ /^\/dev\/zram[0-9]+$/ && $1 != "/dev/zram0" { bad=1 } + END { exit !(found && !bad) } + ' "$swaps_path" +}