- Created backup files for wipefs on specific disk partitions. - Added db-test script to verify metadata.db file integrity. - Documented successful mergerfs branch and pool configurations in mfs-good.txt. - Implemented mfs write test in mfs-test.txt. - Included mergerfs version and device information in mfs.txt. - Developed rebuild scripts for mergerfs stages, including verification and filesystem creation. - Generated stage logs for each rebuild step to track disk and filesystem status. - Added checks for systemd mount requirements and filesystem verification in stage4.
226 lines
6.6 KiB
Python
Executable File
226 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
LV = "/dev/mapper/plex--new-plex--new"
|
|
MANIFEST = Path("/mnt/recovery/plex-books-manifest.jsonl")
|
|
REPORT = Path("/mnt/recovery/plex-books-unknown-check.tsv")
|
|
SUMMARY = Path("/mnt/recovery/plex-books-unknown-summary.txt")
|
|
|
|
DEAD_START = 3418636288
|
|
DEAD_END = 4395389951
|
|
|
|
inode_re = re.compile(r"Inode:\s*(\d+)\s+Type:\s*([^\s]+)")
|
|
size_re = re.compile(r"\bSize:\s*(\d+)")
|
|
etb_re = re.compile(r"\(ETB(\d+)\):(\d+)")
|
|
extent_re = re.compile(r"\((\d+)(?:-(\d+))?\):(\d+)(?:-(\d+))?")
|
|
|
|
def die(msg):
|
|
print(f"ERROR: {msg}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
def human(n):
|
|
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
|
x = float(n)
|
|
for unit in units:
|
|
if x < 1024 or unit == units[-1]:
|
|
return f"{x:.2f} {unit}"
|
|
x /= 1024
|
|
|
|
def run(cmd):
|
|
return subprocess.run(
|
|
cmd,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
def debugfs(command):
|
|
env = os.environ.copy()
|
|
env["DEBUGFS_PAGER"] = "cat"
|
|
return subprocess.run(
|
|
["debugfs", "-c", "-R", command, LV],
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
|
|
def preflight():
|
|
if os.geteuid() != 0:
|
|
die("Run as root.")
|
|
if not Path(LV).exists():
|
|
die(f"Missing LV: {LV}")
|
|
ro = run(["blockdev", "--getro", LV])
|
|
if ro.returncode != 0 or ro.stdout.strip() != "1":
|
|
die(f"{LV} is not kernel read-only.")
|
|
if not MANIFEST.exists():
|
|
die(f"Manifest not found: {MANIFEST}")
|
|
|
|
def classify(record):
|
|
inode = int(record["inode"])
|
|
path = record["path"]
|
|
size = int(record.get("size", 0))
|
|
|
|
stat_result = debugfs(f"stat <{inode}>")
|
|
stat_text = stat_result.stdout
|
|
|
|
etbs = [
|
|
{"level": int(level), "block": int(block)}
|
|
for level, block in etb_re.findall(stat_text)
|
|
]
|
|
|
|
real_extents = []
|
|
if "EXTENTS:" in stat_text:
|
|
extent_text = stat_text.split("EXTENTS:", 1)[1]
|
|
for m in extent_re.finditer(extent_text):
|
|
# Skip ETB pseudo-lines; extent_re only matches numeric logical starts.
|
|
logical_start = int(m.group(1))
|
|
logical_end = int(m.group(2) or m.group(1))
|
|
physical_start = int(m.group(3))
|
|
physical_end = int(m.group(4) or m.group(3))
|
|
real_extents.append(
|
|
{
|
|
"logical_start": logical_start,
|
|
"logical_end": logical_end,
|
|
"physical_start": physical_start,
|
|
"physical_end": physical_end,
|
|
}
|
|
)
|
|
|
|
blocks_result = debugfs(f"blocks <{inode}>")
|
|
block_nums = []
|
|
for token in re.findall(r"\b\d+\b", blocks_result.stdout):
|
|
try:
|
|
block_nums.append(int(token))
|
|
except ValueError:
|
|
pass
|
|
|
|
dead_etbs = [e for e in etbs if DEAD_START <= e["block"] <= DEAD_END]
|
|
live_etbs = [e for e in etbs if not (DEAD_START <= e["block"] <= DEAD_END)]
|
|
|
|
overlapping_extents = [
|
|
ex for ex in real_extents
|
|
if ex["physical_start"] <= DEAD_END and ex["physical_end"] >= DEAD_START
|
|
]
|
|
|
|
if real_extents:
|
|
if overlapping_extents:
|
|
cls = "DATA_OVERLAP"
|
|
reason = "parsable data extent overlaps dead PV"
|
|
else:
|
|
cls = "EXTENTS_RECOVERABLE"
|
|
reason = "parsable data extents exist and avoid dead PV"
|
|
elif etbs:
|
|
if dead_etbs:
|
|
cls = "ETB_LOST"
|
|
reason = "extent-tree metadata block is on dead PV"
|
|
elif live_etbs:
|
|
cls = "ETB_SURVIVES"
|
|
reason = "extent-tree metadata block is outside dead PV; investigate"
|
|
else:
|
|
cls = "OTHER_UNKNOWN"
|
|
reason = "ETB metadata present but could not classify"
|
|
else:
|
|
cls = "OTHER_UNKNOWN"
|
|
reason = "no parsable data extents or ETB references"
|
|
|
|
return {
|
|
"class": cls,
|
|
"reason": reason,
|
|
"inode": inode,
|
|
"path": path,
|
|
"size": size,
|
|
"etbs": etbs,
|
|
"dead_etbs": dead_etbs,
|
|
"live_etbs": live_etbs,
|
|
"real_extents": real_extents,
|
|
"blocks_output": " ".join(str(b) for b in block_nums),
|
|
"stat_stderr": stat_result.stderr.strip(),
|
|
"blocks_stderr": blocks_result.stderr.strip(),
|
|
}
|
|
|
|
def main():
|
|
preflight()
|
|
|
|
unknown = []
|
|
with MANIFEST.open("r", encoding="utf-8") as f:
|
|
for line in f:
|
|
if not line.strip():
|
|
continue
|
|
rec = json.loads(line)
|
|
if rec.get("status") == "UNKNOWN":
|
|
unknown.append(rec)
|
|
|
|
if not unknown:
|
|
print("No UNKNOWN entries found in manifest.")
|
|
return
|
|
|
|
counts = {}
|
|
sizes = {}
|
|
|
|
with REPORT.open("w", encoding="utf-8") as out:
|
|
out.write("class\tbytes\tinode\tpath\treason\tetb_blocks\tblocks_output\n")
|
|
|
|
for idx, rec in enumerate(unknown, 1):
|
|
result = classify(rec)
|
|
cls = result["class"]
|
|
counts[cls] = counts.get(cls, 0) + 1
|
|
sizes[cls] = sizes.get(cls, 0) + result["size"]
|
|
|
|
etb_blocks = ",".join(
|
|
f"ETB{e['level']}:{e['block']}" for e in result["etbs"]
|
|
)
|
|
|
|
clean_reason = result["reason"].replace("\t", " ").replace("\n", " ")
|
|
clean_path = result["path"].replace("\t", " ")
|
|
clean_blocks = result["blocks_output"].replace("\t", " ")
|
|
|
|
out.write(
|
|
f"{cls}\t{result['size']}\t{result['inode']}\t"
|
|
f"{clean_path}\t{clean_reason}\t{etb_blocks}\t{clean_blocks}\n"
|
|
)
|
|
out.flush()
|
|
|
|
print(
|
|
f"[{idx}/{len(unknown)}] {cls:<20} "
|
|
f"{human(result['size']):>10} {result['path']}"
|
|
)
|
|
if result["etbs"]:
|
|
print(
|
|
" "
|
|
+ ", ".join(
|
|
f"ETB{e['level']}={e['block']}"
|
|
+ (" [DEAD]" if DEAD_START <= e["block"] <= DEAD_END else " [LIVE]")
|
|
for e in result["etbs"]
|
|
)
|
|
)
|
|
|
|
lines = [
|
|
"PLEX BOOKS UNKNOWN SECONDARY CHECK",
|
|
"==================================",
|
|
f"Unknown entries checked: {len(unknown)}",
|
|
f"Dead block range: {DEAD_START}-{DEAD_END}",
|
|
"",
|
|
]
|
|
|
|
for cls in sorted(counts):
|
|
lines.append(f"{cls:20} {counts[cls]:4d} files {human(sizes[cls])}")
|
|
|
|
lines += [
|
|
"",
|
|
f"Report: {REPORT}",
|
|
]
|
|
|
|
text = "\n".join(lines) + "\n"
|
|
SUMMARY.write_text(text, encoding="utf-8")
|
|
print()
|
|
print(text)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|