From 51ed84f9b1d6c219df8c80afcf9aebbe6c36480d Mon Sep 17 00:00:00 2001 From: Gabriel Gonzalez Pineda Date: Thu, 15 Oct 2026 00:28:52 +0100 Subject: [PATCH 1/2] feat(post-install): add optional zram profile and metrics --- AppImage/components/system-overview.tsx | 37 +++- AppImage/messages/de/common.json | 6 + AppImage/messages/en/common.json | 6 + AppImage/messages/es/common.json | 6 + AppImage/messages/fr/common.json | 6 + AppImage/messages/it/common.json | 6 + AppImage/messages/pt/common.json | 6 + AppImage/messages/sk/common.json | 6 + AppImage/messages/sv/common.json | 6 + AppImage/scripts/build_appimage.sh | 1 + AppImage/scripts/flask_proxmenux_routes.py | 1 + AppImage/scripts/flask_server.py | 7 + AppImage/scripts/tests/test_zram_metrics.py | 110 +++++++++++ .../tests/test_zram_profile_helpers.py | 166 ++++++++++++++++ AppImage/scripts/zram_metrics.py | 68 +++++++ .../post_install/customizable_post_install.sh | 187 ++++++++++++++++++ scripts/post_install/uninstall-tools.sh | 90 ++++++++- scripts/post_install/zram_profile_helpers.sh | 71 +++++++ 18 files changed, 783 insertions(+), 3 deletions(-) create mode 100644 AppImage/scripts/tests/test_zram_metrics.py create mode 100644 AppImage/scripts/tests/test_zram_profile_helpers.py create mode 100644 AppImage/scripts/zram_metrics.py create mode 100644 scripts/post_install/zram_profile_helpers.sh diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx index 744d62f9..ab443d42 100644 --- a/AppImage/components/system-overview.tsx +++ b/AppImage/components/system-overview.tsx @@ -12,7 +12,7 @@ import { ProcessDetailModal } from "./process-detail-modal" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { fetchApi } from "../lib/api-config" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" -import { formatStorage } from "../lib/utils" +import { formatBytes as formatByteSize, formatStorage } from "../lib/utils" import { getCountFormKey, useI18n, useT } from "../lib/i18n/provider" import { Area, AreaChart, ResponsiveContainer } from "recharts" @@ -21,6 +21,19 @@ interface TempDataPoint { value: number } +interface ZramDevice { + name: string + active: boolean + capacity_bytes: number + swap_used_bytes: number + original_bytes: number + compressed_bytes: number + memory_used_bytes: number + algorithm: string | null + compression_ratio: number | null + saved_bytes: number +} + interface SystemData { cpu_usage: number cpu_user?: number // preview restyle @@ -29,6 +42,7 @@ interface SystemData { memory_total: number memory_used: number memory_cached?: number // preview restyle + zram?: { available: boolean; devices: ZramDevice[] } temperature: number temperature_sparkline?: TempDataPoint[] uptime: string @@ -489,6 +503,27 @@ export function SystemOverview() { + {systemData.zram?.available && systemData.zram.devices.length > 0 && ( +
+ {systemData.zram.devices.map((device) => ( +
+
{t("overview.zram")} {device.name}
+
+ {t("overview.zramCapacity")} + {formatByteSize(device.capacity_bytes)} + {t("overview.zramSwapUsed")} + {formatByteSize(device.swap_used_bytes)} + {t("overview.zramAlgorithm")} + {device.algorithm ?? "—"} + {t("overview.zramCompressionRatio")} + {device.compression_ratio === null ? "—" : `${device.compression_ratio.toFixed(2)}:1`} + {t("overview.zramPhysicalMemory")} + {formatByteSize(device.memory_used_bytes)} +
+
+ ))} +
+ )} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 1206f5e4..f93d7a9b 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -92,6 +92,12 @@ "loadAxis": "Laden", "memory": "Erinnerung", "memoryUsage": "Speichernutzung", + "zram": "ZRAM", + "zramCapacity": "Kapazität", + "zramSwapUsed": "Verwendeter Swap", + "zramAlgorithm": "Algorithmus", + "zramCompressionRatio": "Kompressionsverhältnis", + "zramPhysicalMemory": "Verwendeter physischer Speicher", "user": "Benutzer", "system": "System", "cores": "Kerne", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 9b590728..0719aeb9 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -91,6 +91,12 @@ "loadAxis": "Load", "memory": "Memory", "memoryUsage": "Memory Usage", + "zram": "ZRAM", + "zramCapacity": "Capacity", + "zramSwapUsed": "Swap used", + "zramAlgorithm": "Algorithm", + "zramCompressionRatio": "Compression ratio", + "zramPhysicalMemory": "Physical memory used", "user": "User", "system": "System", "cores": "Cores", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 205176d4..135107ea 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -92,6 +92,12 @@ "loadAxis": "Carga", "memory": "Memoria", "memoryUsage": "Uso de la memoria", + "zram": "ZRAM", + "zramCapacity": "Capacidad", + "zramSwapUsed": "Swap usado", + "zramAlgorithm": "Algoritmo", + "zramCompressionRatio": "Relación de compresión", + "zramPhysicalMemory": "Memoria física usada", "user": "Usuario", "system": "Sistema", "cores": "Núcleos", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index beaf6426..f7120796 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -92,6 +92,12 @@ "loadAxis": "Charger", "memory": "Mémoire", "memoryUsage": "Utilisation de la mémoire", + "zram": "ZRAM", + "zramCapacity": "Capacité", + "zramSwapUsed": "Swap utilisé", + "zramAlgorithm": "Algorithme", + "zramCompressionRatio": "Taux de compression", + "zramPhysicalMemory": "Mémoire physique utilisée", "user": "Utilisateur", "system": "Système", "cores": "Noyaux", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 37bdb0e4..44f2c243 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -92,6 +92,12 @@ "loadAxis": "Carico", "memory": "Memoria", "memoryUsage": "Utilizzo memoria", + "zram": "ZRAM", + "zramCapacity": "Capacità", + "zramSwapUsed": "Swap utilizzato", + "zramAlgorithm": "Algoritmo", + "zramCompressionRatio": "Rapporto di compressione", + "zramPhysicalMemory": "Memoria fisica utilizzata", "user": "Utente", "system": "Sistema", "cores": "Core", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index fbc3414f..7b783e25 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -92,6 +92,12 @@ "loadAxis": "Carregar", "memory": "Memória", "memoryUsage": "Uso de memória", + "zram": "ZRAM", + "zramCapacity": "Capacidade", + "zramSwapUsed": "Swap usado", + "zramAlgorithm": "Algoritmo", + "zramCompressionRatio": "Taxa de compressão", + "zramPhysicalMemory": "Memória física usada", "user": "Usuário", "system": "Sistema", "cores": "Núcleos", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 846469f4..b21c1c45 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -91,6 +91,12 @@ "loadAxis": "Záťaž", "memory": "Pamäť", "memoryUsage": "Využitie pamäte", + "zram": "ZRAM", + "zramCapacity": "Kapacita", + "zramSwapUsed": "Použitý swap", + "zramAlgorithm": "Algoritmus", + "zramCompressionRatio": "Pomer kompresie", + "zramPhysicalMemory": "Použitá fyzická pamäť", "user": "Používateľ", "system": "Systém", "cores": "Jadrá", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 7174ac00..0f9e86c0 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -92,6 +92,12 @@ "loadAxis": "Belastning", "memory": "Minne", "memoryUsage": "Minnesanvändning", + "zram": "ZRAM", + "zramCapacity": "Kapacitet", + "zramSwapUsed": "Använt swaputrymme", + "zramAlgorithm": "Algoritm", + "zramCompressionRatio": "Komprimeringsgrad", + "zramPhysicalMemory": "Använt fysiskt minne", "user": "Användare", "system": "System", "cores": "Kärnor", diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index b9034e2b..cfb1ccb2 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -126,6 +126,7 @@ cd "$SCRIPT_DIR" # Copy Flask server echo "📋 Copying Flask server..." cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/zram_metrics.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found" cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found" cp "$SCRIPT_DIR/jwt_middleware.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ jwt_middleware.py not found" diff --git a/AppImage/scripts/flask_proxmenux_routes.py b/AppImage/scripts/flask_proxmenux_routes.py index 04b317f6..8f1f0d6c 100644 --- a/AppImage/scripts/flask_proxmenux_routes.py +++ b/AppImage/scripts/flask_proxmenux_routes.py @@ -26,6 +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'}, '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/flask_server.py b/AppImage/scripts/flask_server.py index 14d20310..78f79f8c 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -78,6 +78,7 @@ from smartctl_resolver import ( # noqa: E402 smartctl_type_args, ) from temperature_sensor_resolver import get_storage_temperatures # noqa: E402 +from zram_metrics import read_zram_metrics # noqa: E402 from flask_script_runner import script_runner import threading from proxmox_storage_monitor import classify_storage_state, proxmox_storage_monitor @@ -9827,6 +9828,11 @@ def api_system(): # Get temperature sparkline (last 1h) for overview mini chart temp_sparkline = get_temperature_sparkline(60) + try: + zram = read_zram_metrics() + except Exception: + zram = {'available': False, 'devices': []} + return jsonify({ 'cpu_usage': round(cpu_usage, 1), 'cpu_user': cpu_user_pct, @@ -9835,6 +9841,7 @@ def api_system(): 'memory_total': round(memory_total_gb, 1), 'memory_used': round(memory_used_gb, 1), 'memory_cached': memory_cached_gb, + 'zram': zram, 'temperature': temp, 'temperature_sparkline': temp_sparkline, 'uptime': uptime, diff --git a/AppImage/scripts/tests/test_zram_metrics.py b/AppImage/scripts/tests/test_zram_metrics.py new file mode 100644 index 00000000..617cdd80 --- /dev/null +++ b/AppImage/scripts/tests/test_zram_metrics.py @@ -0,0 +1,110 @@ +"""Tests for reading zram metrics from injectable proc and sys paths.""" + +import importlib +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +class ZramMetricsTests(unittest.TestCase): + def test_reader_module_is_available(self): + module_spec = importlib.util.find_spec("zram_metrics") + self.assertIsNotNone(module_spec) + module = importlib.import_module("zram_metrics") + self.assertTrue(callable(getattr(module, "read_zram_metrics", None))) + + 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" + self.block_root.mkdir(parents=True) + self.swaps_path = self.root / "proc" / "swaps" + self.swaps_path.parent.mkdir(parents=True) + self.swaps_path.write_text( + "Filename\tType\tSize\tUsed\tPriority\n", encoding="utf-8" + ) + + def add_device(self, name="zram0", mm_stat="2097152 1048576 1179648 0"): + device = self.block_root / name + device.mkdir() + (device / "disksize").write_text("1073741824\n", encoding="utf-8") + (device / "mm_stat").write_text(mm_stat + "\n", encoding="utf-8") + (device / "comp_algorithm").write_text( + "lzo lzo-rle [zstd] lz4\n", encoding="utf-8" + ) + return device + + def read_metrics(self): + from zram_metrics import read_zram_metrics + + return read_zram_metrics(self.block_root, self.swaps_path) + + def test_no_zram_devices_reports_unavailable(self): + (self.block_root / "sda").mkdir() + + self.assertEqual(self.read_metrics(), {"available": False, "devices": []}) + + def test_reads_capacity_swap_usage_and_compression_counters(self): + self.add_device() + self.swaps_path.write_text( + "Filename\tType\tSize\tUsed\tPriority\n" + "/dev/zram0\tpartition\t1048576\t1024\t100\n", + encoding="utf-8", + ) + + self.assertEqual( + self.read_metrics(), + { + "available": True, + "devices": [ + { + "name": "zram0", + "active": True, + "capacity_bytes": 1073741824, + "swap_used_bytes": 1048576, + "original_bytes": 2097152, + "compressed_bytes": 1048576, + "memory_used_bytes": 1179648, + "algorithm": "zstd", + "compression_ratio": 2.0, + "saved_bytes": 917504, + } + ], + }, + ) + + def test_zero_compressed_bytes_has_null_ratio(self): + self.add_device(mm_stat="2097152 0 0 0") + + result = self.read_metrics() + + self.assertTrue(result["available"]) + self.assertEqual(len(result["devices"]), 1) + self.assertFalse(result["devices"][0]["active"]) + self.assertEqual(result["devices"][0]["swap_used_bytes"], 0) + self.assertIsNone(result["devices"][0]["compression_ratio"]) + + def test_malformed_or_short_mm_stat_omits_device(self): + for mm_stat in ("not-a-number 1048576 1179648", "2097152 1048576"): + with self.subTest(mm_stat=mm_stat): + device = self.block_root / "zram0" + if device.exists(): + (device / "mm_stat").write_text(mm_stat + "\n", encoding="utf-8") + else: + self.add_device(mm_stat=mm_stat) + + result = self.read_metrics() + + self.assertFalse(result["available"]) + self.assertEqual(result["devices"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/AppImage/scripts/tests/test_zram_profile_helpers.py b/AppImage/scripts/tests/test_zram_profile_helpers.py new file mode 100644 index 00000000..47b14842 --- /dev/null +++ b/AppImage/scripts/tests/test_zram_profile_helpers.py @@ -0,0 +1,166 @@ +"""Contract tests for the opt-in ZRAM profile's pure Bash helpers.""" + +import subprocess +import tempfile +import unittest +from pathlib import Path + + +HELPERS = Path(__file__).resolve().parents[3] / "scripts/post_install/zram_profile_helpers.sh" +SWAPS_HEADER = "Filename\tType\tSize\tUsed\tPriority\n" + + +class HelperAvailabilityTests(unittest.TestCase): + def test_helper_file_is_available(self): + self.assertTrue(HELPERS.is_file(), f"ZRAM helper is missing: {HELPERS}") + + +def call_helper(function, *arguments): + return subprocess.run( + [ + "bash", + "-c", + 'source "$1" || exit; shift; "$@"', + "bash", + str(HELPERS), + function, + *(str(argument) for argument in arguments), + ], + capture_output=True, + text=True, + check=False, + ) + + +class ZramGeneratorConfigTests(unittest.TestCase): + def assert_config(self, supports_algorithm_params, expected_algorithm): + result = call_helper("zram_render_generator_config", supports_algorithm_params) + self.assertEqual(result.returncode, 0, result.stderr) + settings = { + key.strip(): value.strip() + for line in result.stdout.splitlines() + if "=" in line + for key, value in [line.split("=", 1)] + } + 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("compression-algorithm", "").strip(), expected_algorithm) + self.assertNotIn("writeback", result.stdout.lower()) + + def test_generator_1_1_2_2_uses_plain_zstd(self): + self.assert_config("false", "zstd") + + def test_generator_1_2_1_2_uses_zstd_level_nine(self): + self.assert_config("true", "zstd(level=9)") + + +class NonZramSwapDetectionTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + directory = Path(self.temporary.name) + self.swaps_file = directory / "swaps" + self.fstab_file = directory / "fstab" + + def detect(self, swaps, fstab): + 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) + + def test_active_disk_swap_is_detected(self): + result = self.detect( + SWAPS_HEADER + "/dev/nvme0n1p3\tpartition\t1048576\t0\t-2\n", + "", + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_persistent_swapfile_is_detected(self): + result = self.detect(SWAPS_HEADER, "/swapfile none swap sw 0 0\n") + self.assertEqual(result.returncode, 0, result.stderr) + + 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): + result = self.detect( + SWAPS_HEADER + "/dev/zram0\tpartition\t1048576\t0\t100\n", + "/dev/zram0 none swap defaults 0 0\n", + ) + self.assertEqual(result.returncode, 1, result.stderr) + + 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) + + +class ForeignZramConfigTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.etc = self.root / "etc/systemd" + (self.etc / "zram-generator.conf.d").mkdir(parents=True) + self.owned = self.etc / "zram-generator.conf.d/90-proxmenux.conf" + + def foreign_configs(self): + return call_helper( + "zram_foreign_generator_config", self.owned, self.root + ).stdout.splitlines() + + def test_empty_packaged_default_does_not_block_profile(self): + (self.etc / "zram-generator.conf").write_text( + "# package default\n[zram0]\n; no active directives\n", + encoding="utf-8", + ) + + self.assertEqual(self.foreign_configs(), []) + + def test_active_foreign_directive_is_reported(self): + foreign = self.etc / "zram-generator.conf" + foreign.write_text("[zram1]\nzram-size = ram / 4\n", encoding="utf-8") + + self.assertEqual(self.foreign_configs(), [str(foreign)]) + + def test_owned_drop_in_is_not_reported(self): + self.owned.write_text( + "# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n", + encoding="utf-8", + ) + + self.assertEqual(self.foreign_configs(), []) + + +class SystemdSwapDetectionTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + 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") + + result = call_helper("zram_list_defined_systemd_swap_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'}", + }, + ) + + def test_no_non_zram_systemd_swap_has_empty_output(self): + result = call_helper("zram_list_defined_systemd_swap_units", self.root) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/AppImage/scripts/zram_metrics.py b/AppImage/scripts/zram_metrics.py new file mode 100644 index 00000000..8620049b --- /dev/null +++ b/AppImage/scripts/zram_metrics.py @@ -0,0 +1,68 @@ +"""Read zram swap metrics from procfs and sysfs.""" + +import re +from pathlib import Path + + +def _swap_usage(swaps_path): + usage = {} + try: + lines = Path(swaps_path).read_text(encoding="utf-8").splitlines() + except OSError: + return usage + + for line in lines[1:]: + fields = line.split() + if len(fields) < 4: + continue + name = Path(fields[0]).name + if not re.fullmatch(r"zram\d+", name): + continue + try: + usage[name] = int(fields[3]) * 1024 + except ValueError: + continue + return usage + + +def read_zram_metrics(sys_block_root="/sys/block", swaps_path="/proc/swaps"): + """Return zram device counters with byte units and active swap usage.""" + root = Path(sys_block_root) + try: + device_paths = sorted( + path for path in root.iterdir() + if path.is_dir() and re.fullmatch(r"zram\d+", path.name) + ) + except OSError: + device_paths = [] + + swap_usage = _swap_usage(swaps_path) + devices = [] + for path in device_paths: + try: + capacity = int((path / "disksize").read_text(encoding="utf-8").strip()) + counters = (path / "mm_stat").read_text(encoding="utf-8").split() + if len(counters) < 3: + continue + original, compressed, memory_used = map(int, counters[:3]) + if min(capacity, original, compressed, memory_used) < 0: + continue + algorithms = (path / "comp_algorithm").read_text(encoding="utf-8") + except (OSError, ValueError): + continue + + selected = re.search(r"\[([^\]]+)\]", algorithms) + devices.append({ + "name": path.name, + "active": path.name in swap_usage, + "capacity_bytes": capacity, + "swap_used_bytes": swap_usage.get(path.name, 0), + "original_bytes": original, + "compressed_bytes": compressed, + "memory_used_bytes": memory_used, + "algorithm": selected.group(1) if selected else None, + "compression_ratio": original / compressed if compressed else None, + "saved_bytes": original - memory_used, + }) + + return {"available": bool(devices), "devices": devices} diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index 7bf56e55..86a8db17 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -63,6 +63,9 @@ fi if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then source "$LOCAL_SCRIPTS/global/pmx_journal.sh" fi +if [[ -f "$LOCAL_SCRIPTS/post_install/zram_profile_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS/post_install/zram_profile_helpers.sh" +fi # ========================================================== @@ -2448,6 +2451,187 @@ EOF } +configure_zram_swap() { + local FUNC_VERSION="1.0" + # description: Enable an opt-in ZRAM-only 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 + + 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_foreign_generator_config >/dev/null || + ! declare -f zram_profile_verify_active >/dev/null; then + msg_error "$(translate "ZRAM profile helpers are missing")" + return 1 + fi + if [[ ! -r /proc/swaps || ! -r /etc/fstab ]]; then + 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 + if [[ -e "$marker" ]]; then + if [[ ! -f "$config" || -L "$config" || -L "$marker" ]] || + ! grep -qx '# ProxMenux ZRAM profile' "$config" || + ! grep -Eq '^installed_by_proxmenux=(true|false)$' "$marker"; then + msg_error "$(translate "ZRAM ownership state is incomplete; inspect it before retrying")" + return 1 + fi + owned=true + 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")" + return 1 + fi + + if awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' /proc/swaps; then + previous_active=true + fi + if ! dpkg-query -W -f='${Status}' systemd-zram-generator 2>/dev/null | grep -q 'install ok installed'; then + if ! pmx_install_pkg systemd-zram-generator; then + if [[ "$owned" == false ]] && + dpkg-query -W -f='${Status}' systemd-zram-generator 2>/dev/null | grep -q 'install ok installed' && + [[ -z "$(zram_foreign_generator_config "$config")" ]]; 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" + fi + fi + msg_error "$(translate "Could not install systemd-zram-generator")" + return 1 + fi + package_added=true + package_owned=true + fi + version="$(dpkg-query -W -f='${Version}' systemd-zram-generator 2>/dev/null)" + if [[ -z "$version" ]]; then + failure="Cannot determine installed systemd-zram-generator version" + else + if dpkg --compare-versions "$version" ge '1.2.0'; then + supports_params=true + fi + desired="$(zram_render_generator_config "$supports_params")" + algorithm=zstd + fi + if [[ -z "$failure" && "$owned" == true && "$previous_config" == "$desired" && "$previous_active" == true ]]; then + if zram_profile_verify_active "$algorithm"; then + register_tool "zram_swap" true "$FUNC_VERSION" + msg_success "$(translate "ZRAM profile is already active")" + return 0 + fi + failure="Existing ZRAM profile is active but does not match its configuration" + fi + if [[ -z "$failure" && "$previous_active" == true ]]; then + if ! swapoff /dev/zram0; then + msg_error "$(translate "Could not release ZRAM pages; existing profile remains active")" + return 1 + fi + state_mutated=true + if ! systemctl stop dev-zram0.swap >/dev/null 2>&1; then + failure="Could not stop existing dev-zram0.swap" + fi + fi + if [[ -z "$failure" ]]; then + state_mutated=true + if ! install -d -m 755 "$(dirname "$config")"; then + failure="Could not create ZRAM generator directory" + elif ! printf '%s\n' "$desired" | pmx_write_file "$config"; then + failure="Could not write ZRAM generator configuration" + fi + fi + if [[ -z "$failure" ]]; then + old_umask="$(umask)" + umask 077 + if ! printf 'installed_by_proxmenux=%s\n' "$package_owned" | pmx_write_file "$marker"; then + failure="Could not write ZRAM package ownership state" + fi + umask "$old_umask" + [[ -f "$marker" ]] && chmod 600 "$marker" + fi + if [[ -z "$failure" ]]; then + if ! systemctl daemon-reload || ! systemctl start dev-zram0.swap; then + failure="Could not activate dev-zram0.swap" + elif ! zram_profile_verify_active "$algorithm"; then + failure="Active ZRAM algorithm, capacity or swap sources did not match the profile" + fi + fi + if [[ -z "$failure" ]]; then + register_tool "zram_swap" true "$FUNC_VERSION" + msg_success "$(translate "ZRAM-only swap profile enabled")" + return 0 + fi + + msg_error "$(translate "ZRAM profile activation failed; details:") $failure" + if [[ "$state_mutated" == true ]] && awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' /proc/swaps; then + if ! swapoff /dev/zram0; then + # Keep the drop-in and marker so a later uninstall can retry safely. + register_tool "zram_swap" true "$FUNC_VERSION" + msg_error "$(translate "ZRAM still holds pages; configuration was preserved")" + return 1 + fi + fi + if [[ "$state_mutated" == true ]]; then + systemctl stop dev-zram0.swap >/dev/null 2>&1 || true + if [[ "$owned" == true ]]; then + printf '%s\n' "$previous_config" | pmx_write_file "$config" + printf '%s\n' "$previous_marker" | pmx_write_file "$marker" + chmod 600 "$marker" + else + pmx_remove_file "$config" + pmx_remove_file "$marker" + fi + systemctl daemon-reload >/dev/null 2>&1 || true + [[ "$previous_active" == true ]] && systemctl start dev-zram0.swap >/dev/null 2>&1 + fi + if [[ "$package_added" == true && "$owned" == false && ! -e "$config" && ! -e "$marker" && -z "$(zram_foreign_generator_config "$config")" ]]; 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" + fi + fi + 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 +} + @@ -3674,6 +3858,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" ;; *) echo "$1" ;; esac } @@ -3769,6 +3954,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" "System|Enable fast reboots|KEXEC" "System|Enable restart on kernel panic|KERNELPANIC" "Optional|Apply AMD CPU fixes|AMDFIXES" @@ -3912,6 +4098,7 @@ done LIMITS) increase_system_limits ;; # ENTROPY removed — modern kernels 5.6+ have built-in entropy MEMORYFIXES) optimize_memory_settings ;; + ZRAM_SWAP) configure_zram_swap ;; KEXEC) enable_kexec ;; KERNELPANIC) configure_kernel_panic ;; AMDFIXES) apply_amd_fixes ;; diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index 10eeda09..63d337c0 100644 --- a/scripts/post_install/uninstall-tools.sh +++ b/scripts/post_install/uninstall-tools.sh @@ -37,6 +37,9 @@ fi if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then source "$LOCAL_SCRIPTS/global/pmx_journal.sh" fi +if [[ -f "$LOCAL_SCRIPTS/post_install/zram_profile_helpers.sh" ]]; then + source "$LOCAL_SCRIPTS/post_install/zram_profile_helpers.sh" +fi load_language initialize_cache @@ -55,6 +58,76 @@ register_tool() { ################################################################ +uninstall_zram_swap() { + local FUNC_VERSION="1.0" + 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 old_config="" old_marker="" + + if ! declare -f zram_foreign_generator_config >/dev/null; then + msg_error "$(translate "ZRAM profile helpers are missing")" + return 1 + fi + if [[ ! -f "$config" || ! -f "$marker" || -L "$config" || -L "$marker" ]] || + ! grep -qx '# ProxMenux ZRAM profile' "$config" || + ! grep -Eq '^installed_by_proxmenux=(true|false)$' "$marker"; then + msg_error "$(translate "ZRAM ownership state is incomplete; no files were removed")" + return 1 + fi + old_config="$(cat "$config")" + old_marker="$(cat "$marker")" + [[ "$old_marker" == installed_by_proxmenux=true ]] && package_owned=true + + if awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' /proc/swaps; then + old_active=true + if ! swapoff /dev/zram0; then + msg_error "$(translate "ZRAM still holds pages; profile remains active")" + return 1 + fi + fi + if systemctl is-active --quiet dev-zram0.swap && ! systemctl stop dev-zram0.swap; then + if [[ "$old_active" == true ]]; then + systemctl start dev-zram0.swap >/dev/null 2>&1 || true + fi + msg_error "$(translate "Could not stop ZRAM swap; profile configuration remains")" + return 1 + fi + if ! pmx_remove_file "$config" || ! pmx_remove_file "$marker" || ! systemctl daemon-reload; then + printf '%s\n' "$old_config" | pmx_write_file "$config" + printf '%s\n' "$old_marker" | pmx_write_file "$marker" + chmod 600 "$marker" + systemctl daemon-reload >/dev/null 2>&1 || true + if [[ "$old_active" == true ]] && ! systemctl start dev-zram0.swap >/dev/null 2>&1; then + msg_error "$(translate "Could not remove ZRAM profile; configuration was restored but swap could not be restarted")" + else + msg_error "$(translate "Could not remove ZRAM profile; configuration was restored")" + fi + return 1 + fi + 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 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")" + fi + fi + register_tool "zram_swap" false + msg_success "$(translate "ZRAM swap profile removed")" + return 0 +} + +################################################################ + uninstall_fastfetch() { local FUNC_VERSION="1.0" pmx_journal_context "uninstall_fastfetch" "$FUNC_VERSION" @@ -1270,6 +1343,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";; kernel_panic) desc="Kernel Panic Configuration";; apt_ipv4) desc="APT IPv4 Force";; kexec) desc="kexec for quick reboots";; @@ -1315,18 +1389,30 @@ show_uninstall_menu() { fi # Execute uninstallations + local failed=0 for tool in $selected_tools; do tool=$(echo "$tool" | tr -d '"') if declare -f "uninstall_$tool" > /dev/null 2>&1; then clear show_proxmenux_logo - "uninstall_$tool" + # Preserve legacy return handling for existing uninstallers. + # Only ZRAM uses a failure status to signal that its active swap + # profile could not be safely removed. + if [[ "$tool" == "zram_swap" ]]; then + uninstall_zram_swap || failed=1 + else + "uninstall_$tool" || true + fi else msg_warn "$(translate "No uninstaller found for:") $tool" fi done - msg_success "$(translate "Selected optimizations have been uninstalled.")" + if [[ "$failed" -eq 0 ]]; then + msg_success "$(translate "Selected optimizations have been uninstalled.")" + else + msg_error "$(translate "Some optimizations could not be uninstalled; their configuration was preserved.")" + fi msg_warn "$(translate "A system reboot is recommended to ensure all changes take effect.")" echo -e msg_success "$(translate "Press Enter to continue...")" diff --git a/scripts/post_install/zram_profile_helpers.sh b/scripts/post_install/zram_profile_helpers.sh new file mode 100644 index 00000000..947998a1 --- /dev/null +++ b/scripts/post_install/zram_profile_helpers.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Pure helpers shared by the ZRAM installer and its fixture tests. + +zram_render_generator_config() { + local algorithm=zstd + [[ "${1:-false}" == true ]] && algorithm='zstd(level=9)' + cat < 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_has_non_zram_swap() { + [[ -r "$1" && -r "$2" ]] || return 2 + [[ -n "$(zram_list_non_zram_swap "$1" "$2")" ]] +} + +# Print generator configuration outside this feature's dedicated drop-in. +zram_foreign_generator_config() { + local owned_file="$1" fixture_root="${2:-}" root file + local roots=(/etc/systemd /run/systemd /usr/local/lib/systemd /usr/lib/systemd /lib/systemd) + if [[ -n "$fixture_root" ]]; then + roots=("$fixture_root/etc/systemd" "$fixture_root/run/systemd" "$fixture_root/usr/local/lib/systemd" "$fixture_root/usr/lib/systemd" "$fixture_root/lib/systemd") + fi + for root in "${roots[@]}"; do + for file in "$root/zram-generator.conf" "$root"/zram-generator.conf.d/*.conf; do + [[ -e "$file" || -L "$file" ]] || continue + [[ "$file" == "$owned_file" ]] && continue + # Ignore package defaults containing only comments, whitespace and + # section headers. Any directive or malformed active line is foreign. + if awk ' + { + line=$0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line == "" || line ~ /^[#;]/ || line ~ /^\[[^]]+\]$/) next + found=1 + } + END { exit !found } + ' "$file"; 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) + if [[ -n "$fixture_root" ]]; then + roots=("$fixture_root/etc/systemd/system" "$fixture_root/run/systemd/system") + fi + for root in "${roots[@]}"; do + [[ -d "$root" ]] || continue + 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) + done +} From 3ad7407e0782f6cbf0e8319012cf729561fad191 Mon Sep 17 00:00:00 2001 From: Gabriel Gonzalez Pineda Date: Fri, 16 Oct 2026 15:12:14 +0100 Subject: [PATCH 2/2] 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" +}