New PUID startup sequence

This commit is contained in:
Adam Outler
2026-01-03 01:13:18 +00:00
parent a01ccaec94
commit c15f621ad4
29 changed files with 620 additions and 884 deletions
+477
View File
@@ -0,0 +1,477 @@
#!/usr/bin/env python3
"""
Mount Diagnostic Tool
Analyzes container mount points for permission issues, persistence risks, and performance problems.
TODO: Future Enhancements (Roadmap Step 3 & 4)
1. Text-based Output: Replace emoji status indicators (✅, ❌) with plain text (e.g., [OK], [FAIL])
to ensure compatibility with all terminal types and logging systems.
2. OverlayFS/Copy-up Support: Improve detection logic for filesystems like Synology's OverlayFS
where files may appear writable but fail on specific operations (locking, mmap).
3. Root-to-User Context: Ensure this tool remains accurate when the container starts as root
to fix permissions and then drops privileges to the 'netalertx' user. The check should
reflect the *effective* permissions of the application user.
"""
import os
import sys
from dataclasses import dataclass
# if NETALERTX_DEBUG is 1 then exit
if os.environ.get("NETALERTX_DEBUG") == "1":
sys.exit(0)
@dataclass
class MountCheckResult:
"""Object to track mount status and potential issues."""
var_name: str
path: str = ""
is_writeable: bool = False
is_readable: bool = False
is_mounted: bool = False
is_mount_point: bool = False
is_ramdisk: bool = False
underlying_fs_is_ramdisk: bool = False # Track this separately
fstype: str = "N/A"
error: bool = False
write_error: bool = False
read_error: bool = False
performance_issue: bool = False
dataloss_risk: bool = False
category: str = ""
role: str = ""
group: str = ""
@dataclass(frozen=True)
class PathSpec:
"""Describes how a filesystem path should behave."""
var_name: str
category: str # e.g. persist, ramdisk
role: str # primary, sub, secondary
group: str # logical grouping for primary/sub relationships
PATH_SPECS = (
PathSpec("NETALERTX_DATA", "persist", "primary", "data"),
PathSpec("NETALERTX_DB", "persist", "sub", "data"),
PathSpec("NETALERTX_CONFIG", "persist", "sub", "data"),
PathSpec("SYSTEM_SERVICES_RUN_TMP", "ramdisk", "primary", "tmp"),
PathSpec("NETALERTX_API", "ramdisk", "sub", "tmp"),
PathSpec("NETALERTX_LOG", "ramdisk", "sub", "tmp"),
PathSpec("SYSTEM_SERVICES_RUN", "ramdisk", "sub", "tmp"),
PathSpec("SYSTEM_SERVICES_ACTIVE_CONFIG", "ramdisk", "secondary", "tmp"),
)
def get_mount_info():
"""Parses /proc/mounts to get a dict of {mount_point: fstype}."""
mounts = {}
try:
with open("/proc/mounts", "r") as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 3:
mount_point = parts[1].replace("\\040", " ")
fstype = parts[2]
mounts[mount_point] = fstype
except FileNotFoundError:
print("Error: /proc/mounts not found. Not a Linux system?", file=sys.stderr)
return None
return mounts
def _resolve_writeable_state(target_path: str) -> bool:
"""Determine if a path is writeable, ascending to the first existing parent."""
current = target_path
seen: set[str] = set()
while True:
if current in seen:
break
seen.add(current)
if os.path.exists(current):
if not os.access(current, os.W_OK):
return False
# OverlayFS/Copy-up check: Try to actually write a file to verify
if os.path.isdir(current):
test_file = os.path.join(current, f".netalertx_write_test_{os.getpid()}")
try:
with open(test_file, "w") as f:
f.write("test")
os.remove(test_file)
return True
except OSError:
return False
return True
parent_dir = os.path.dirname(current)
if not parent_dir or parent_dir == current:
break
current = parent_dir
return False
def _resolve_readable_state(target_path: str) -> bool:
"""Determine if a path is readable, ascending to the first existing parent."""
current = target_path
seen: set[str] = set()
while True:
if current in seen:
break
seen.add(current)
if os.path.exists(current):
return os.access(current, os.R_OK)
parent_dir = os.path.dirname(current)
if not parent_dir or parent_dir == current:
break
current = parent_dir
return False
def analyze_path(
spec: PathSpec,
mounted_filesystems,
non_persistent_fstypes,
):
"""
Analyzes a single path, checking for errors, performance, and dataloss.
"""
result = MountCheckResult(
var_name=spec.var_name,
category=spec.category,
role=spec.role,
group=spec.group,
)
target_path = os.environ.get(spec.var_name)
if target_path is None:
result.path = f"({spec.var_name} unset)"
result.error = True
return result
result.path = target_path
# --- 1. Check Read/Write Permissions ---
result.is_writeable = _resolve_writeable_state(target_path)
result.is_readable = _resolve_readable_state(target_path)
if not result.is_writeable:
result.error = True
if spec.role != "secondary":
result.write_error = True
if not result.is_readable:
result.error = True
if spec.role != "secondary":
result.read_error = True
# --- 2. Check Filesystem Type (Parent and Self) ---
parent_mount_fstype = ""
longest_mount = ""
for mount_point, fstype in mounted_filesystems.items():
normalized = mount_point.rstrip("/") if mount_point != "/" else "/"
if target_path == normalized or target_path.startswith(f"{normalized}/"):
if len(normalized) > len(longest_mount):
longest_mount = normalized
parent_mount_fstype = fstype
result.underlying_fs_is_ramdisk = parent_mount_fstype in non_persistent_fstypes
if parent_mount_fstype:
result.fstype = parent_mount_fstype
# --- 3. Check if path IS a mount point ---
if target_path in mounted_filesystems:
result.is_mounted = True
result.is_mount_point = True
result.fstype = mounted_filesystems[target_path]
result.is_ramdisk = result.fstype in non_persistent_fstypes
else:
result.is_mounted = False
result.is_ramdisk = False
if longest_mount and longest_mount != "/":
if target_path == longest_mount or target_path.startswith(
f"{longest_mount}/"
):
result.is_mounted = True
result.fstype = parent_mount_fstype
result.is_ramdisk = parent_mount_fstype in non_persistent_fstypes
# --- 4. Apply Risk Logic ---
# Keep risk flags about persistence/performance properties of the mount itself.
# Read/write permission problems are surfaced via the R/W columns and error flags.
if spec.category == "persist":
if result.underlying_fs_is_ramdisk or result.is_ramdisk:
result.dataloss_risk = True
if not result.is_mounted:
result.dataloss_risk = True
elif spec.category == "ramdisk":
if not result.is_mounted or not result.is_ramdisk:
result.performance_issue = True
return result
def print_warning_message(results: list[MountCheckResult]):
"""Prints a formatted warning to stderr."""
YELLOW = "\033[1;33m"
RESET = "\033[0m"
print(f"{YELLOW}══════════════════════════════════════════════════════════════════════════════", file=sys.stderr)
print("⚠️ ATTENTION: Configuration issues detected (marked with ❌).\n", file=sys.stderr)
for r in results:
issues = []
if not r.is_writeable:
issues.append("error writing")
if not r.is_readable:
issues.append("error reading")
if not r.is_mounted and (r.category == "persist" or r.category == "ramdisk"):
issues.append("not mounted")
if r.dataloss_risk:
issues.append("risk of dataloss")
if r.performance_issue:
issues.append("performance issue")
if issues:
print(f" * {r.path} {', '.join(issues)}", file=sys.stderr)
message = (
"\n We recommend starting with the default docker-compose.yml as the\n"
" configuration can be quite complex.\n\n"
" Review the documentation for a correct setup:\n"
" https://github.com/jokob-sk/NetAlertX/blob/main/docs/DOCKER_COMPOSE.md\n"
" https://github.com/jokob-sk/NetAlertX/blob/main/docs/docker-troubleshooting/mount-configuration-issues.md\n"
"══════════════════════════════════════════════════════════════════════════════\n"
)
print(f"{message}{RESET}", file=sys.stderr)
def _get_active_specs() -> list[PathSpec]:
"""Return the path specifications that should be evaluated for this run."""
return list(PATH_SPECS)
def _sub_result_is_healthy(result: MountCheckResult) -> bool:
"""Determine if a sub-path result satisfies its expected constraints."""
if result.category == "persist":
if not result.is_mounted:
return False
if result.dataloss_risk or result.write_error or result.read_error or result.error:
return False
return True
if result.category == "ramdisk":
if not result.is_mounted or not result.is_ramdisk:
return False
if result.performance_issue or result.write_error or result.read_error or result.error:
return False
return True
return False
def _apply_primary_rules(specs: list[PathSpec], results_map: dict[str, MountCheckResult]) -> list[MountCheckResult]:
"""Suppress or flag primary rows based on the state of their sub-paths."""
final_results: list[MountCheckResult] = []
specs_by_group: dict[str, list[PathSpec]] = {}
for spec in specs:
specs_by_group.setdefault(spec.group, []).append(spec)
for spec in specs:
result = results_map.get(spec.var_name)
if result is None:
continue
if spec.role == "primary":
group_specs = specs_by_group.get(spec.group, [])
sub_results_all = [
results_map[s.var_name]
for s in group_specs
if s.var_name in results_map and s.var_name != spec.var_name
]
core_sub_results = [
results_map[s.var_name]
for s in group_specs
if s.var_name in results_map and s.role == "sub"
]
sub_mount_points = [sub for sub in sub_results_all if sub.is_mount_point]
core_mount_points = [sub for sub in core_sub_results if sub.is_mount_point]
all_core_subs_healthy = bool(core_sub_results) and all(
_sub_result_is_healthy(sub) for sub in core_sub_results
)
all_core_subs_are_mounts = bool(core_sub_results) and len(core_mount_points) == len(core_sub_results)
suppress_primary = False
if all_core_subs_healthy and all_core_subs_are_mounts:
suppress_primary = True
if suppress_primary:
# All sub-paths are healthy and mounted; suppress the aggregate row.
continue
if sub_mount_points and result.is_mount_point:
result.error = True
if result.category == "persist":
result.dataloss_risk = True
elif result.category == "ramdisk":
result.performance_issue = True
final_results.append(result)
return final_results
def main():
NON_PERSISTENT_FSTYPES = {"tmpfs", "ramfs"}
active_specs = _get_active_specs()
mounted_filesystems = get_mount_info()
if mounted_filesystems is None:
sys.exit(1)
results_map: dict[str, MountCheckResult] = {}
for spec in active_specs:
results_map[spec.var_name] = analyze_path(
spec,
mounted_filesystems,
NON_PERSISTENT_FSTYPES,
)
results = _apply_primary_rules(active_specs, results_map)
has_issues = any(
r.dataloss_risk or r.error or r.write_error or r.read_error or r.performance_issue
for r in results
)
has_rw_errors = any(
(r.write_error or r.read_error) and r.category == "persist"
for r in results
)
has_primary_dataloss = any(
r.category == "persist" and r.role == "primary" and r.dataloss_risk and r.is_mount_point
for r in results
)
# --- Print Table ---
headers = ["Path", "R", "W", "Mount", "RAMDisk", "Performance", "DataLoss"]
CHECK_SYMBOL = "✅"
CROSS_SYMBOL = "❌"
BLANK_SYMBOL = "➖"
def bool_to_check(is_good):
return CHECK_SYMBOL if is_good else CROSS_SYMBOL
col_widths = [len(h) for h in headers]
for r in results:
col_widths[0] = max(col_widths[0], len(str(r.path)))
header_fmt = (
f" {{:<{col_widths[0]}}} |"
f" {{:^{col_widths[1]}}} |"
f" {{:^{col_widths[2]}}} |"
f" {{:^{col_widths[3]}}} |"
f" {{:^{col_widths[4]}}} |"
f" {{:^{col_widths[5]}}} |"
f" {{:^{col_widths[6]}}} "
)
row_fmt = (
f" {{:<{col_widths[0]}}} |"
f" {{:^{col_widths[1]}}}|" # No space - intentional
f" {{:^{col_widths[2]}}}|" # No space - intentional
f" {{:^{col_widths[3]}}}|" # No space - intentional
f" {{:^{col_widths[4]}}}|" # No space - intentional
f" {{:^{col_widths[5]}}}|" # No space - intentional
f" {{:^{col_widths[6]}}} " # DataLoss is last, needs space
)
separator = "".join([
"-" * (col_widths[0] + 2),
"+",
"-" * (col_widths[1] + 2),
"+",
"-" * (col_widths[2] + 2),
"+",
"-" * (col_widths[3] + 2),
"+",
"-" * (col_widths[4] + 2),
"+",
"-" * (col_widths[5] + 2),
"+",
"-" * (col_widths[6] + 2)
])
print(header_fmt.format(*headers), file=sys.stderr)
print(separator, file=sys.stderr)
for r in results:
# Symbol Logic
read_symbol = bool_to_check(r.is_readable)
write_symbol = bool_to_check(r.is_writeable)
mount_symbol = CHECK_SYMBOL if r.is_mounted else CROSS_SYMBOL
if r.category == "persist":
if r.underlying_fs_is_ramdisk or r.is_ramdisk:
ramdisk_symbol = CROSS_SYMBOL
else:
ramdisk_symbol = BLANK_SYMBOL
perf_symbol = BLANK_SYMBOL
elif r.category == "ramdisk":
ramdisk_symbol = CHECK_SYMBOL if r.is_ramdisk else CROSS_SYMBOL
perf_symbol = bool_to_check(not r.performance_issue)
else:
ramdisk_symbol = BLANK_SYMBOL
perf_symbol = bool_to_check(not r.performance_issue)
dataloss_symbol = bool_to_check(not r.dataloss_risk)
print(
row_fmt.format(
r.path,
read_symbol,
write_symbol,
mount_symbol,
ramdisk_symbol,
perf_symbol,
dataloss_symbol,
),
file=sys.stderr
)
# --- Print Warning ---
if has_issues:
print("\n", file=sys.stderr)
print_warning_message(results)
# Exit with error only if there are read/write permission issues
if (has_rw_errors or has_primary_dataloss) and os.environ.get("NETALERTX_DEBUG") != "1":
sys.exit(1)
if __name__ == "__main__":
main()