def test_destination_context(self): """Test destination context creation.""" # Make sure DestinationContextUnavailable is raised when the # destination is an rsync daemon module. program = RsyncSystemBackup(destination='server::backups/system') self.assertRaises(DestinationContextUnavailable, lambda: program.destination_context) # Make sure the SSH alias and user are copied from the destination # expression to the destination context. program = RsyncSystemBackup( destination='backup-user@backup-server:backups/system') assert program.destination_context.ssh_alias == 'backup-server' assert program.destination_context.ssh_user == 'backup-user'
def test_backup_failure(self): """Test that an exception is raised when ``rsync`` fails.""" program = RsyncSystemBackup( destination='0.0.0.0::module/directory', sudo_enabled=False, ) self.assertRaises(ExternalCommandFailed, program.execute)
def test_unsupported_platform_with_force(self): """Test that UnsupportedPlatformError is raised as expected.""" with MockedProgram('uname'): program = RsyncSystemBackup(destination='/some/random/directory', force=True) # Avoid making an actual backup. program.execute_helper = MagicMock() program.execute() assert program.execute_helper.called
def test_notifications(self): """Test the desktop notification functionality.""" program = RsyncSystemBackup(destination='/backups/system') # Right now we just make sure the Python code doesn't contain any silly # mistakes. It would be nice to have a more thorough test though, e.g. # make sure that `notify-send' is called and make sure that we don't # fail when `notify-send' does fail. program.notify_starting() program.notify_finished(Timer()) program.notify_failed(Timer())
def test_rsync_module_path_as_destination(self): """Test that destination defaults to ``$RSYNC_MODULE_PATH``.""" with TemporaryDirectory() as temporary_directory: try: os.environ['RSYNC_MODULE_PATH'] = temporary_directory program = RsyncSystemBackup() assert program.destination.directory == temporary_directory assert not program.destination.hostname assert not program.destination.username assert not program.destination.module finally: os.environ.pop('RSYNC_MODULE_PATH')
def test_invalid_destination_directory(self): """Test that InvalidDestinationDirectory is raised as expected.""" if not os.path.isdir(MOUNT_POINT): return self.skipTest("Skipping test because %s doesn't exist!", MOUNT_POINT) with prepared_image_file(): program = RsyncSystemBackup( crypto_device=CRYPTO_NAME, destination='/some/random/directory', mount_point=MOUNT_POINT, notifications_enabled=False, ) self.assertRaises(InvalidDestinationDirectory, program.transfer_changes)
def test_notifications(self): """Test the desktop notification functionality.""" timer = Timer() program = RsyncSystemBackup(destination='/backups/system') # The happy path. with MockedProgram('notify-send', returncode=0): program.notify_starting() program.notify_finished(timer) program.notify_failed(timer) # The sad path (should not raise exceptions). with MockedProgram('notify-send', returncode=1): program.notify_starting() program.notify_finished(timer) program.notify_failed(timer)
def test_mount_failure(self): """Test that FailedToMountError is raised as expected.""" with prepared_image_file(create_filesystem=False): program = RsyncSystemBackup( crypto_device=CRYPTO_NAME, destination=os.path.join(MOUNT_POINT, 'latest'), mount_point=MOUNT_POINT, ) # When `mount' fails it should exit with a nonzero exit code, # thereby causing executor to raise an ExternalCommandFailed # exception that obscures the FailedToMountError exception that # we're interested in. The check=False option enables our # `last resort error handling' code path to be reached. program.destination_context.options['check'] = False self.assertRaises(FailedToMountError, program.execute)
def test_missing_crypto_device(self): """Test that MissingBackupDiskError is raised as expected.""" if not os.path.isdir(MOUNT_POINT): return self.skipTest("Skipping test because %s doesn't exist!", MOUNT_POINT) # Make sure the image file doesn't exist. if os.path.exists(IMAGE_FILE): os.unlink(IMAGE_FILE) # Ask rsync-system-backup to use the encrypted filesystem on the image # file anyway, because we know it will fail and that's exactly what # we're interested in :-). program = RsyncSystemBackup( crypto_device=CRYPTO_NAME, destination=os.path.join(MOUNT_POINT, 'latest'), mount_point=MOUNT_POINT, notifications_enabled=False, ) self.assertRaises(MissingBackupDiskError, program.execute)
def test_unlock_failure(self): """Test that FailedToUnlockError is raised as expected.""" # Make sure the image file doesn't exist. if os.path.exists(IMAGE_FILE): os.unlink(IMAGE_FILE) # Ask rsync-system-backup to use the encrypted filesystem on the image # file anyway, because we know it will fail and that's exactly what # we're interested in :-). program = RsyncSystemBackup( crypto_device=CRYPTO_NAME, destination=os.path.join(MOUNT_POINT, 'latest'), mount_point=MOUNT_POINT, ) # When `cryptdisks_start' fails it should exit with a nonzero exit # code, thereby causing executor to raise an ExternalCommandFailed # exception that obscures the FailedToUnlockError exception that we're # interested in. The check=False option enables our `last resort error # handling' code path to be reached. program.destination_context.options['check'] = False self.assertRaises(FailedToUnlockError, program.execute)
def test_unsupported_platform_error(self): """Test that UnsupportedPlatformError is raised as expected.""" with MockedProgram('uname'): program = RsyncSystemBackup(destination='/some/random/directory') self.assertRaises(UnsupportedPlatformError, program.execute)
def main(): """Command line interface for the ``rsync-system-backup`` program.""" # Initialize logging to the terminal and system log. coloredlogs.install(syslog=True) # Parse the command line arguments. context_opts = dict() program_opts = dict() dest_opts = dict() try: options, arguments = getopt.gnu_getopt(sys.argv[1:], 'bsrm:c:t:i:unx:fvqh', [ 'backup', 'snapshot', 'rotate', 'mount=', 'crypto=', 'tunnel=', 'ionice=', 'no-sudo', 'dry-run', 'multi-fs', 'exclude=', 'force', 'disable-notifications', 'verbose', 'quiet', 'help', ]) for option, value in options: if option in ('-b', '--backup'): enable_explicit_action(program_opts, 'backup_enabled') elif option in ('-s', '--snapshot'): enable_explicit_action(program_opts, 'snapshot_enabled') elif option in ('-r', '--rotate'): enable_explicit_action(program_opts, 'rotate_enabled') elif option in ('-m', '--mount'): program_opts['mount_point'] = value elif option in ('-c', '--crypto'): program_opts['crypto_device'] = value elif option in ('-t', '--tunnel'): ssh_user, _, value = value.rpartition('@') ssh_alias, _, port_number = value.partition(':') tunnel_opts = dict( ssh_alias=ssh_alias, ssh_user=ssh_user, # The port number of the rsync daemon. remote_port=RSYNCD_PORT, ) if port_number: # The port number of the SSH server. tunnel_opts['port'] = int(port_number) dest_opts['ssh_tunnel'] = SecureTunnel(**tunnel_opts) elif option in ('-i', '--ionice'): value = value.lower().strip() validate_ionice_class(value) program_opts['ionice'] = value elif option in ('-u', '--no-sudo'): program_opts['sudo_enabled'] = False elif option in ('-n', '--dry-run'): logger.info("Performing a dry run (because of %s option) ..", option) program_opts['dry_run'] = True elif option in ('-f', '--force'): program_opts['force'] = True elif option in ('-x', '--exclude'): program_opts.setdefault('exclude_list', []) program_opts['exclude_list'].append(value) elif option == '--multi-fs': program_opts['multi_fs'] = True elif option == '--disable-notifications': program_opts['notifications_enabled'] = False elif option in ('-v', '--verbose'): coloredlogs.increase_verbosity() elif option in ('-q', '--quiet'): coloredlogs.decrease_verbosity() elif option in ('-h', '--help'): usage(__doc__) return else: raise Exception("Unhandled option! (programming error)") if len(arguments) > 2: msg = "Expected one or two positional arguments! (got %i)" raise Exception(msg % len(arguments)) if len(arguments) == 2: # Get the source from the first of two arguments. program_opts['source'] = arguments.pop(0) if arguments: # Get the destination from the second (or only) argument. dest_opts['expression'] = arguments[0] program_opts['destination'] = Destination(**dest_opts) elif not os.environ.get('RSYNC_MODULE_PATH'): # Show a usage message when no destination is given. usage(__doc__) return except Exception as e: warning("Error: %s", e) sys.exit(1) try: # Inject the source context into the program options. program_opts['source_context'] = create_context(**context_opts) # Initialize the program with the command line # options and execute the requested action(s). RsyncSystemBackup(**program_opts).execute() except Exception as e: if isinstance(e, RsyncSystemBackupError): # Special handling when the backup disk isn't available. if isinstance(e, MissingBackupDiskError): # Check if we're connected to a terminal to decide whether the # error should be propagated or silenced, the idea being that # rsync-system-backup should keep quiet when it's being run # from cron and the backup disk isn't available. if not connected_to_terminal(): logger.info("Skipping backup: %s", e) sys.exit(0) # Known problems shouldn't produce # an intimidating traceback to users. logger.error("Aborting due to error: %s", e) else: # Unhandled exceptions do get a traceback, # because it may help fix programming errors. logger.exception("Aborting due to unhandled exception!") sys.exit(1)
def main(): """Command line interface for the ``rsync-system-backup`` program.""" # Initialize logging to the terminal and system log. coloredlogs.install(syslog=True) # Parse the command line arguments. context_opts = dict() program_opts = dict() try: options, arguments = getopt.getopt(sys.argv[1:], 'bsrm:c:i:unvqh', [ 'backup', 'snapshot', 'rotate', 'mount=', 'crypto=', 'ionice=', 'no-sudo', 'dry-run', 'disable-notifications', 'verbose', 'quiet', 'help', ]) for option, value in options: if option in ('-b', '--backup'): enable_explicit_action(program_opts, 'backup_enabled') elif option in ('-s', '--snapshot'): enable_explicit_action(program_opts, 'snapshot_enabled') elif option in ('-r', '--rotate'): enable_explicit_action(program_opts, 'rotate_enabled') elif option in ('-m', '--mount'): program_opts['mount_point'] = value elif option in ('-c', '--crypto'): program_opts['crypto_device'] = value elif option in ('-i', '--ionice'): value = value.lower().strip() validate_ionice_class(value) program_opts['ionice'] = value elif option in ('-u', '--no-sudo'): program_opts['sudo_enabled'] = False elif option in ('-n', '--dry-run'): logger.info("Performing a dry run (because of %s option) ..", option) program_opts['dry_run'] = True elif option == '--disable-notifications': program_opts['notifications_enabled'] = False elif option in ('-v', '--verbose'): coloredlogs.increase_verbosity() elif option in ('-q', '--quiet'): coloredlogs.decrease_verbosity() elif option in ('-h', '--help'): usage(__doc__) return else: raise Exception("Unhandled option! (programming error)") if len(arguments) > 2: msg = "Expected one or two positional arguments! (got %i)" raise Exception(msg % len(arguments)) if len(arguments) == 2: # Get the source from the first of two arguments. program_opts['source'] = arguments.pop(0) if arguments: # Get the destination from the second (or only) argument. program_opts['destination'] = arguments[0] elif not os.environ.get('RSYNC_MODULE_PATH'): # Show a usage message when no destination is given. usage(__doc__) return except Exception as e: warning("Error: %s", e) sys.exit(1) try: # Inject the source context into the program options. program_opts['source_context'] = create_context(**context_opts) # Initialize the program with the command line # options and execute the requested action(s). RsyncSystemBackup(**program_opts).execute() except Exception as e: if isinstance(e, RsyncSystemBackupError): # Known problems shouldn't produce # an intimidating traceback to users. logger.error("Aborting due to error: %s", e) else: # Unhandled exceptions do get a traceback, # because it may help fix programming errors. logger.exception("Aborting due to unhandled exception!") sys.exit(1)