TISbackup/libtisbackup/common.py

1048 lines
40 KiB
Python
Raw Normal View History

2022-04-25 10:02:43 +02:00
#!/usr/bin/python3
2013-05-23 10:19:43 +02:00
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------
# This file is part of TISBackup
#
# TISBackup is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# TISBackup is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
#
# -----------------------------------------------------------------------
2024-11-28 23:46:48 +01:00
import datetime
import errno
2024-11-28 23:46:48 +01:00
import logging
2013-05-23 10:19:43 +02:00
import os
import re
import select
2024-11-28 23:46:48 +01:00
import shutil
import sqlite3
import subprocess
2013-05-23 10:19:43 +02:00
import sys
2024-11-28 23:46:48 +01:00
import time
from abc import ABC, abstractmethod
from iniparse import ConfigParser
2013-05-23 10:19:43 +02:00
try:
sys.stderr = open("/dev/null") # Silence silly warnings from paramiko
2013-05-23 10:19:43 +02:00
import paramiko
2022-04-25 10:02:43 +02:00
except ImportError as e:
print(("Error : can not load paramiko library %s" % e))
2013-05-23 10:19:43 +02:00
raise
sys.stderr = sys.__stderr__
nagiosStateOk = 0
nagiosStateWarning = 1
nagiosStateCritical = 2
nagiosStateUnknown = 3
backup_drivers = {}
2013-05-23 10:19:43 +02:00
def register_driver(driverclass):
backup_drivers[driverclass.type] = driverclass
2013-05-23 10:19:43 +02:00
def datetime2isodate(adatetime=None):
if not adatetime:
adatetime = datetime.datetime.now()
assert isinstance(adatetime, datetime.datetime)
2013-05-23 10:19:43 +02:00
return adatetime.isoformat()
2013-05-23 10:19:43 +02:00
def isodate2datetime(isodatestr):
# we remove the microseconds part as it is not working for python2.5 strptime
return datetime.datetime.strptime(isodatestr.split(".")[0], "%Y-%m-%dT%H:%M:%S")
2013-05-23 10:19:43 +02:00
def time2display(adatetime):
return adatetime.strftime("%Y-%m-%d %H:%M")
2013-05-23 10:19:43 +02:00
def hours_minutes(hours):
if hours is None:
return None
else:
return "%02i:%02i" % (int(hours), int((hours - int(hours)) * 60.0))
2013-05-23 10:19:43 +02:00
def fileisodate(filename):
return datetime.datetime.fromtimestamp(os.stat(filename).st_mtime).isoformat()
2013-05-23 10:19:43 +02:00
def dateof(adatetime):
return adatetime.replace(hour=0, minute=0, second=0, microsecond=0)
2013-05-23 10:19:43 +02:00
#####################################
# http://code.activestate.com/recipes/498181-add-thousands-separator-commas-to-formatted-number/
# Code from Michael Robellard's comment made 28 Feb 2010
# Modified for leading +, -, space on 1 Mar 2010 by Glenn Linderman
#
2013-05-23 10:19:43 +02:00
# Tail recursion removed and leading garbage handled on March 12 2010, Alessandro Forghieri
def splitThousands(s, tSep=",", dSep="."):
"""Splits a general float on thousands. GIGO on general input"""
if s is None:
2013-05-23 10:19:43 +02:00
return 0
if not isinstance(s, str):
s = str(s)
cnt = 0
numChars = dSep + "0123456789"
ls = len(s)
while cnt < ls and s[cnt] not in numChars:
cnt += 1
lhs = s[0:cnt]
s = s[cnt:]
if dSep == "":
2013-05-23 10:19:43 +02:00
cnt = -1
else:
cnt = s.rfind(dSep)
2013-05-23 10:19:43 +02:00
if cnt > 0:
rhs = dSep + s[cnt + 1 :]
s = s[:cnt]
2013-05-23 10:19:43 +02:00
else:
rhs = ""
2013-05-23 10:19:43 +02:00
splt = ""
while s != "":
splt = s[-3:] + tSep + splt
s = s[:-3]
2013-05-23 10:19:43 +02:00
return lhs + splt[:-1] + rhs
2013-05-23 10:19:43 +02:00
def call_external_process(shell_string):
p = subprocess.call(shell_string, shell=True)
if p != 0:
raise Exception("shell program exited with error code " + str(p), shell_string)
2013-05-23 10:19:43 +02:00
def check_string(test_string):
pattern = r"[^\.A-Za-z0-9\-_]"
2013-05-23 10:19:43 +02:00
if re.search(pattern, test_string):
# Character other then . a-z 0-9 was found
print(("Invalid : %r" % (test_string,)))
2013-05-23 10:19:43 +02:00
def convert_bytes(bytes):
if bytes is None:
return None
else:
2013-05-23 10:19:43 +02:00
bytes = float(bytes)
if bytes >= 1099511627776:
terabytes = bytes / 1099511627776
size = "%.2fT" % terabytes
2013-05-23 10:19:43 +02:00
elif bytes >= 1073741824:
gigabytes = bytes / 1073741824
size = "%.2fG" % gigabytes
2013-05-23 10:19:43 +02:00
elif bytes >= 1048576:
megabytes = bytes / 1048576
size = "%.2fM" % megabytes
2013-05-23 10:19:43 +02:00
elif bytes >= 1024:
kilobytes = bytes / 1024
size = "%.2fK" % kilobytes
2013-05-23 10:19:43 +02:00
else:
size = "%.2fb" % bytes
return size
2013-05-23 10:19:43 +02:00
2013-05-23 10:19:43 +02:00
## {{{ http://code.activestate.com/recipes/81189/ (r2)
def pp(cursor, data=None, rowlens=0, callback=None):
"""
pretty print a query result as a table
callback is a function called for each field (fieldname,value) to format the output
"""
def defaultcb(fieldname, value):
2013-05-23 10:19:43 +02:00
return value
if not callback:
callback = defaultcb
d = cursor.description
if not d:
return "#### NO RESULTS ###"
names = []
lengths = []
rules = []
if not data:
data = cursor.fetchall()
for dd in d: # iterate over description
2013-05-23 10:19:43 +02:00
l = dd[1]
if not l:
l = 12 # or default arg ...
l = max(l, len(dd[0])) # handle long names
2013-05-23 10:19:43 +02:00
names.append(dd[0])
lengths.append(l)
for col in range(len(lengths)):
if rowlens:
rls = [len(str(callback(d[col][0], row[col]))) for row in data if row[col]]
lengths[col] = max([lengths[col]] + rls)
rules.append("-" * lengths[col])
2013-05-23 10:19:43 +02:00
format = " ".join(["%%-%ss" % l for l in lengths])
result = [format % tuple(names)]
result.append(format % tuple(rules))
for row in data:
row_cb = []
2013-05-23 10:19:43 +02:00
for col in range(len(d)):
row_cb.append(callback(d[col][0], row[col]))
2013-05-23 10:19:43 +02:00
result.append(format % tuple(row_cb))
return "\n".join(result)
2013-05-23 10:19:43 +02:00
## end of http://code.activestate.com/recipes/81189/ }}}
def html_table(cur, callback=None):
2013-05-23 10:19:43 +02:00
"""
cur est un cursor issu d'une requete
callback est une fonction qui prend (rowmap,fieldname,value)
et renvoie une representation texte
2013-05-23 10:19:43 +02:00
"""
2013-05-23 10:19:43 +02:00
def safe_unicode(iso):
if iso is None:
return None
elif isinstance(iso, str):
return iso # .decode()
2013-05-23 10:19:43 +02:00
else:
return iso
def itermap(cur):
for row in cur:
yield dict((cur.description[idx][0], value) for idx, value in enumerate(row))
2013-05-23 10:19:43 +02:00
head = "<tr>" + "".join(["<th>" + c[0] + "</th>" for c in cur.description]) + "</tr>"
lines = ""
2013-05-23 10:19:43 +02:00
if callback:
for r in itermap(cur):
lines = (
lines
+ "<tr>"
+ "".join(["<td>" + str(callback(r, c[0], safe_unicode(r[c[0]]))) + "</td>" for c in cur.description])
+ "</tr>"
)
2013-05-23 10:19:43 +02:00
else:
for r in cur:
lines = lines + "<tr>" + "".join(["<td>" + safe_unicode(c) + "</td>" for c in r]) + "</tr>"
2013-05-23 10:19:43 +02:00
return "<table border=1 cellpadding=2 cellspacing=0>%s%s</table>" % (head, lines)
2013-05-23 10:19:43 +02:00
def monitor_stdout(aprocess, onoutputdata, context):
2013-05-23 10:19:43 +02:00
"""Reads data from stdout and stderr from aprocess and return as a string
on each chunk, call a call back onoutputdata(dataread)
2013-05-23 10:19:43 +02:00
"""
assert isinstance(aprocess, subprocess.Popen)
2013-05-23 10:19:43 +02:00
read_set = []
stdout = []
line = ""
2013-05-23 10:19:43 +02:00
if aprocess.stdout:
read_set.append(aprocess.stdout)
if aprocess.stderr:
read_set.append(aprocess.stderr)
while read_set:
try:
rlist, wlist, xlist = select.select(read_set, [], [])
2022-04-25 10:02:43 +02:00
except select.error as e:
2013-05-23 10:19:43 +02:00
if e.args[0] == errno.EINTR:
continue
raise
# Reads one line from stdout
if aprocess.stdout in rlist:
data = os.read(aprocess.stdout.fileno(), 1)
data = data.decode(errors="ignore")
2013-05-23 10:19:43 +02:00
if data == "":
aprocess.stdout.close()
read_set.remove(aprocess.stdout)
while data and data not in ("\n", "\r"):
2013-05-23 10:19:43 +02:00
line += data
data = os.read(aprocess.stdout.fileno(), 1)
data = data.decode(errors="ignore")
if line or data in ("\n", "\r"):
2013-05-23 10:19:43 +02:00
stdout.append(line)
if onoutputdata:
onoutputdata(line, context)
line = ""
2013-05-23 10:19:43 +02:00
# Reads one line from stderr
if aprocess.stderr in rlist:
data = os.read(aprocess.stderr.fileno(), 1)
data = data.decode(errors="ignore")
2013-05-23 10:19:43 +02:00
if data == "":
aprocess.stderr.close()
read_set.remove(aprocess.stderr)
while data and data not in ("\n", "\r"):
2013-05-23 10:19:43 +02:00
line += data
data = os.read(aprocess.stderr.fileno(), 1)
data = data.decode(errors="ignore")
if line or data in ("\n", "\r"):
2013-05-23 10:19:43 +02:00
stdout.append(line)
if onoutputdata:
onoutputdata(line, context)
line = ""
2013-05-23 10:19:43 +02:00
aprocess.wait()
if line:
stdout.append(line)
if onoutputdata:
onoutputdata(line, context)
2013-05-23 10:19:43 +02:00
return "\n".join(stdout)
def str2bool(val):
if not isinstance(type(val), bool):
return val.lower() in ("yes", "true", "t", "1")
2013-05-23 10:19:43 +02:00
class BackupStat:
dbpath = ""
2013-05-23 10:19:43 +02:00
db = None
logger = logging.getLogger("tisbackup")
2013-05-23 10:19:43 +02:00
def __init__(self, dbpath):
2013-05-23 10:19:43 +02:00
self.dbpath = dbpath
if not os.path.isfile(self.dbpath):
self.db = sqlite3.connect(self.dbpath)
2013-05-23 10:19:43 +02:00
self.initdb()
else:
self.db = sqlite3.connect(self.dbpath, check_same_thread=False)
if "'TYPE'" not in str(self.db.execute("select * from stats").description):
2013-05-23 10:19:43 +02:00
self.updatedb()
2013-05-23 10:19:43 +02:00
def updatedb(self):
self.logger.debug("Update stat database")
2013-05-23 10:19:43 +02:00
self.db.execute("alter table stats add column TYPE TEXT;")
self.db.execute("update stats set TYPE='BACKUP';")
self.db.commit()
2013-05-23 10:19:43 +02:00
def initdb(self):
assert isinstance(self.db, sqlite3.Connection)
self.logger.debug("Initialize stat database")
2013-05-23 10:19:43 +02:00
self.db.execute("""
create table stats (
backup_name TEXT,
server_name TEXT,
description TEXT,
backup_start TEXT,
backup_end TEXT,
backup_duration NUMERIC,
total_files_count INT,
written_files_count INT,
total_bytes INT,
written_bytes INT,
status TEXT,
log TEXT,
backup_location TEXT,
TYPE TEXT)""")
self.db.execute("""
create index idx_stats_backup_name on stats(backup_name);""")
self.db.execute("""
create index idx_stats_backup_location on stats(backup_location);""")
self.db.execute("""
CREATE INDEX idx_stats_backup_name_start on stats(backup_name,backup_start);""")
self.db.commit()
2013-05-23 10:19:43 +02:00
def start(self, backup_name, server_name, TYPE, description="", backup_location=None):
"""Add in stat DB a record for the newly running backup"""
return self.add(
backup_name=backup_name,
server_name=server_name,
description=description,
backup_start=datetime2isodate(),
status="Running",
TYPE=TYPE,
)
def finish(
self,
rowid,
total_files_count=None,
written_files_count=None,
total_bytes=None,
written_bytes=None,
log=None,
status="OK",
backup_end=None,
backup_duration=None,
backup_location=None,
):
"""Update record in stat DB for the finished backup"""
2013-05-23 10:19:43 +02:00
if not backup_end:
backup_end = datetime2isodate()
if backup_duration is None:
2013-05-23 10:19:43 +02:00
try:
# get duration using start of backup datetime
backup_duration = (
isodate2datetime(backup_end)
- isodate2datetime(self.query("select backup_start from stats where rowid=?", (rowid,))[0]["backup_start"])
).seconds / 3600.0
2013-05-23 10:19:43 +02:00
except:
backup_duration = None
# update stat record
self.db.execute(
"""\
update stats set
2013-05-23 10:19:43 +02:00
total_files_count=?,written_files_count=?,total_bytes=?,written_bytes=?,log=?,status=?,backup_end=?,backup_duration=?,backup_location=?
where
rowid = ?
""",
(
total_files_count,
written_files_count,
total_bytes,
written_bytes,
log,
status,
backup_end,
backup_duration,
backup_location,
rowid,
),
)
2013-05-23 10:19:43 +02:00
self.db.commit()
def add(
self,
backup_name="",
server_name="",
description="",
backup_start=None,
backup_end=None,
backup_duration=None,
total_files_count=None,
written_files_count=None,
total_bytes=None,
written_bytes=None,
status="draft",
log="",
TYPE="",
backup_location=None,
):
2013-05-23 10:19:43 +02:00
if not backup_start:
backup_start = datetime2isodate()
2013-05-23 10:19:43 +02:00
if not backup_end:
backup_end = datetime2isodate()
cur = self.db.execute(
"""\
2013-05-23 10:19:43 +02:00
insert into stats (
backup_name,
server_name,
description,
backup_start,
backup_end,
backup_duration,
total_files_count,
written_files_count,
total_bytes,
written_bytes,
status,
log,
backup_location,
TYPE) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
backup_name,
server_name,
description,
backup_start,
backup_end,
backup_duration,
total_files_count,
written_files_count,
total_bytes,
written_bytes,
status,
log,
backup_location,
TYPE,
),
)
2013-05-23 10:19:43 +02:00
self.db.commit()
return cur.lastrowid
def query(self, query, args=(), one=False):
2013-05-23 10:19:43 +02:00
"""
execute la requete query sur la db et renvoie un tableau de dictionnaires
"""
cur = self.db.execute(query, args)
rv = [dict((cur.description[idx][0], value) for idx, value in enumerate(row)) for row in cur.fetchall()]
2013-05-23 10:19:43 +02:00
return (rv[0] if rv else None) if one else rv
def last_backups(self, backup_name, count=30):
2013-05-23 10:19:43 +02:00
if backup_name:
cur = self.db.execute("select * from stats where backup_name=? order by backup_end desc limit ?", (backup_name, count))
2013-05-23 10:19:43 +02:00
else:
cur = self.db.execute("select * from stats order by backup_end desc limit ?", (count,))
2013-05-23 10:19:43 +02:00
def fcb(fieldname, value):
if fieldname in ("backup_start", "backup_end"):
2013-05-23 10:19:43 +02:00
return time2display(isodate2datetime(value))
elif "bytes" in fieldname:
2013-05-23 10:19:43 +02:00
return convert_bytes(value)
elif "count" in fieldname:
return splitThousands(value, " ", ".")
elif "backup_duration" in fieldname:
2013-05-23 10:19:43 +02:00
return hours_minutes(value)
else:
return value
# for r in self.query('select * from stats where backup_name=? order by backup_end desc limit ?',(backup_name,count)):
print((pp(cur, None, 1, fcb)))
2013-05-23 10:19:43 +02:00
def fcb(self, fields, fieldname, value):
if fieldname in ("backup_start", "backup_end"):
2013-05-23 10:19:43 +02:00
return time2display(isodate2datetime(value))
elif "bytes" in fieldname:
2013-05-23 10:19:43 +02:00
return convert_bytes(value)
elif "count" in fieldname:
return splitThousands(value, " ", ".")
elif "backup_duration" in fieldname:
2013-05-23 10:19:43 +02:00
return hours_minutes(value)
else:
return value
def as_html(self, cur):
2013-05-23 10:19:43 +02:00
if cur:
return html_table(cur, self.fcb)
2013-05-23 10:19:43 +02:00
else:
return html_table(self.db.execute("select * from stats order by backup_start asc"), self.fcb)
2013-05-23 10:19:43 +02:00
def ssh_exec(command, ssh=None, server_name="", remote_user="", private_key="", ssh_port=22):
"""execute command on server_name using the provided ssh connection
or creates a new connection if ssh is not provided.
returns (exit_code,output)
2013-05-23 10:19:43 +02:00
output is the concatenation of stdout and stderr
2013-05-23 10:19:43 +02:00
"""
if not ssh:
assert server_name and remote_user and private_key
2013-05-23 10:19:43 +02:00
try:
mykey = paramiko.RSAKey.from_private_key_file(private_key)
except paramiko.SSHException:
# mykey = paramiko.DSSKey.from_private_key_file(private_key)
mykey = paramiko.Ed25519Key.from_private_key_file(private_key)
2013-05-23 10:19:43 +02:00
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(server_name, username=remote_user, pkey=mykey, port=ssh_port)
2013-05-23 10:19:43 +02:00
tran = ssh.get_transport()
chan = tran.open_session()
# chan.set_combine_stderr(True)
chan.get_pty()
stdout = chan.makefile()
chan.exec_command(command)
stdout.flush()
2022-04-25 10:02:43 +02:00
output_base = stdout.read()
output = output_base.decode(errors="ignore").replace("'", "")
2013-05-23 10:19:43 +02:00
exit_code = chan.recv_exit_status()
return (exit_code, output)
2013-05-23 10:19:43 +02:00
2022-04-25 10:02:43 +02:00
class backup_generic(ABC):
2013-05-23 10:19:43 +02:00
"""Generic ancestor class for backups, not registered"""
type = "generic"
required_params = ["type", "backup_name", "backup_dir", "server_name", "backup_retention_time", "maximum_backup_age"]
optional_params = ["preexec", "postexec", "description", "private_key", "remote_user", "ssh_port"]
logger = logging.getLogger("tisbackup")
backup_name = ""
backup_dir = ""
server_name = ""
remote_user = "root"
description = ""
2013-05-23 10:19:43 +02:00
dbstat = None
dry_run = False
preexec = ""
postexec = ""
2013-05-23 10:19:43 +02:00
maximum_backup_age = None
backup_retention_time = None
verbose = False
private_key = ""
ssh_port = 22
2013-05-23 10:19:43 +02:00
def __init__(self, backup_name, backup_dir, dbstat=None, dry_run=False):
if not re.match("^[A-Za-z0-9_\-\.]*$", backup_name):
raise Exception("The backup name %s should contain only alphanumerical characters" % backup_name)
2013-05-23 10:19:43 +02:00
self.backup_name = backup_name
self.backup_dir = backup_dir
self.dbstat = dbstat
assert isinstance(self.dbstat, BackupStat) or self.dbstat is None
2013-05-23 10:19:43 +02:00
if not os.path.isdir(self.backup_dir):
os.makedirs(self.backup_dir)
self.dry_run = dry_run
@classmethod
def get_help(cls):
return """\
%(type)s : %(desc)s
Required params : %(required)s
Optional params : %(optional)s
""" % {"type": cls.type, "desc": cls.__doc__, "required": ",".join(cls.required_params), "optional": ",".join(cls.optional_params)}
2013-05-23 10:19:43 +02:00
def check_required_params(self):
for name in self.required_params:
if not hasattr(self, name) or not getattr(self, name):
raise Exception("[%s] Config Attribute %s is required" % (self.backup_name, name))
2013-05-23 10:19:43 +02:00
if (self.preexec or self.postexec) and (not self.private_key or not self.remote_user):
raise Exception("[%s] remote_user and private_key file required if preexec or postexec is used" % self.backup_name)
def read_config(self, iniconf):
assert isinstance(iniconf, ConfigParser)
allowed_params = self.required_params + self.optional_params
for name, value in iniconf.items(self.backup_name):
if name not in allowed_params:
self.logger.critical('[%s] Invalid param name "%s"', self.backup_name, name)
raise Exception('[%s] Invalid param name "%s"', self.backup_name, name)
self.logger.debug("[%s] reading param %s = %s ", self.backup_name, name, value)
setattr(self, name, value)
2013-05-23 10:19:43 +02:00
# if retention (in days) is not defined at section level, get default global one.
if not self.backup_retention_time:
self.backup_retention_time = iniconf.getint("global", "backup_retention_time")
2013-05-23 10:19:43 +02:00
# for nagios, if maximum last backup age (in hours) is not defined at section level, get default global one.
if not self.maximum_backup_age:
self.maximum_backup_age = iniconf.getint("global", "maximum_backup_age")
2013-05-23 10:19:43 +02:00
self.ssh_port = int(self.ssh_port)
self.backup_retention_time = int(self.backup_retention_time)
self.maximum_backup_age = int(self.maximum_backup_age)
self.check_required_params()
def do_preexec(self, stats):
self.logger.info("[%s] executing preexec %s ", self.backup_name, self.preexec)
2013-05-23 10:19:43 +02:00
try:
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
except paramiko.SSHException:
mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(self.server_name, username=self.remote_user, pkey=mykey)
2013-05-23 10:19:43 +02:00
tran = ssh.get_transport()
chan = tran.open_session()
# chan.set_combine_stderr(True)
chan.get_pty()
stdout = chan.makefile()
if not self.dry_run:
chan.exec_command(self.preexec)
output = stdout.read()
exit_code = chan.recv_exit_status()
self.logger.info('[%s] preexec exit code : "%i", output : %s', self.backup_name, exit_code, output)
2013-05-23 10:19:43 +02:00
return exit_code
else:
return 0
def do_postexec(self, stats):
self.logger.info("[%s] executing postexec %s ", self.backup_name, self.postexec)
2013-05-23 10:19:43 +02:00
try:
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
except paramiko.SSHException:
mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(self.server_name, username=self.remote_user, pkey=mykey)
2013-05-23 10:19:43 +02:00
tran = ssh.get_transport()
chan = tran.open_session()
# chan.set_combine_stderr(True)
chan.get_pty()
stdout = chan.makefile()
if not self.dry_run:
chan.exec_command(self.postexec)
output = stdout.read()
exit_code = chan.recv_exit_status()
self.logger.info('[%s] postexec exit code : "%i", output : %s', self.backup_name, exit_code, output)
2013-05-23 10:19:43 +02:00
return exit_code
else:
return 0
def do_backup(self, stats):
2013-05-23 10:19:43 +02:00
"""stats dict with keys : total_files_count,written_files_count,total_bytes,written_bytes"""
pass
def check_params_connections(self):
"""Perform a dry run trying to connect without actually doing backup"""
self.check_required_params()
def process_backup(self):
"""Process the backup.
launch
- do_preexec
- do_backup
- do_postexec
2013-05-23 10:19:43 +02:00
returns a dict for stats
2013-05-23 10:19:43 +02:00
"""
self.logger.info("[%s] ######### Starting backup", self.backup_name)
2013-05-23 10:19:43 +02:00
starttime = time.time()
self.backup_start_date = datetime.datetime.now().strftime("%Y%m%d-%Hh%Mm%S")
2013-05-23 10:19:43 +02:00
if not self.dry_run and self.dbstat:
stat_rowid = self.dbstat.start(backup_name=self.backup_name, server_name=self.server_name, TYPE="BACKUP")
2013-05-23 10:19:43 +02:00
else:
stat_rowid = None
try:
stats = {}
stats["total_files_count"] = 0
stats["written_files_count"] = 0
stats["total_bytes"] = 0
stats["written_bytes"] = 0
stats["log"] = ""
stats["status"] = "Running"
stats["backup_location"] = None
2013-05-23 10:19:43 +02:00
if self.preexec.strip():
exit_code = self.do_preexec(stats)
if exit_code != 0:
raise Exception('Preexec "%s" failed with exit code "%i"' % (self.preexec, exit_code))
2013-05-23 10:19:43 +02:00
self.do_backup(stats)
if self.postexec.strip():
exit_code = self.do_postexec(stats)
if exit_code != 0:
raise Exception('Postexec "%s" failed with exit code "%i"' % (self.postexec, exit_code))
2013-05-23 10:19:43 +02:00
endtime = time.time()
duration = (endtime - starttime) / 3600.0
2013-05-23 10:19:43 +02:00
if not self.dry_run and self.dbstat:
self.dbstat.finish(
stat_rowid,
backup_end=datetime2isodate(datetime.datetime.now()),
backup_duration=duration,
total_files_count=stats["total_files_count"],
written_files_count=stats["written_files_count"],
total_bytes=stats["total_bytes"],
written_bytes=stats["written_bytes"],
status=stats["status"],
log=stats["log"],
backup_location=stats["backup_location"],
)
self.logger.info("[%s] ######### Backup finished : %s", self.backup_name, stats["log"])
2013-05-23 10:19:43 +02:00
return stats
2022-04-25 10:02:43 +02:00
except BaseException as e:
stats["status"] = "ERROR"
stats["log"] = str(e)
2013-05-23 10:19:43 +02:00
endtime = time.time()
duration = (endtime - starttime) / 3600.0
2013-05-23 10:19:43 +02:00
if not self.dry_run and self.dbstat:
self.dbstat.finish(
stat_rowid,
backup_end=datetime2isodate(datetime.datetime.now()),
backup_duration=duration,
total_files_count=stats["total_files_count"],
written_files_count=stats["written_files_count"],
total_bytes=stats["total_bytes"],
written_bytes=stats["written_bytes"],
status=stats["status"],
log=stats["log"],
backup_location=stats["backup_location"],
)
self.logger.error("[%s] ######### Backup finished with ERROR: %s", self.backup_name, stats["log"])
raise
2013-05-23 10:19:43 +02:00
def checknagios(self):
2013-05-23 10:19:43 +02:00
"""
Returns a tuple (nagiosstatus,message) for the current backup_name
Read status from dbstat database
"""
if not self.dbstat:
self.logger.warn("[%s] checknagios : no database provided", self.backup_name)
return ("No database provided", nagiosStateUnknown)
2013-05-23 10:19:43 +02:00
else:
self.logger.debug(
'[%s] checknagios : sql query "%s" %s',
self.backup_name,
"select status, backup_end, log from stats where TYPE='BACKUP' AND backup_name=? order by backup_end desc limit 30",
self.backup_name,
)
q = self.dbstat.query(
"select status, backup_start, backup_end, log, backup_location, total_bytes from stats where TYPE='BACKUP' AND backup_name=? order by backup_start desc limit 30",
(self.backup_name,),
)
2013-05-23 10:19:43 +02:00
if not q:
self.logger.debug("[%s] checknagios : no result from query", self.backup_name)
return (nagiosStateCritical, "CRITICAL : No backup found for %s in database" % self.backup_name)
2013-05-23 10:19:43 +02:00
else:
mindate = datetime2isodate((datetime.datetime.now() - datetime.timedelta(hours=self.maximum_backup_age)))
self.logger.debug("[%s] checknagios : looking for most recent OK not older than %s", self.backup_name, mindate)
2013-05-23 10:19:43 +02:00
for b in q:
if b["backup_end"] >= mindate and b["status"] == "OK":
2013-05-23 10:19:43 +02:00
# check if backup actually exists on registered backup location and is newer than backup start date
if b["total_bytes"] == 0:
return (nagiosStateWarning, "WARNING : No data to backup was found for %s" % (self.backup_name,))
if not b["backup_location"]:
return (
nagiosStateWarning,
"WARNING : No Backup location found for %s finished on (%s) %s"
% (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
)
if os.path.isfile(b["backup_location"]):
backup_actual_date = datetime.datetime.fromtimestamp(os.stat(b["backup_location"]).st_ctime)
if backup_actual_date + datetime.timedelta(hours=1) > isodate2datetime(b["backup_start"]):
return (
nagiosStateOk,
"OK Backup %s (%s), %s" % (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
)
2013-05-23 10:19:43 +02:00
else:
return (
nagiosStateCritical,
"CRITICAL Backup %s (%s), %s seems older than start of backup"
% (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
)
elif os.path.isdir(b["backup_location"]):
return (
nagiosStateOk,
"OK Backup %s (%s), %s" % (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
)
elif self.type == "copy-vm-xcp":
return (
nagiosStateOk,
"OK Backup %s (%s), %s" % (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
)
2013-05-23 10:19:43 +02:00
else:
return (
nagiosStateCritical,
"CRITICAL Backup %s (%s), %s has disapeared from backup location %s"
% (self.backup_name, isodate2datetime(b["backup_end"]), b["log"], b["backup_location"]),
)
self.logger.debug(
"[%s] checknagios : looking for most recent Warning or Running not older than %s", self.backup_name, mindate
)
2013-05-23 10:19:43 +02:00
for b in q:
if b["backup_end"] >= mindate and b["status"] in ("Warning", "Running"):
return (nagiosStateWarning, "WARNING : Backup %s still running or warning. %s" % (self.backup_name, b["log"]))
2013-05-23 10:19:43 +02:00
self.logger.debug("[%s] checknagios : No Ok or warning recent backup found", self.backup_name)
return (nagiosStateCritical, "CRITICAL : No recent backup for %s" % self.backup_name)
2013-05-23 10:19:43 +02:00
def cleanup_backup(self):
"""Removes obsolete backups (older than backup_retention_time)"""
mindate = datetime2isodate((dateof(datetime.datetime.now()) - datetime.timedelta(days=self.backup_retention_time)))
# check if there is at least 1 "OK" backup left after cleanup :
ok_backups = self.dbstat.query(
'select backup_location from stats where TYPE="BACKUP" and backup_name=? and backup_start>=? and status="OK" order by backup_start desc',
(self.backup_name, mindate),
)
2013-05-23 10:19:43 +02:00
removed = []
if ok_backups and os.path.exists(ok_backups[0]["backup_location"]):
records = self.dbstat.query(
'select status, backup_start, backup_end, log, backup_location from stats where backup_name=? and backup_start<? and backup_location is not null and TYPE="BACKUP" order by backup_start',
(self.backup_name, mindate),
)
2013-05-23 10:19:43 +02:00
if records:
for oldbackup_location in [rec["backup_location"] for rec in records if rec["backup_location"]]:
2013-05-23 10:19:43 +02:00
try:
if os.path.isdir(oldbackup_location) and self.backup_dir in oldbackup_location:
self.logger.info('[%s] removing directory "%s"', self.backup_name, oldbackup_location)
2013-05-23 10:19:43 +02:00
if not self.dry_run:
if self.type == "rsync+btrfs+ssh" or self.type == "rsync+btrfs":
cmd = "/bin/btrfs subvolume delete %s" % oldbackup_location
process = subprocess.Popen(
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True
)
log = monitor_stdout(process, "", self)
returncode = process.returncode
if returncode != 0:
self.logger.error("[" + self.backup_name + "] shell program exited with error code: %s" % log)
raise Exception(
"[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd
)
else:
self.logger.info(
"[" + self.backup_name + "] deleting snapshot volume: %s" % oldbackup_location.encode("ascii")
)
else:
shutil.rmtree(oldbackup_location.encode("ascii"))
if os.path.isfile(oldbackup_location) and self.backup_dir in oldbackup_location:
self.logger.debug('[%s] removing file "%s"', self.backup_name, oldbackup_location)
2013-05-23 10:19:43 +02:00
if not self.dry_run:
os.remove(oldbackup_location)
self.logger.debug('Cleanup_backup : Removing records from DB : [%s]-"%s"', self.backup_name, oldbackup_location)
2013-05-23 10:19:43 +02:00
if not self.dry_run:
self.dbstat.db.execute(
'update stats set TYPE="CLEAN" where backup_name=? and backup_location=?',
(self.backup_name, oldbackup_location),
)
2013-05-23 10:19:43 +02:00
self.dbstat.db.commit()
2022-04-25 10:02:43 +02:00
except BaseException as e:
self.logger.error('cleanup_backup : Unable to remove directory/file "%s". Error %s', oldbackup_location, e)
removed.append((self.backup_name, oldbackup_location))
2013-05-23 10:19:43 +02:00
else:
self.logger.debug("[%s] cleanup : no result for query", self.backup_name)
2013-05-23 10:19:43 +02:00
else:
self.logger.info("Nothing to do because we want to keep at least one OK backup after cleaning")
2013-05-23 10:19:43 +02:00
self.logger.info(
"[%s] Cleanup finished : removed : %s", self.backup_name, ",".join([('[%s]-"%s"') % r for r in removed]) or "Nothing"
)
2013-05-23 10:19:43 +02:00
return removed
2022-04-25 10:02:43 +02:00
@abstractmethod
2013-05-23 10:19:43 +02:00
def register_existingbackups(self):
2022-04-25 10:02:43 +02:00
pass
# """scan existing backups and insert stats in database"""
# registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',[self.backup_name])]
# raise Exception('Abstract method')
def export_latestbackup(self, destdir):
"""Copy (rsync) latest OK backup to external storage located at locally mounted "destdir" """
2013-05-23 10:19:43 +02:00
stats = {}
stats["total_files_count"] = 0
stats["written_files_count"] = 0
stats["total_bytes"] = 0
stats["written_bytes"] = 0
stats["log"] = ""
stats["status"] = "Running"
2013-05-23 10:19:43 +02:00
if not self.dbstat:
self.logger.critical("[%s] export_latestbackup : no database provided", self.backup_name)
raise Exception("No database")
2013-05-23 10:19:43 +02:00
else:
latest_sql = """\
select status, backup_start, backup_end, log, backup_location, total_bytes
from stats
2013-05-23 10:19:43 +02:00
where backup_name=? and status='OK' and TYPE='BACKUP'
order by backup_start desc limit 30"""
self.logger.debug('[%s] export_latestbackup : sql query "%s" %s', self.backup_name, latest_sql, self.backup_name)
q = self.dbstat.query(latest_sql, (self.backup_name,))
2013-05-23 10:19:43 +02:00
if not q:
self.logger.debug("[%s] export_latestbackup : no result from query", self.backup_name)
raise Exception("No OK backup found for %s in database" % self.backup_name)
2013-05-23 10:19:43 +02:00
else:
latest = q[0]
backup_source = latest["backup_location"]
backup_dest = os.path.join(os.path.abspath(destdir), self.backup_name)
2013-05-23 10:19:43 +02:00
if not os.path.exists(backup_source):
raise Exception("Backup source %s doesn't exists" % backup_source)
2013-05-23 10:19:43 +02:00
# ensure there is a slash at end
if os.path.isdir(backup_source) and backup_source[-1] != "/":
backup_source += "/"
if backup_dest[-1] != "/":
backup_dest += "/"
2013-05-23 10:19:43 +02:00
if not os.path.isdir(backup_dest):
os.makedirs(backup_dest)
options = ["-aP", "--stats", "--delete-excluded", "--numeric-ids", "--delete-after"]
2013-05-23 10:19:43 +02:00
if self.logger.level:
options.append("-P")
2013-05-23 10:19:43 +02:00
if self.dry_run:
options.append("-d")
2013-05-23 10:19:43 +02:00
options_params = " ".join(options)
cmd = "/usr/bin/rsync %s %s %s 2>&1" % (options_params, backup_source, backup_dest)
self.logger.debug("[%s] rsync : %s", self.backup_name, cmd)
2013-05-23 10:19:43 +02:00
if not self.dry_run:
self.line = ""
2013-05-23 10:19:43 +02:00
starttime = time.time()
stat_rowid = self.dbstat.start(backup_name=self.backup_name, server_name=self.server_name, TYPE="EXPORT")
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
2013-05-23 10:19:43 +02:00
def ondata(data, context):
2013-05-23 10:19:43 +02:00
if context.verbose:
2022-04-25 10:02:43 +02:00
print(data)
2013-05-23 10:19:43 +02:00
context.logger.debug(data)
log = monitor_stdout(process, ondata, self)
2013-05-23 10:19:43 +02:00
for l in log.splitlines():
if l.startswith("Number of files:"):
stats["total_files_count"] += int(re.findall("[0-9]+", l.split(":")[1])[0])
if l.startswith("Number of files transferred:"):
stats["written_files_count"] += int(l.split(":")[1])
if l.startswith("Total file size:"):
stats["total_bytes"] += float(l.replace(",", "").split(":")[1].split()[0])
if l.startswith("Total transferred file size:"):
stats["written_bytes"] += float(l.replace(",", "").split(":")[1].split()[0])
2013-05-23 10:19:43 +02:00
returncode = process.returncode
## deal with exit code 24 (file vanished)
if returncode == 24:
2013-05-23 10:19:43 +02:00
self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer")
elif returncode == 23:
2013-05-23 10:19:43 +02:00
self.logger.warning("[" + self.backup_name + "] unable so set uid on some files")
elif returncode != 0:
2013-05-23 10:19:43 +02:00
self.logger.error("[" + self.backup_name + "] shell program exited with error code ")
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd)
else:
2022-04-25 10:02:43 +02:00
print(cmd)
2013-05-23 10:19:43 +02:00
stats["status"] = "OK"
self.logger.info(
"export backup from %s to %s OK, %d bytes written for %d changed files"
% (backup_source, backup_dest, stats["written_bytes"], stats["written_files_count"])
)
2013-05-23 10:19:43 +02:00
endtime = time.time()
duration = (endtime - starttime) / 3600.0
2013-05-23 10:19:43 +02:00
if not self.dry_run and self.dbstat:
self.dbstat.finish(
stat_rowid,
backup_end=datetime2isodate(datetime.datetime.now()),
backup_duration=duration,
total_files_count=stats["total_files_count"],
written_files_count=stats["written_files_count"],
total_bytes=stats["total_bytes"],
written_bytes=stats["written_bytes"],
status=stats["status"],
log=stats["log"],
backup_location=backup_dest,
)
2013-05-23 10:19:43 +02:00
return stats
if __name__ == "__main__":
logger = logging.getLogger("tisbackup")
2013-05-23 10:19:43 +02:00
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
2013-05-23 10:19:43 +02:00
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)
dbstat = BackupStat("/backup/data/log/tisbackup.sqlite")