Pin Minecraft 26.3 and add safe Orion managed deployment support
This commit is contained in:
1 parent
dff2b5a9c4
commit
008120dbad
40 files changed
+1877
-490
No files matched your search
@@ -0,0 +1,116 @@
|
||||
#!/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)))
|
||||
Reference in new issue
Block a user