# Copyright (C) 2009-2016 Lars Wirzenius # Copyright (C) 2017 SanskritFritz # # This program 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. # # This program 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 this program. If not, see . import re import stat import sys import time import obnamlib class ClientDoesNotExistError(obnamlib.ObnamError): msg = 'Client {client} does not exist in repository {repo}' class WrongNumberOfGenerationsForDiffError(obnamlib.ObnamError): msg = 'Need one or two generations' class ShowFirstGenerationError(obnamlib.ObnamError): msg = "Can't show first generation. Use 'obnam ls' instead" class ShowPlugin(obnamlib.ObnamPlugin): '''Show information about data in the backup repository. This implements commands for listing contents of root and client objects, or the contents of a backup generation. ''' leftists = (2, 3, 6) min_widths = (1, 1, 1, 1, 6, 20, 1) def enable(self): self.app.add_subcommand('clients', self.clients) self.app.add_subcommand('generations', self.generations) self.app.add_subcommand('genids', self.genids) self.app.add_subcommand('ls', self.ls, arg_synopsis='[FILE]...') self.app.add_subcommand( 'kdirstat', self.kdirstat, arg_synopsis='[FILE]...') self.app.add_subcommand('diff', self.diff, arg_synopsis='[GENERATION1] GENERATION2') self.app.add_subcommand('nagios-last-backup-age', self.nagios_last_backup_age) self.app.settings.string( ['warn-age'], 'for nagios-last-backup-age: maximum age (by ' 'default in hours) for the most recent ' 'backup before status is warning. ' 'Accepts one char unit specifier ' '(s,m,h,d for seconds, minutes, hours, ' 'and days.', metavar='AGE', default=obnamlib.DEFAULT_NAGIOS_WARN_AGE) self.app.settings.string( ['critical-age'], 'for nagios-last-backup-age: maximum age ' '(by default in hours) for the most ' 'recent backup before statis is critical. ' 'Accepts one char unit specifier ' '(s,m,h,d for seconds, minutes, hours, ' 'and days.', metavar='AGE', default=obnamlib.DEFAULT_NAGIOS_WARN_AGE) def open_repository(self, require_client=True): self.app.settings.require('repository') if require_client: self.app.settings.require('client-name') self.repo = self.app.get_repository_object() if require_client: client = self.app.settings['client-name'] clients = self.repo.get_client_names() if client not in clients: raise ClientDoesNotExistError( client=client, repo=self.app.settings['repository']) def clients(self, args): '''List clients using the repository.''' self.open_repository(require_client=False) for client_name in self.repo.get_client_names(): self.app.output.write('%s\n' % client_name) self.repo.close() def generations(self, args): '''List backup generations for client.''' self.open_repository() client_name = self.app.settings['client-name'] for gen_id in self.repo.get_client_generation_ids(client_name): start = self.repo.get_generation_key( gen_id, obnamlib.REPO_GENERATION_STARTED) end = self.repo.get_generation_key( gen_id, obnamlib.REPO_GENERATION_ENDED) is_checkpoint = self.repo.get_generation_key( gen_id, obnamlib.REPO_GENERATION_IS_CHECKPOINT) file_count = self.repo.get_generation_key( gen_id, obnamlib.REPO_GENERATION_FILE_COUNT) data_size = self.repo.get_generation_key( gen_id, obnamlib.REPO_GENERATION_TOTAL_DATA) if is_checkpoint: checkpoint = ' (checkpoint)' else: checkpoint = '' sys.stdout.write('%s\t%s .. %s (%d files, %d bytes) %s\n' % (self.repo.make_generation_spec(gen_id), self.format_time(start), self.format_time(end), file_count, data_size, checkpoint)) self.repo.close() def nagios_last_backup_age(self, args): '''Check if the most recent generation is recent enough.''' try: self.open_repository() except obnamlib.ObnamError as e: self.app.output.write('CRITICAL: %s\n' % e) sys.exit(2) most_recent = None warn_age = self._convert_time(self.app.settings['warn-age']) critical_age = self._convert_time(self.app.settings['critical-age']) client_name = self.app.settings['client-name'] for gen_id in self.repo.get_client_generation_ids(client_name): start = self.repo.get_generation_key( gen_id, obnamlib.REPO_GENERATION_STARTED) if most_recent is None or start > most_recent: most_recent = start self.repo.close() now = self.app.time() if most_recent is None: # the repository is empty / the client does not exist self.app.output.write('CRITICAL: no backup found.\n') sys.exit(2) elif now - most_recent > critical_age: self.app.output.write( 'CRITICAL: backup is old. last backup was %s.\n' % (self.format_time(most_recent))) sys.exit(2) elif now - most_recent > warn_age: self.app.output.write( 'WARNING: backup is old. last backup was %s.\n' % self.format_time(most_recent)) sys.exit(1) self.app.output.write( 'OK: backup is recent. last backup was %s.\n' % self.format_time(most_recent)) def genids(self, args): '''List generation ids for client.''' self.open_repository() client_name = self.app.settings['client-name'] for gen_id in self.repo.get_client_generation_ids(client_name): sys.stdout.write('%s\n' % self.repo.make_generation_spec(gen_id)) self.repo.close() def traverse(self, hdr, cb, args): '''Traverse a generation calling callback.''' self.open_repository() if len(args) is 0: args = ['/'] client_name = self.app.settings['client-name'] for genspec in self.app.settings['generation']: gen_id = self.repo.interpret_generation_spec(client_name, genspec) started = self.repo.get_generation_key( gen_id, obnamlib.REPO_GENERATION_STARTED) ended = self.repo.get_generation_key( gen_id, obnamlib.REPO_GENERATION_ENDED) started = self.format_time(started) ended = self.format_time(ended) hdr('Generation %s (%s - %s)\n' % (self.repo.make_generation_spec(gen_id), started, ended)) for filename in args: filename = self.remove_trailing_slashes(filename) self.show_objects(cb, gen_id, filename) self.repo.close() def remove_trailing_slashes(self, filename): while filename.endswith('/') and filename != '/': filename = filename[:-1] return filename def format_time(self, timestamp): prefix = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp)) return prefix + ' ' + self.format_timezone() def format_timezone(self): '''Return a timezone indication on +0300 format.''' # A zero offset gets a plus sign. The time.timezone value has the # opposite sign of what we want in the output. if time.timezone <= 0: sign = '+' else: sign = '-' hh = abs(time.timezone) / 3600 mm = (abs(time.timezone) % 3600) / 60 return '%c%02d%02d' % (sign, hh, mm) def isdir(self, gen_id, filename): mode = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_MODE) return stat.S_ISDIR(mode) def show_objects(self, cb, gen_id, dirname): cb(gen_id, dirname) subdirs = [] for filename in sorted(self.repo.get_file_children(gen_id, dirname)): if self.isdir(gen_id, filename): subdirs.append(filename) else: cb(gen_id, filename) for subdir in subdirs: self.show_objects(cb, gen_id, subdir) def ls(self, args): '''List contents of a generation.''' self.traverse(self.show_hdr_ls, self.show_item_ls, args) def show_hdr_ls(self, comment): self.app.output.write(comment) def show_item_ls(self, gen_id, filename): fields = self.fields(gen_id, filename) widths = [ 1, # mode 5, # nlink -8, # owner -8, # group 10, # size 1, # mtime -1, # name ] result = [] for i, field in enumerate(fields): if widths[i] < 0: fmt = '%-*s' else: fmt = '%*s' result.append(fmt % (abs(widths[i]), field)) self.app.output.write('%s\n' % ' '.join(result)) def kdirstat(self, args): '''List contents of a generation in kdirstat cache format.''' self.traverse(self.show_hdr_kdirstat, self.show_item_kdirstat, args) def show_hdr_kdirstat(self, comment): self.app.output.write('''[kdirstat 4.0 cache file] # Generated by obnam %s # Do not edit! # # Type path size mtime ''' % comment) def show_item_kdirstat(self, gen_id, filename): mode = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_MODE) size = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_SIZE) mtime_sec = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_MTIME_SEC) if stat.S_ISREG(mode): mode_str = "F\t" elif stat.S_ISDIR(mode): mode_str = "D " elif stat.S_ISLNK(mode): mode_str = "L\t" elif stat.S_ISBLK(mode): mode_str = "BlockDev\t" elif stat.S_ISCHR(mode): mode_str = "CharDev\t" elif stat.S_ISFIFO(mode): mode_str = "FIFO\t" elif stat.S_ISSOCK(mode): mode_str = "Socket\t" else: # Unhandled, make it look like a comment mode_str = "#UNHANDLED\t" enc_filename = filename.replace("%", "%25") enc_filename = enc_filename.replace(" ", "%20") enc_filename = enc_filename.replace("\t", "%09") self.app.output.write( "%s%s\t%d\t%#x\n" % (mode_str, enc_filename, size, mtime_sec)) def show_diff_for_file(self, gen_id, fullname, change_char): '''Show what has changed for a single file. change_char is a single char (+,- or *) indicating whether a file got added, removed or altered. If --verbose, just show all the details as ls shows, otherwise show just the file's full name. ''' if self.app.settings['verbose']: sys.stdout.write('%s ' % change_char) self.show_item_ls(gen_id, fullname) else: self.app.output.write('%s %s\n' % (change_char, fullname)) def show_diff_for_common_file(self, gen_id1, gen_id2, fullname, subdirs): changed = False if self.isdir(gen_id1, fullname) != self.isdir(gen_id2, fullname): changed = True elif self.isdir(gen_id2, fullname): subdirs.append(fullname) else: # Files are both present and neither is a directory. # Compare md5 def get_md5(gen_id): if obnamlib.REPO_FILE_MD5 in self.repo.get_allowed_file_keys(): return self.repo.get_file_key( gen_id, fullname, obnamlib.REPO_FILE_MD5) md5_1 = get_md5(gen_id1) md5_2 = get_md5(gen_id2) if md5_1 != md5_2: changed = True if changed: self.show_diff_for_file(gen_id2, fullname, '*') def show_diff(self, gen_id1, gen_id2, dirname): # This set contains the files from the old/src generation set1 = self.repo.get_file_children(gen_id1, dirname) subdirs = [] # These are the new/dst generation files for filename in sorted(self.repo.get_file_children(gen_id2, dirname)): if filename in set1: # Its in both generations set1.remove(filename) self.show_diff_for_common_file( gen_id1, gen_id2, filename, subdirs) else: # Its only in set2 - the file/dir got added self.show_diff_for_file(gen_id2, filename, '+') for filename in sorted(set1): # This was only in gen1 - it got removed self.show_diff_for_file(gen_id1, filename, '-') for subdir in subdirs: self.show_diff(gen_id1, gen_id2, subdir) def diff(self, args): '''Show difference between two generations.''' if len(args) not in (1, 2): raise WrongNumberOfGenerationsForDiffError() self.open_repository() client_name = self.app.settings['client-name'] if len(args) == 1: gen_id2 = self.repo.interpret_generation_spec( client_name, args[0]) # Now we have the dst/second generation for show_diff. Use # genids/list_generations to find the previous generation genids = self.repo.get_client_generation_ids(client_name) index = genids.index(gen_id2) if index == 0: raise ShowFirstGenerationError() gen_id1 = genids[index - 1] else: gen_id1 = self.repo.interpret_generation_spec( client_name, args[0]) gen_id2 = self.repo.interpret_generation_spec( client_name, args[1]) self.show_diff(gen_id1, gen_id2, '/') self.repo.close() def fields(self, gen_id, filename): mode = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_MODE) mtime_sec = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_MTIME_SEC) target = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_SYMLINK_TARGET) nlink = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_NLINK) username = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_USERNAME) groupname = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_GROUPNAME) size = self.repo.get_file_key( gen_id, filename, obnamlib.REPO_FILE_SIZE) perms = ['?'] + ['-'] * 9 tab = [ (stat.S_IFDIR, 0, 'd'), (stat.S_IFCHR, 0, 'c'), # character device (stat.S_IFBLK, 0, 'b'), # block device (stat.S_IFREG, 0, '-'), (stat.S_IFIFO, 0, 'p'), (stat.S_IFLNK, 0, 'l'), # (stat.S_IFSOCK, 0, 's'), # not stored, listed for completeness (stat.S_IRUSR, 1, 'r'), (stat.S_IWUSR, 2, 'w'), (stat.S_IXUSR, 3, 'x'), (stat.S_IRGRP, 4, 'r'), (stat.S_IWGRP, 5, 'w'), (stat.S_IXGRP, 6, 'x'), (stat.S_IROTH, 7, 'r'), (stat.S_IWOTH, 8, 'w'), (stat.S_IXOTH, 9, 'x'), ] for bitmap, offset, char in tab: if (mode & bitmap) == bitmap: perms[offset] = char # set modifiers based on the x bit in that position tab = [ (stat.S_ISUID, 3, 's', 'S'), (stat.S_ISGID, 6, 's', 'S'), (stat.S_ISVTX, 9, 't', 'T'), ] for bitmap, offset, has_X, no_X in tab: if mode & bitmap: if perms[offset] == 'x': perms[offset] = has_X else: perms[offset] = no_X perms = ''.join(perms) timestamp = time.strftime( '%Y-%m-%d %H:%M:%S', time.gmtime(mtime_sec)) if stat.S_ISLNK(mode): name = '%s -> %s' % (filename, target) else: name = filename return (perms, str(nlink), username, groupname, str(size), timestamp, name) def align(self, width, field, field_no): if field_no in self.leftists: return '%-*s' % (width, field) else: return '%*s' % (width, field) def _convert_time(self, s, default_unit='h'): m = re.match('([0-9]+)([smhdw])?$', s) if m is None: raise ValueError ticks = int(m.group(1)) unit = m.group(2) if unit is None: unit = default_unit if unit == 's': pass elif unit == 'm': ticks *= 60 elif unit == 'h': ticks *= 60*60 elif unit == 'd': ticks *= 60*60*24 elif unit == 'w': ticks *= 60*60*24*7 else: raise ValueError return ticks