117 lines
4.9 KiB
Python
117 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate a Minecraft archive and replace data contents while preserving the bind root.
|
|
|
|
The caller MUST stop Minecraft and its backup writer before `restore`.
|
|
Only regular files/directories are accepted. No existing data is deleted.
|
|
"""
|
|
import argparse
|
|
import fcntl
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import shutil
|
|
import tarfile
|
|
import tempfile
|
|
|
|
|
|
def members(archive):
|
|
result = []
|
|
seen = set()
|
|
for member in archive.getmembers():
|
|
name = PurePosixPath(member.name)
|
|
if name.is_absolute() or ".." in name.parts or "\\" in member.name:
|
|
raise ValueError("Archive contains an unsafe path")
|
|
if not (member.isdir() or member.isfile()):
|
|
raise ValueError("Archive links and special files are not supported")
|
|
key = str(name)
|
|
if key in seen:
|
|
raise ValueError("Archive contains duplicate paths")
|
|
seen.add(key)
|
|
result.append(member)
|
|
if not any(m.isfile() and PurePosixPath(m.name).name == "level.dat" for m in result):
|
|
raise ValueError("Archive does not contain a Minecraft world (level.dat)")
|
|
return result
|
|
|
|
|
|
def verify(archive_path):
|
|
# Reading each member also verifies the gzip footer, rather than only headers.
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
entries = members(archive)
|
|
for member in entries:
|
|
if member.isfile():
|
|
with archive.extractfile(member) as source:
|
|
while source.read(1024 * 1024):
|
|
pass
|
|
import gzip
|
|
with gzip.open(archive_path, "rb") as source:
|
|
while source.read(1024 * 1024):
|
|
pass
|
|
return entries
|
|
|
|
|
|
def restore(archive_path, data_path, work_path, lock_file=None):
|
|
data = Path(data_path).resolve(strict=True)
|
|
work = Path(work_path).resolve()
|
|
if data == work or data in work.parents or work in data.parents:
|
|
raise ValueError("Restore workspace must be separate from the data tree")
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
if data.stat().st_dev != work.stat().st_dev:
|
|
raise ValueError("Data and restore workspace must be on the same filesystem")
|
|
with (work / ".restore.lock").open("a") as operation_lock:
|
|
fcntl.flock(operation_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
entries = verify(archive_path)
|
|
required = sum(m.size for m in entries if m.isfile())
|
|
if shutil.disk_usage(work).free < required + 64 * 1024 * 1024:
|
|
raise ValueError("Insufficient free space for a staged restore")
|
|
run = Path(tempfile.mkdtemp(prefix="restore-", dir=work))
|
|
staged, previous = run / "staged", run / "previous"
|
|
staged.mkdir()
|
|
previous.mkdir()
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
archive.extractall(staged, members=members(archive), filter="data")
|
|
# tarfile's data filter intentionally discards archive ownership.
|
|
# Give the staged tree to the actual server data owner before activation.
|
|
owner = data.stat()
|
|
if os.geteuid() == 0:
|
|
for child in staged.rglob("*"):
|
|
os.chown(child, owner.st_uid, owner.st_gid)
|
|
elif os.geteuid() != owner.st_uid:
|
|
raise ValueError("Restore must run as root or the server data owner")
|
|
if lock_file:
|
|
marker = staged / ".deployment-lock.json"
|
|
if not marker.is_file() or json.loads(marker.read_text()) != json.loads(Path(lock_file).read_text()):
|
|
raise ValueError("Backup deployment lock differs. Restore the matching release via the host runbook.")
|
|
# Moving children keeps the /data bind mount inode alive in existing containers.
|
|
moved_old, moved_new = [], []
|
|
try:
|
|
for child in data.iterdir():
|
|
child.rename(previous / child.name)
|
|
moved_old.append(child.name)
|
|
for child in staged.iterdir():
|
|
child.rename(data / child.name)
|
|
moved_new.append(child.name)
|
|
except BaseException:
|
|
for name in reversed(moved_new):
|
|
(data / name).rename(staged / name)
|
|
for name in reversed(moved_old):
|
|
(previous / name).rename(data / name)
|
|
raise
|
|
return {"previous_data": str(previous), "restored_from": str(Path(archive_path).resolve())}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("action", choices=["verify", "restore"])
|
|
parser.add_argument("--archive", required=True)
|
|
parser.add_argument("--data")
|
|
parser.add_argument("--work")
|
|
parser.add_argument("--lock-file")
|
|
args = parser.parse_args()
|
|
if args.action == "verify":
|
|
verify(args.archive)
|
|
print(json.dumps({"valid": True}))
|
|
else:
|
|
if not args.data or not args.work:
|
|
parser.error("restore requires --data and --work")
|
|
print(json.dumps(restore(args.archive, args.data, args.work, args.lock_file)))
|