Python Backup Script for Wekan Docker environment - jean/wekan GitHub Wiki
Features
- reads values from config file (db-name, container-name, retention of backups, target-path)
- executes mongodump and copies it to the host system
- checks the target backup directory for existing dumps and deletes them if they reached a certain age
This backup script is meant to be executed via cronjob. Example crontab (Backup daily at 18:30):
30 18 * * * /usr/local/sbin/wekandump/wekandump.py /usr/local/sbin/wekandump/wekandump.yml > /dev/null 2>&1
Adjust the retention value in the yaml-config file to suit your needs (see example .yml file at the bottom of the page)
#!/usr/bin/env python3
# vim: set fileencoding=utf-8 :
#various imports
import os
import sys
import subprocess
import configparser
import time
import datetime
import smtplib
import traceback
import logging
import gzip
import yaml
import abc
import shutil
#define config class with all required config-parameters
class Config:
__conf = {
"db_name" : '',
"retention" : '',
"dump_path" : '',
"container" : '',
"start_date" : time.strftime('%Y%m%d-%H%M%S'),
"curdate" : time.strftime('%Y-%m-%d %X')
}
#define the parameters that can be set through config file
__setters = ["db_name", "retention", "dump_path", "container"]
@staticmethod
def config(name):
return Config.__conf[name]
@staticmethod
def set(name, value):
if name in Config.__setters:
Config.__conf[name] = value
else:
raise NameError("Name not accepted in set() method")
#define db class and assign vars
class Dbms(metaclass=abc.ABCMeta):
def __init__(self, db_name, container, dump_path):
self._database = db_name
self._dumpfile = os.path.join(dump_path, self.getdumpfilename())
self._container = container
self._compression = CompressionGzip()
self._dump_path = dump_path
@abc.abstractmethod
def dump(self):
pass
#function to define filename of the backup-archive
def getdumpfilename(self):
return 'dump-{}-{}'.format(self._database, Config.config('start_date'))
#class for the mongodb backup
class DbmsMongodb(Dbms):
def dump(self):
#command for creating the backup
call = 'docker exec {} bash -c "mongodump -d {} -o /dump/"'.format(self._container, self._database)
try:
output = subprocess.check_output(call, universal_newlines=True, shell=True)
except subprocess.CalledProcessError as e:
raise Exception('Mongodump failed due to the following Error: {}'.format(e))
#command for copying the backup to the host system
call = 'docker cp {}:/dump {}'.format(self._container, self._dump_path)
try:
output = subprocess.check_output(call, universal_newlines=True, shell=True)
except subprocess.CalledProcessError as e:
raise Exception('Pulling dump from container failed due to the following Error: {}'.format(e))
#tar the backup-folder
call = 'tar -C {}/dump -cf {} .'.format(self._dump_path, self._dumpfile + '.tar')
try:
output = subprocess.check_output(call, universal_newlines=True, shell=True)
except subprocess.CalledProcessError as e:
raise Exception('Creating .tar-ball failed due to the following Error: {}'.format(e))
self._compression.setFilename(self._dumpfile + '.tar')
self._compression.compress()
shutil.rmtree(self._dump_path + '/dump')
class Compression(metaclass=abc.ABCMeta):
def __init__(self):
self._filename = ''
def setFilename(self, filename):
self._filename = filename
@abc.abstractmethod
def compress(self):
pass
#class for compressing the backup with gzip (this can be interchanged with xz, bzip etc.)
class CompressionGzip(Compression):
def compress(self):
call = 'gzip {}'.format(self._filename)
try:
output = subprocess.check_output(call, universal_newlines=True, shell=True)
except subprocess.CalledProcessError as e:
raise Exception('Compression failed due to the following Error: {}'.format(e))
else:
#print('Successfully created dump: {}'.format(self._filename + '.gz'))
pass
#DB-Config-File-Checker: checks if the file passed in the function call is accessible, if not, raise exception
def checkcfg(conf):
if(os.path.isfile(conf)):
config_file = conf
else:
raise Exception("Specified Config File doesn't exist or insufficient access rights")
checkpermission(config_file)
return config_file
def checkpath(path):
if not os.path.exists(path):
os.makedirs(path)
#this checks the permissions of the config file (you can leave this part out, just required because of corporate environment)
def checkpermission(cfg):
if (os.stat(cfg).st_uid != 0):
raise Exception("Config file must be owned by user root!")
elif (os.stat(cfg).st_gid != 0):
raise Exception("Config file must be owned by group root!")
else:
accessmask = oct(os.stat(cfg).st_mode)[-3:]
if accessmask == '600' or accessmask == '700':
pass
else:
raise Exception("Root must have read and write access to config file, all other users mustn't be allowed. Current Access Mask: {} but it should be 600 or 700".format(accessmask))
pass
def parseInput():
sys.tracebacklimit = None
#check if the script has been called with one argument --> The db-specific config file
if len(sys.argv) != 2:
raise Exception("usage: wekandump.py <path_to_configfile> \n Please specify the path to a configfile")
#Send the specified db-config file to the Configuration-Checker
config_file = checkcfg(sys.argv[1])
#Now that the config-file have been checked, finally open it
with open(sys.argv[1], 'r') as cfgfile:
cfg = yaml.safe_load(cfgfile)
#Set some vars using data from the config-file
Config.set('db_name', cfg['dumps']['database'])
Config.set('retention', cfg['dumps']['retention'])
Config.set('dump_path', cfg['dumps']['path'])
Config.set('container', cfg['dumps']['container'])
checkpath(Config.config('dump_path'))
cfgfile.close
def dumpcompress():
dbms = DbmsMongodb(Config.config('db_name'), Config.config('container'), Config.config('dump_path'))
dbms.dump()
def getcrtime(item):
call = 'stat -c %y {}'.format(item)
output = subprocess.check_output(call, universal_newlines=True, shell=True)
output = output.rstrip()
crtime = datetime.datetime.fromtimestamp(os.stat(item).st_mtime)
return crtime
def housekeep():
#get all filenames beginning with "dump-" located in the dump-directory
call = 'ls {}'.format(os.path.join(Config.config('dump_path'), "dump-*"))
output = subprocess.check_output(call, universal_newlines=True, shell=True)
output = output.rstrip()
dumps = output.split('\n')
#now that we have a list with the filenames of the files in the dump-folder, every filename is handled seperately
for item in dumps:
item = os.path.join(Config.config('dump_path'), item)
crtime = getcrtime(item)
curtime = datetime.datetime.strptime(Config.config('curdate'), '%Y-%m-%d %X')
if (curtime-crtime).days >= Config.config('retention'):
try:
os.remove(item)
except:
try:
shutil.rmtree(item)
except:
raise Exception('Housekeep: failed to delete the dump {}'.format(item))
else:
#print("Housekeep: Deleted dump: {}, it has reached the age of {} days. (Retention is {} days.)".format(item, curtime-crtime, Config.config('retention')))
pass
else:
#print("Housekeep: Deleted dump: {}, it has reached the age of {} days. (Retention is {} days.)".format(item, curtime-crtime, Config.config('retention')))
pass
else:
#print("Housekeep: Dump {} was kept since it is only {} hours old. (Retention is {} days.)".format(item, curtime-crtime, Config.config('retention')))
pass
def main():
parseInput()
dumpcompress()
housekeep()
if __name__ == "__main__":
main()
#created by DrGraypFroot
Yaml Config file (Specify the database name, retention in days, backup target path and name of your mongodb-docker-container:
dumps:
database: wekan #name of the database
retention: 14 #number of days of retention
path: /var/lib/wekandump/ #name of the target directory for dumps
container: wekan-db #name of the docker-container
IMPORTANT:
- the names of the values in the yml-file shouldn't be changed. If you really need to change them, keep in mind that you also have to alter the script accordingly
- You need to have PyYAML and Python installed
- feel free to comment if you have any issues
- Disclaimer: I don't take any responsibility for lost data