2016-03-21 19:10:52 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
"""mc-backup.py: automate backups of my Minecraft servers, using the
|
|
|
|
minecraft-control wrapper.
|
|
|
|
|
|
|
|
Usage: mc-backup.py -c minecraft_control.ini -s /path/to/server
|
|
|
|
|
|
|
|
The `minecraft-control` password is obtained from the settings.ini for
|
|
|
|
minecraft-control. Backups are placed in the `backups/` directory under the
|
|
|
|
Minecraft server root, named with datetime stamps.
|
|
|
|
|
2016-03-23 17:51:57 +00:00
|
|
|
See `mc-backup.py --help` for command usage.
|
|
|
|
|
|
|
|
minecraft-control is available at: https://github.com/kirsle/minecraft-control
|
|
|
|
|
|
|
|
Example setting this up in cron:
|
|
|
|
0 2 * * * ~/cron/mc-backup.py -c ~/mc-control/settings.ini -s ~/mc-server
|
|
|
|
|
|
|
|
--Kirsle
|
|
|
|
https://www.kirsle.net/"""
|
2016-03-21 19:10:52 +00:00
|
|
|
|
|
|
|
import argparse
|
|
|
|
from configparser import RawConfigParser
|
|
|
|
import datetime
|
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import subprocess
|
|
|
|
import sys
|
|
|
|
import time
|
|
|
|
|
|
|
|
# Import the minecraft-control client library.
|
|
|
|
from mcclient import MinecraftClient
|
|
|
|
|
2016-04-12 22:31:40 +00:00
|
|
|
# Threshhold for the number of backups to retain. Modify these variables if you
|
|
|
|
# don't like them.
|
|
|
|
DAILY_BACKUPS = 7 # Retain the 7 most recent daily backups
|
|
|
|
WEEKLY_BACKUPS = 4 # For backups > DAILY_BACKUPS, keep this many copies around
|
|
|
|
# once per week.
|
|
|
|
WEEKLY_WEEKDAY = 6 # For weekly backups, base them around Sundays.
|
|
|
|
# 0=Monday .. 6=Sunday
|
|
|
|
|
2016-03-21 19:10:52 +00:00
|
|
|
logging.basicConfig()
|
|
|
|
log = logging.getLogger("mc-backup")
|
|
|
|
log.setLevel(logging.INFO)
|
|
|
|
|
|
|
|
class Application:
|
|
|
|
def __init__(self, args):
|
|
|
|
"""Initialize the application."""
|
|
|
|
self.args = args
|
|
|
|
|
|
|
|
# Verify settings.
|
|
|
|
self.verify_args()
|
|
|
|
|
|
|
|
self.config = dict() # minecraft-control configuration
|
|
|
|
self.client = None # MinecraftClient instance
|
|
|
|
self.today = datetime.datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S")
|
|
|
|
self.world = self.get_world_name()
|
|
|
|
log.info("Today's date/time: {}".format(self.today))
|
|
|
|
|
|
|
|
# Load the minecraft-control configuration.
|
|
|
|
self.load_mc_config()
|
|
|
|
|
|
|
|
def verify_args(self):
|
|
|
|
"""Do some sanity checking on input arguments."""
|
|
|
|
|
|
|
|
# The minecraft-control config file.
|
|
|
|
if not os.path.isfile(self.args.config):
|
|
|
|
log.error("{}: not a file".format(self.args.config))
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
# The server directory.
|
|
|
|
if not os.path.isfile("{}/server.properties".format(self.args.server)):
|
|
|
|
log.error("{}: not a Minecraft server directory (no "
|
|
|
|
"server.properties file present)".format(self.args.server))
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
def load_mc_config(self):
|
|
|
|
"""Load the configuration from minecraft-control."""
|
|
|
|
log.debug("Loading minecraft-control settings from {}".format(
|
|
|
|
args.config
|
|
|
|
))
|
|
|
|
|
|
|
|
parser = RawConfigParser()
|
|
|
|
with open(self.args.config, "r") as fh:
|
|
|
|
parser.readfp(fh)
|
|
|
|
|
|
|
|
self.config["host"] = parser.get("tcp-server", "address")
|
|
|
|
self.config["port"] = parser.get("tcp-server", "port")
|
|
|
|
self.config["password"] = parser.get("auth", "password")
|
|
|
|
self.config["method"] = parser.get("auth", "method")
|
|
|
|
|
|
|
|
def get_world_name(self):
|
|
|
|
"""Load the server.properties to find the world name."""
|
|
|
|
|
|
|
|
with open("{}/server.properties".format(self.args.server), "r") as fh:
|
|
|
|
for line in fh:
|
|
|
|
if not "=" in line: continue
|
|
|
|
parts = line.split("=", 1)
|
|
|
|
key = parts[0].strip()
|
|
|
|
value = parts[1].strip()
|
|
|
|
|
|
|
|
if key == "level-name":
|
|
|
|
return value
|
|
|
|
|
|
|
|
raise ValueError("No level-name found in server.properties!")
|
|
|
|
|
|
|
|
def run(self):
|
|
|
|
"""Run the main program's logic."""
|
|
|
|
|
|
|
|
# Change into the server's directory.
|
|
|
|
os.chdir(self.args.server)
|
|
|
|
|
2016-04-12 22:31:40 +00:00
|
|
|
# If we're only culling backups, do that and exit.
|
|
|
|
if self.args.clean:
|
|
|
|
return self.cull_backups()
|
|
|
|
|
2016-03-21 19:10:52 +00:00
|
|
|
# Backups directory.
|
|
|
|
backups = os.path.join(self.args.server, "backups")
|
|
|
|
if not os.path.isdir(backups):
|
|
|
|
log.info("Creating backups directory: {}".format(backups))
|
|
|
|
os.mkdir(backups)
|
|
|
|
|
|
|
|
# Connect to the Minecraft-Control server.
|
|
|
|
log.info("Connecting to Minecraft control server...")
|
|
|
|
self.client = MinecraftClient(
|
|
|
|
host=self.config["host"],
|
|
|
|
port=self.config["port"],
|
|
|
|
password=self.config["password"],
|
|
|
|
methods=[self.config["method"]],
|
|
|
|
)
|
|
|
|
self.client.add_handler("auth_ok", self.on_auth_ok)
|
|
|
|
self.client.add_handler("auth_error", self.on_auth_error)
|
|
|
|
self.client.add_handler("server_message", self.on_message)
|
|
|
|
|
|
|
|
self.client.connect()
|
|
|
|
self.client.start()
|
|
|
|
|
|
|
|
def on_auth_ok(self, mc):
|
|
|
|
"""Handle successful authentication."""
|
|
|
|
log.info("Connection to server established and authenticated!")
|
|
|
|
|
|
|
|
# Target file name.
|
|
|
|
fname = self.today + ".tar.gz"
|
|
|
|
target = os.path.join("backups", fname)
|
|
|
|
|
|
|
|
# Turn off saving and save the world now.
|
|
|
|
log.info("Turning off auto-saving and saving the world now!")
|
|
|
|
self.client.sendline("save-off")
|
|
|
|
self.client.sendline("save-all")
|
|
|
|
time.sleep(5)
|
|
|
|
|
|
|
|
# Archive the world.
|
|
|
|
log.info("Backing up the world as: {}".format(target))
|
|
|
|
subprocess.call(["tar", "czvf", target, self.world])
|
|
|
|
|
|
|
|
# Turn saving back on.
|
|
|
|
time.sleep(5)
|
|
|
|
log.info("Turning auto-saving back on!")
|
|
|
|
self.client.sendline("save-on")
|
|
|
|
|
2016-04-12 22:31:40 +00:00
|
|
|
# Cull old backups.
|
|
|
|
self.cull_backups()
|
|
|
|
|
|
|
|
def cull_backups(self):
|
|
|
|
"""Trim the backup copies and remove older backups."""
|
|
|
|
log.info("Culling backups...")
|
|
|
|
|
|
|
|
# Date cut-off for daily backups.
|
|
|
|
daily_cutoff = (
|
|
|
|
datetime.datetime.utcnow() - datetime.timedelta(days=DAILY_BACKUPS)
|
|
|
|
).strftime("%Y-%m-%d")
|
|
|
|
log.debug("Daily cutoff date: {}".format(daily_cutoff))
|
|
|
|
|
|
|
|
# Number of weekly backups spared.
|
|
|
|
weekly_spared = 0
|
|
|
|
|
|
|
|
# Check all the existing backups.
|
|
|
|
for tar in sorted(os.listdir("backups"), reverse=True):
|
|
|
|
if not tar.endswith(".tar.gz"):
|
|
|
|
continue
|
|
|
|
|
|
|
|
try:
|
|
|
|
dt = datetime.datetime.strptime(tar, "%Y-%m-%d_%H-%M-%S.tar.gz")
|
|
|
|
weekday = dt.weekday()
|
|
|
|
date = dt.strftime("%Y-%m-%d")
|
|
|
|
except Exception as e:
|
|
|
|
log.error("Error parsing datetime from existing backup {}: {}".format(
|
|
|
|
tar, e,
|
|
|
|
))
|
|
|
|
continue
|
|
|
|
|
|
|
|
# If this is within the daily cutoff, we keep it.
|
|
|
|
if date > daily_cutoff:
|
|
|
|
log.debug("DAILY KEEP: {} is within the daily cutoff".format(tar))
|
|
|
|
continue
|
|
|
|
|
|
|
|
# Now we're at backups older than our daily threshhold, so we only
|
|
|
|
# want to keep one backup per week until we have WEEKLY_BACKUPS
|
|
|
|
# copies, and only for backups taken on WEEKLY_WEEKDAY day-of-week.
|
|
|
|
if weekday == WEEKLY_WEEKDAY and weekly_spared < WEEKLY_BACKUPS:
|
|
|
|
log.debug("WEEKLY KEEP: {} is being kept".format(tar))
|
|
|
|
weekly_spared += 1
|
|
|
|
continue
|
|
|
|
|
|
|
|
# All other old backups get deleted.
|
|
|
|
log.info("Cull old backup: {}".format(tar))
|
|
|
|
os.unlink("backups/{}".format(tar))
|
2016-03-21 19:10:52 +00:00
|
|
|
|
|
|
|
def on_auth_error(self, mc, error):
|
|
|
|
"""Handle unsuccessful authentication."""
|
|
|
|
log.error(error)
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
def on_message(self, mc, message):
|
|
|
|
"""Handle a Minecraft server message."""
|
|
|
|
log.info("Minecraft server says: {}".format(message))
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
parser = argparse.ArgumentParser(description="Minecraft Backup Taker")
|
|
|
|
parser.add_argument("--debug", "-d",
|
|
|
|
help="Turn on debug mode.",
|
|
|
|
action="store_true",
|
|
|
|
)
|
|
|
|
parser.add_argument("--config", "-c",
|
|
|
|
help="Path to minecraft-control configuration file (settings.ini)",
|
|
|
|
type=str,
|
|
|
|
required=True,
|
|
|
|
)
|
|
|
|
parser.add_argument("--server", "-s",
|
|
|
|
help="Path to the Minecraft server on disk (should contain "
|
|
|
|
"./server.properties file)",
|
|
|
|
type=str,
|
|
|
|
required=True,
|
|
|
|
)
|
2016-04-12 22:31:40 +00:00
|
|
|
parser.add_argument("--clean",
|
|
|
|
help="Clean up (cull) old backups; do not create a new backup. "
|
|
|
|
"This executes the cull backups functionality which trims the "
|
|
|
|
"set of stored backups on disk to fit your backup threshholds. "
|
|
|
|
"This option does not trigger a *new* backup to be taken.",
|
|
|
|
action="store_true",
|
|
|
|
)
|
2016-03-21 19:10:52 +00:00
|
|
|
args = parser.parse_args()
|
|
|
|
if args.debug:
|
|
|
|
log.setLevel(logging.DEBUG)
|
|
|
|
|
|
|
|
Application(args).run()
|
|
|
|
|