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/>.
#
# -----------------------------------------------------------------------
2022-04-25 10:02:43 +02:00
from abc import ABC , abstractmethod
2013-05-23 10:19:43 +02:00
import os
import subprocess
import re
import logging
import datetime
import time
from iniparse import ConfigParser
import sqlite3
import shutil
import select
import sys
try :
sys . stderr = open ( ' /dev/null ' ) # Silence silly warnings from paramiko
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 = { }
def register_driver ( driverclass ) :
backup_drivers [ driverclass . type ] = driverclass
def datetime2isodate ( adatetime = None ) :
if not adatetime :
adatetime = datetime . datetime . now ( )
assert ( isinstance ( adatetime , datetime . datetime ) )
return adatetime . isoformat ( )
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- %d T % H: % M: % S " )
def time2display ( adatetime ) :
return adatetime . strftime ( " % Y- % m- %d % H: % M " )
def hours_minutes ( hours ) :
if hours is None :
return None
else :
return " %02i : %02i " % ( int ( hours ) , int ( ( hours - int ( hours ) ) * 60.0 ) )
def fileisodate ( filename ) :
return datetime . datetime . fromtimestamp ( os . stat ( filename ) . st_mtime ) . isoformat ( )
def dateof ( adatetime ) :
return adatetime . replace ( hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
#####################################
# 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
2018-01-30 12:29:16 +01:00
#
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 == None :
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 == ' ' :
cnt = - 1
else :
cnt = s . rfind ( dSep )
if cnt > 0 :
rhs = dSep + s [ cnt + 1 : ]
s = s [ : cnt ]
else :
rhs = ' '
splt = ' '
while s != ' ' :
splt = s [ - 3 : ] + tSep + splt
s = s [ : - 3 ]
return lhs + splt [ : - 1 ] + rhs
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 )
def check_string ( test_string ) :
pattern = r ' [^ \ .A-Za-z0-9 \ -_] '
if re . search ( pattern , test_string ) :
#Character other then . a-z 0-9 was found
2022-04-25 10:02:43 +02:00
print ( ( ' Invalid : %r ' % ( test_string , ) ) )
2013-05-23 10:19:43 +02:00
def convert_bytes ( bytes ) :
if bytes is None :
return None
2018-01-30 12:29:16 +01:00
else :
2013-05-23 10:19:43 +02:00
bytes = float ( bytes )
if bytes > = 1099511627776 :
terabytes = bytes / 1099511627776
size = ' %.2f T ' % terabytes
elif bytes > = 1073741824 :
gigabytes = bytes / 1073741824
size = ' %.2f G ' % gigabytes
elif bytes > = 1048576 :
megabytes = bytes / 1048576
size = ' %.2f M ' % megabytes
elif bytes > = 1024 :
kilobytes = bytes / 1024
size = ' %.2f K ' % kilobytes
else :
size = ' %.2f b ' % bytes
2018-01-30 12:29:16 +01:00
return size
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 ) :
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
l = dd [ 1 ]
if not l :
l = 12 # or default arg ...
l = max ( l , len ( dd [ 0 ] ) ) # handle long names
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 ] )
format = " " . join ( [ " %% - %s s " % l for l in lengths ] )
result = [ format % tuple ( names ) ]
result . append ( format % tuple ( rules ) )
for row in data :
row_cb = [ ]
for col in range ( len ( d ) ) :
row_cb . append ( callback ( d [ col ] [ 0 ] , row [ col ] ) )
result . append ( format % tuple ( row_cb ) )
return " \n " . join ( result )
## end of http://code.activestate.com/recipes/81189/ }}}
def html_table ( cur , callback = None ) :
"""
cur est un cursor issu d ' une requete
callback est une fonction qui prend ( rowmap , fieldname , value )
et renvoie une representation texte
"""
def safe_unicode ( iso ) :
if iso is None :
return None
elif isinstance ( iso , str ) :
return iso . decode ( ' iso8859 ' )
else :
return iso
def itermap ( cur ) :
for row in cur :
yield dict ( ( cur . description [ idx ] [ 0 ] , value )
for idx , value in enumerate ( row ) )
2022-04-25 10:02:43 +02:00
head = " <tr> " + " " . join ( [ " <th> " + c [ 0 ] + " </th> " for c in cur . description ] ) + " </tr> "
2013-05-23 10:19:43 +02:00
lines = " "
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> "
else :
for r in cur :
lines = lines + " <tr> " + " " . join ( [ " <td> " + safe_unicode ( c ) + " </td> " for c in r ] ) + " </tr> "
return " <table border=1 cellpadding=2 cellspacing=0> %s %s </table> " % ( head , lines )
2018-01-30 12:29:16 +01:00
2013-05-23 10:19:43 +02:00
def monitor_stdout ( aprocess , onoutputdata , context ) :
""" Reads data from stdout and stderr from aprocess and return as a string
on each chunk , call a call back onoutputdata ( dataread )
"""
assert ( isinstance ( aprocess , subprocess . Popen ) )
read_set = [ ]
stdout = [ ]
line = ' '
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 )
2022-04-25 10:02:43 +02:00
data = data . decode ( ' utf-8 ' )
2013-05-23 10:19:43 +02:00
if data == " " :
aprocess . stdout . close ( )
read_set . remove ( aprocess . stdout )
while data and not data in ( ' \n ' , ' \r ' ) :
line + = data
data = os . read ( aprocess . stdout . fileno ( ) , 1 )
2022-04-25 10:02:43 +02:00
data = data . decode ( ' utf-8 ' )
2013-05-23 10:19:43 +02:00
if line or data in ( ' \n ' , ' \r ' ) :
stdout . append ( line )
if onoutputdata :
onoutputdata ( line , context )
line = ' '
# Reads one line from stderr
if aprocess . stderr in rlist :
data = os . read ( aprocess . stderr . fileno ( ) , 1 )
2022-04-25 10:02:43 +02:00
data = data . decode ( ' utf-8 ' )
2013-05-23 10:19:43 +02:00
if data == " " :
aprocess . stderr . close ( )
read_set . remove ( aprocess . stderr )
while data and not data in ( ' \n ' , ' \r ' ) :
line + = data
data = os . read ( aprocess . stderr . fileno ( ) , 1 )
2022-04-25 10:02:43 +02:00
data = data . decode ( ' utf-8 ' )
2013-05-23 10:19:43 +02:00
if line or data in ( ' \n ' , ' \r ' ) :
stdout . append ( line )
if onoutputdata :
onoutputdata ( line , context )
line = ' '
aprocess . wait ( )
if line :
stdout . append ( line )
if onoutputdata :
onoutputdata ( line , context )
return " \n " . join ( stdout )
2014-11-20 15:59:29 +01:00
def str2bool ( val ) :
if type ( val ) != bool :
return val . lower ( ) in ( " yes " , " true " , " t " , " 1 " )
2013-05-23 10:19:43 +02:00
class BackupStat :
dbpath = ' '
db = None
logger = logging . getLogger ( ' tisbackup ' )
def __init__ ( self , dbpath ) :
self . dbpath = dbpath
if not os . path . isfile ( self . dbpath ) :
self . db = sqlite3 . connect ( self . dbpath )
self . initdb ( )
else :
2018-01-30 12:29:16 +01:00
self . db = sqlite3 . connect ( self . dbpath , check_same_thread = False )
2013-05-23 10:19:43 +02:00
if not " ' TYPE ' " in str ( self . db . execute ( " select * from stats " ) . description ) :
self . updatedb ( )
2018-01-30 12:29:16 +01:00
2013-05-23 10:19:43 +02:00
def updatedb ( self ) :
self . logger . debug ( ' Update stat database ' )
self . db . execute ( " alter table stats add column TYPE TEXT; " )
self . db . execute ( " update stats set TYPE= ' BACKUP ' ; " )
self . db . commit ( )
2018-01-30 12:29:16 +01:00
2013-05-23 10:19:43 +02:00
def initdb ( self ) :
assert ( isinstance ( self . db , sqlite3 . Connection ) )
self . logger . debug ( ' Initialize stat database ' )
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 ) ; """ )
2014-07-25 15:06:51 +02:00
self . db . execute ( """
CREATE INDEX idx_stats_backup_name_start on stats ( backup_name , backup_start ) ; """ )
2018-01-30 12:29:16 +01:00
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 """
if not backup_end :
backup_end = datetime2isodate ( )
if backup_duration == None :
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
except :
backup_duration = None
# update stat record
self . db . execute ( """ \
2018-01-30 12:29:16 +01:00
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))
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 ) :
if not backup_start :
backup_start = datetime2isodate ( )
if not backup_end :
backup_end = datetime2isodate ( )
2018-01-30 12:29:16 +01:00
2013-05-23 10:19:43 +02:00
cur = self . db . execute ( """ \
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 ,
2018-01-30 12:29:16 +01:00
TYPE )
2013-05-23 10:19:43 +02:00
)
self . db . commit ( )
return cur . lastrowid
def query ( self , query , args = ( ) , one = False ) :
"""
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 ( ) ]
return ( rv [ 0 ] if rv else None ) if one else rv
def last_backups ( self , backup_name , count = 30 ) :
if backup_name :
cur = self . db . execute ( ' select * from stats where backup_name=? order by backup_end desc limit ? ' , ( backup_name , count ) )
else :
cur = self . db . execute ( ' select * from stats order by backup_end desc limit ? ' , ( count , ) )
def fcb ( fieldname , value ) :
if fieldname in ( ' backup_start ' , ' backup_end ' ) :
return time2display ( isodate2datetime ( value ) )
elif ' bytes ' in fieldname :
return convert_bytes ( value )
elif ' count ' in fieldname :
return splitThousands ( value , ' ' , ' . ' )
elif ' backup_duration ' in fieldname :
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)):
2022-04-25 10:02:43 +02:00
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 ' ) :
return time2display ( isodate2datetime ( value ) )
elif ' bytes ' in fieldname :
return convert_bytes ( value )
elif ' count ' in fieldname :
return splitThousands ( value , ' ' , ' . ' )
elif ' backup_duration ' in fieldname :
return hours_minutes ( value )
else :
return value
def as_html ( self , cur ) :
if cur :
return html_table ( cur , self . fcb )
else :
return html_table ( self . db . execute ( ' select * from stats order by backup_start asc ' ) , self . fcb )
def ssh_exec ( command , ssh = None , server_name = ' ' , remote_user = ' ' , private_key = ' ' , ssh_port = 22 ) :
2018-01-30 12:29:16 +01:00
""" execute command on server_name using the provided ssh connection
2013-05-23 10:19:43 +02:00
or creates a new connection if ssh is not provided .
returns ( exit_code , output )
output is the concatenation of stdout and stderr
"""
if not ssh :
assert ( server_name and remote_user and private_key )
try :
mykey = paramiko . RSAKey . from_private_key_file ( private_key )
except paramiko . SSHException :
2020-08-10 10:02:46 +02:00
#mykey = paramiko.DSSKey.from_private_key_file(private_key)
mykey = paramiko . Ed25519Key . from_private_key_file ( self . private_key )
2013-05-23 10:19:43 +02:00
ssh = paramiko . SSHClient ( )
ssh . set_missing_host_key_policy ( paramiko . AutoAddPolicy ( ) )
2016-04-14 14:32:04 +02:00
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 ( encoding = ' UTF-8 ' ) . replace ( " ' " , " " )
2013-05-23 10:19:43 +02:00
exit_code = chan . recv_exit_status ( )
return ( exit_code , output )
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 = ' '
dbstat = None
dry_run = False
preexec = ' '
postexec = ' '
maximum_backup_age = None
backup_retention_time = None
verbose = False
private_key = ' '
ssh_port = 22
def __init__ ( self , backup_name , backup_dir , dbstat = None , dry_run = False ) :
if not re . match ( ' ^[A-Za-z0-9_ \ - \ .]*$ ' , backup_name ) :
2018-01-30 12:29:16 +01:00
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 == None )
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 ) }
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 ) )
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 not name 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 )
# if retention (in days) is not defined at section level, get default global one.
if not self . backup_retention_time :
2018-01-30 12:29:16 +01:00
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 ' )
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 )
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 )
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 )
return exit_code
else :
return 0
def do_postexec ( self , stats ) :
self . logger . info ( " [ %s ] executing postexec %s " , self . backup_name , self . postexec )
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 )
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 )
return exit_code
else :
return 0
def do_backup ( self , stats ) :
""" 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.
2018-01-30 12:29:16 +01:00
launch
2013-05-23 10:19:43 +02:00
- do_preexec
- do_backup
- do_postexec
returns a dict for stats
"""
self . logger . info ( ' [ %s ] ######### Starting backup ' , self . backup_name )
starttime = time . time ( )
self . backup_start_date = datetime . datetime . now ( ) . strftime ( ' % Y % m %d - % Hh % Mm % S ' )
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 " )
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
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 ) )
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 ) )
endtime = time . time ( )
duration = ( endtime - starttime ) / 3600.0
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 ' ] )
return stats
2022-04-25 10:02:43 +02:00
except BaseException as e :
2013-05-23 10:19:43 +02:00
stats [ ' status ' ] = ' ERROR '
stats [ ' log ' ] = str ( e )
endtime = time . time ( )
duration = ( endtime - starttime ) / 3600.0
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 ' ] )
2018-01-30 12:29:16 +01:00
raise
2013-05-23 10:19:43 +02:00
2018-01-30 12:29:16 +01: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 )
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 , ) )
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 )
else :
2018-01-30 12:29:16 +01:00
mindate = datetime2isodate ( ( datetime . datetime . now ( ) - datetime . timedelta ( hours = self . maximum_backup_age ) ) )
2013-05-23 10:19:43 +02:00
self . logger . debug ( ' [ %s ] checknagios : looking for most recent OK not older than %s ' , self . backup_name , mindate )
for b in q :
if b [ ' backup_end ' ] > = mindate and b [ ' status ' ] == ' OK ' :
# 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 ' ] ) )
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 ' ] ) )
2014-07-25 15:06:51 +02:00
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 )
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 ' ] ) )
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 )
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 ) )
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 ) )
if records :
for oldbackup_location in [ rec [ ' backup_location ' ] for rec in records if rec [ ' backup_location ' ] ] :
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 )
if not self . dry_run :
2014-07-25 15:06:51 +02:00
if self . type == " rsync+btrfs+ssh " or self . type == " rsync+btrfs " :
2020-06-11 11:42:30 +02:00
cmd = " /bin/btrfs subvolume delete %s " % oldbackup_location . encode ( ' ascii ' )
2014-07-25 15:06:51 +02:00
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 )
2018-01-30 12:29:16 +01:00
raise Exception ( " [ " + self . backup_name + " ] shell program exited with error code " + str ( returncode ) , cmd )
2014-07-25 15:06:51 +02:00
else :
2018-01-30 12:29:16 +01:00
self . logger . info ( " [ " + self . backup_name + " ] deleting snapshot volume: %s " % oldbackup_location . encode ( ' ascii ' ) )
2014-07-25 15:06:51 +02:00
else :
shutil . rmtree ( oldbackup_location . encode ( ' ascii ' ) )
2013-05-23 10:19:43 +02:00
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 )
if not self . dry_run :
2018-01-30 12:29:16 +01:00
os . remove ( oldbackup_location )
2013-05-23 10:19:43 +02:00
self . logger . debug ( ' Cleanup_backup : Removing records from DB : [ %s ]- " %s " ' , self . backup_name , oldbackup_location )
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 ) )
self . dbstat . db . commit ( )
2022-04-25 10:02:43 +02:00
except BaseException as e :
2013-05-23 10:19:43 +02:00
self . logger . error ( ' cleanup_backup : Unable to remove directory/file " %s " . Error %s ' , oldbackup_location , e )
removed . append ( ( self . backup_name , oldbackup_location ) )
else :
self . logger . debug ( ' [ %s ] cleanup : no result for query ' , self . backup_name )
else :
self . logger . info ( ' Nothing to do because we want to keep at least one OK backup after cleaning ' )
self . logger . info ( ' [ %s ] Cleanup finished : removed : %s ' , self . backup_name , ' , ' . join ( [ ( ' [ %s ]- " %s " ' ) % r for r in removed ] ) or ' Nothing ' )
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')
2018-01-30 12:29:16 +01:00
2013-05-23 10:19:43 +02:00
def export_latestbackup ( self , destdir ) :
""" Copy (rsync) latest OK backup to external storage located at locally mounted " destdir "
"""
stats = { }
stats [ ' total_files_count ' ] = 0
stats [ ' written_files_count ' ] = 0
stats [ ' total_bytes ' ] = 0
stats [ ' written_bytes ' ] = 0
stats [ ' log ' ] = ' '
stats [ ' status ' ] = ' Running '
if not self . dbstat :
self . logger . critical ( ' [ %s ] export_latestbackup : no database provided ' , self . backup_name )
raise Exception ( ' No database ' )
else :
latest_sql = """ \
2018-01-30 12:29:16 +01:00
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 """
2018-01-30 12:29:16 +01:00
self . logger . debug ( ' [ %s ] export_latestbackup : sql query " %s " %s ' , self . backup_name , latest_sql , self . backup_name )
2013-05-23 10:19:43 +02:00
q = self . dbstat . query ( latest_sql , ( self . backup_name , ) )
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 )
else :
latest = q [ 0 ]
backup_source = latest [ ' backup_location ' ]
backup_dest = os . path . join ( os . path . abspath ( destdir ) , self . backup_name )
if not os . path . exists ( backup_source ) :
raise Exception ( ' Backup source %s doesn \' t exists ' % backup_source )
# ensure there is a slash at end
2022-04-25 10:02:43 +02:00
if os . path . isdir ( backup_source ) and backup_source [ - 1 ] != ' / ' :
2013-05-23 10:19:43 +02:00
backup_source + = ' / '
2022-04-25 10:02:43 +02:00
if backup_dest [ - 1 ] != ' / ' :
2013-05-23 10:19:43 +02:00
backup_dest + = ' / '
2018-01-30 12:29:16 +01:00
2013-05-23 10:19:43 +02:00
if not os . path . isdir ( backup_dest ) :
os . makedirs ( backup_dest )
2018-01-30 12:29:16 +01:00
2013-05-23 10:19:43 +02:00
options = [ ' -aP ' , ' --stats ' , ' --delete-excluded ' , ' --numeric-ids ' , ' --delete-after ' ]
if self . logger . level :
options . append ( ' -P ' )
if self . dry_run :
options . append ( ' -d ' )
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 )
if not self . dry_run :
self . line = ' '
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 )
def ondata ( data , context ) :
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 )
for l in log . splitlines ( ) :
if l . startswith ( ' Number of files: ' ) :
2014-07-25 15:06:51 +02:00
stats [ ' total_files_count ' ] + = int ( re . findall ( ' [0-9]+ ' , l . split ( ' : ' ) [ 1 ] ) [ 0 ] )
2013-05-23 10:19:43 +02:00
if l . startswith ( ' Number of files transferred: ' ) :
stats [ ' written_files_count ' ] + = int ( l . split ( ' : ' ) [ 1 ] )
if l . startswith ( ' Total file size: ' ) :
2014-07-25 15:06:51 +02:00
stats [ ' total_bytes ' ] + = float ( l . replace ( ' , ' , ' ' ) . split ( ' : ' ) [ 1 ] . split ( ) [ 0 ] )
2013-05-23 10:19:43 +02:00
if l . startswith ( ' Total transferred file size: ' ) :
2014-07-25 15:06:51 +02:00
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 ) :
self . logger . warning ( " [ " + self . backup_name + " ] Note: some files vanished before transfer " )
elif ( returncode == 23 ) :
self . logger . warning ( " [ " + self . backup_name + " ] unable so set uid on some files " )
elif ( returncode != 0 ) :
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 ' ] ) )
2018-01-30 12:29:16 +01:00
2013-05-23 10:19:43 +02:00
endtime = time . time ( )
duration = ( endtime - starttime ) / 3600.0
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 ' ] ,
2018-01-30 12:29:16 +01:00
backup_location = backup_dest )
2013-05-23 10:19:43 +02:00
return stats
if __name__ == ' __main__ ' :
logger = logging . getLogger ( ' tisbackup ' )
logger . setLevel ( logging . DEBUG )
formatter = logging . Formatter ( ' %(asctime)s %(levelname)s %(message)s ' )
handler = logging . StreamHandler ( )
handler . setFormatter ( formatter )
logger . addHandler ( handler )
2018-01-30 12:29:16 +01:00
dbstat = BackupStat ( ' /backup/data/log/tisbackup.sqlite ' )