Files
minecraft/dashboard/scripts/restore_data.py

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)))