Compare commits

..

No commits in common. "master" and "4.1.4" have entirely different histories.

6 changed files with 94 additions and 206 deletions

View File

@ -1,40 +0,0 @@
# PKGBUILD
# Maintainer: Daniele Fucini <dfucini [at] gmail [dot] com>
pkgname=simple_backup
pkgdesc='Simple backup script that uses rsync to copy files'
pkgver=4.1.6
pkgrel=1
url="https://git.shouldnt.work/fuxino/${pkgname}"
arch=('any')
license=('GPL-3.0-or-later')
makedepends=('git'
'python-setuptools'
'python-build'
'python-installer'
'python-wheel')
depends=('python>=3.10'
'rsync'
'python-dotenv')
optdepends=('python-systemd: use systemd log'
'python-dbus: for desktop notifications'
'python-paramiko: for remote backup through ssh')
conflicts=('simple_backup-git')
source=(git+${url}?signed#tag=${pkgver})
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('b3b29d9e2e1b7b949e95674d9a401e8eeb0d5f898e8450473dce94f799ee9df3')
build()
{
cd ${srcdir}/${pkgname}
python -m build --wheel --no-isolation
}
package()
{
cd ${srcdir}/${pkgname}
python -m installer --destdir=${pkgdir} dist/*.whl
install -Dm644 ${pkgname}/${pkgname}.conf ${pkgdir}/usr/share/doc/${pkgname}/${pkgname}.conf
install -Dm644 man/${pkgname}.1 ${pkgdir}/usr/share/man/man1/${pkgname}.1
}

View File

@ -1,4 +1,4 @@
.TH SIMPLE_BACKUP 1 2025-03-30 SIMPLE_BACKUP 4.1.6
.TH SIMPLE_BACKUP 1 2023-06-15 SIMPLE_BACKUP 3.2.6
.SH NAME
simple_backup \- Backup files and folders using rsync
.SH SYNOPSIS
@ -187,6 +187,6 @@ Bad configuration file.
.SH SEE ALSO
.BR rsync (1)
.SH AUTHORS
.MT https://git.shouldnt.work/fuxino
.MT https://github.com/Fuxino
Daniele Fucini
.ME

View File

@ -6,7 +6,7 @@ long_description = file: README.md
author = Daniele Fucini
author_email = dfucini@gmail.com
license = GPL3
url = https://git.shouldnt.work/fuxino
url = https://github.com/Fuxino/simple_backup
classifiers =
Development Status :: 4 - Beta
Environment :: Console
@ -16,7 +16,6 @@ classifiers =
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
Programming Language :: Python :: 3.13
Topic :: System :: Archiving :: Backup
[options]

View File

@ -1,3 +1,3 @@
"""Init."""
__version__ = '4.1.6'
__version__ = '4.1.2'

View File

@ -2,7 +2,7 @@
[backup]
# Files and directories to backup. Multiple items can be separated using a comma (','). It is possible to use wildcards (i.e. '*' to match multiple characters and '~' for the user's home directory).
inputs=/home/user
inputs=/home/my_home,/etc
# Output directory.
backup_dir=/media/Backup

View File

@ -14,7 +14,6 @@ Classes:
# Import libraries
import sys
import os
from typing import Callable, List, Optional, ParamSpec, TypeVar, Union
import warnings
from functools import wraps
from shutil import rmtree, which
@ -68,29 +67,29 @@ if journal:
j_handler.setFormatter(j_format)
logger.addHandler(j_handler)
P = ParamSpec('P')
R = TypeVar('R')
def timing(func: Callable[P, R]) -> Callable[P, R]:
def timing(_logger):
"""Decorator to measure execution time of a function
Parameters:
func: Function to decorate
_logger: Logger object
"""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = default_timer()
def decorator_timing(func):
@wraps(func)
def wrapper_timing(*args, **kwargs):
start = default_timer()
value = func(*args, **kwargs)
value = func(*args, **kwargs)
end = default_timer()
end = default_timer()
logger.info('Elapsed time: %.3f seconds', end - start)
_logger.info(f'Elapsed time: {end - start:.3f} seconds')
return value
return value
return wrapper
return wrapper_timing
return decorator_timing
class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
@ -135,9 +134,8 @@ class Backup:
Perform the backup
"""
def __init__(self, inputs: List[str], output: str, exclude: List[str], keep: int, options: str,
ssh_host: Optional[str] = None, ssh_user: Optional[str] = None, ssh_keyfile: Optional[str] = None,
remote_sudo: bool = False, remove_before: bool = False, verbose: bool = False) -> None:
def __init__(self, inputs, output, exclude, keep, options, ssh_host=None, ssh_user=None,
ssh_keyfile=None, remote_sudo=False, remove_before=False, verbose=False):
self.inputs = inputs
self.output = output
self.exclude = exclude
@ -154,23 +152,21 @@ class Backup:
self._output_dir = ''
self._inputs_path = ''
self._exclude_path = ''
self._remote = False
self._remote = None
self._ssh = None
self._password_auth = False
self._password = None
def check_params(self, homedir: str = '') -> int:
def check_params(self, homedir=''):
"""Check if parameters for the backup are valid"""
if self.inputs is None or len(self.inputs) == 0:
logger.info(
'No existing files or directories specified for backup. Nothing to do')
logger.info('No existing files or directories specified for backup. Nothing to do')
return 1
if self.output is None:
logger.critical(
'No output path specified. Use -o argument or specify output path in configuration file')
logger.critical('No output path specified. Use -o argument or specify output path in configuration file')
return 2
@ -183,8 +179,7 @@ class Backup:
if self._ssh is None:
return 5
_, stdout, _ = self._ssh.exec_command(
f'if [ -d "{self.output}" ]; then echo "ok"; fi')
_, stdout, _ = self._ssh.exec_command(f'if [ -d "{self.output}" ]; then echo "ok"; fi')
output = stdout.read().decode('utf-8').strip()
@ -206,7 +201,7 @@ class Backup:
return 0
# Function to create the actual backup directory
def define_backup_dir(self) -> None:
def define_backup_dir(self):
"""Define the actual backup dir"""
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self._output_dir = f'{self.output}/simple_backup/{now}'
@ -214,14 +209,11 @@ class Backup:
if self._remote:
self._server = f'{self.ssh_user}@{self.ssh_host}:'
def remove_old_backups(self) -> None:
def remove_old_backups(self):
"""Remove old backups if there are more than indicated by 'keep'"""
if self._remote:
assert self._ssh is not None
_, stdout, _ = self._ssh.exec_command(
f'ls {self.output}/simple_backup')
_, stdout, _ = self._ssh.exec_command(f'ls {self.output}/simple_backup')
dirs = stdout.read().decode('utf-8').strip().split('\n')
@ -238,17 +230,14 @@ class Backup:
for i in range(n_backup - self.keep):
if self.remote_sudo:
_, _, stderr = self._ssh.exec_command(
f'sudo rm -r "{self.output}/simple_backup/{dirs[i]}"')
_, _, stderr = self._ssh.exec_command(f'sudo rm -r "{self.output}/simple_backup/{dirs[i]}"')
else:
_, _, stderr = self._ssh.exec_command(
f'rm -r "{self.output}/simple_backup/{dirs[i]}"')
_, _, stderr = self._ssh.exec_command(f'rm -r "{self.output}/simple_backup/{dirs[i]}"')
err = stderr.read().decode('utf-8').strip().split('\n')[0]
if err != '':
logger.error(
'Error while removing backup %s.', {dirs[i]})
logger.error('Error while removing backup %s.', {dirs[i]})
logger.error(err)
else:
count += 1
@ -274,18 +263,16 @@ class Backup:
rmtree(f'{self.output}/simple_backup/{dirs[i]}')
count += 1
except FileNotFoundError:
logger.error(
'Error while removing backup %s. Directory not found', dirs[i])
logger.error('Error while removing backup %s. Directory not found', dirs[i])
except PermissionError:
logger.error(
'Error while removing backup %s. Permission denied', dirs[i])
logger.error('Error while removing backup %s. Permission denied', dirs[i])
if count == 1:
logger.info('Removed %d backup', count)
elif count > 1:
logger.info('Removed %d backups', count)
def find_last_backup(self) -> None:
def find_last_backup(self):
"""Get path of last backup (from last_backup symlink) for rsync --link-dest"""
if self._remote:
@ -293,8 +280,7 @@ class Backup:
logger.critical('SSH connection to server failed')
sys.exit(5)
_, stdout, _ = self._ssh.exec_command(
f'find {self.output}/simple_backup/ -mindepth 1 -maxdepth 1 -type d | sort')
_, stdout, _ = self._ssh.exec_command(f'find {self.output}/simple_backup/ -mindepth 1 -maxdepth 1 -type d | sort')
output = stdout.read().decode('utf-8').strip().split('\n')
if output[-1] != '':
@ -303,18 +289,16 @@ class Backup:
logger.info('No previous backups available')
else:
try:
dirs = sorted([f.path for f in os.scandir(
f'{self.output}/simple_backup') if f.is_dir(follow_symlinks=False)])
dirs = sorted([f.path for f in os.scandir(f'{self.output}/simple_backup') if f.is_dir(follow_symlinks=False)])
except FileNotFoundError:
logger.info('No previous backups available')
return
except PermissionError:
logger.critical(
'Cannot access the backup directory. Permission denied')
logger.critical('Cannot access the backup directory. Permission denied')
try:
_notify('Backup failed (check log for details)')
notify('Backup failed (check log for details)')
except NameError:
pass
@ -325,18 +309,17 @@ class Backup:
except IndexError:
logger.info('No previous backups available')
def _ssh_connect(self, homedir: str = '') -> paramiko.client.SSHClient:
def _ssh_connect(self, homedir=''):
try:
ssh = paramiko.SSHClient()
except NameError:
logger.error('Install paramiko for ssh support')
return None
try:
ssh.load_host_keys(filename=f'{homedir}/.ssh/known_hosts')
except FileNotFoundError:
logger.warning('Cannot find file %s/.ssh/known_hosts', homedir)
logger.warning(f'Cannot find file {homedir}/.ssh/known_hosts')
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
@ -345,8 +328,7 @@ class Backup:
return ssh
except UserWarning:
k = input(
f'Unknown key for host {self.ssh_host}. Continue anyway? (Y/N) ')
k = input(f'Unknown key for host {self.ssh_host}. Continue anyway? (Y/N) ')
if k[0].upper() == 'Y':
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
@ -369,10 +351,8 @@ class Backup:
if self.ssh_keyfile is None:
try:
password = getpass(
f'{self.ssh_user}@{self.ssh_host}\'s password: ')
ssh.connect(self.ssh_host, username=self.ssh_user,
password=password)
password = getpass(f'{self.ssh_user}@{self.ssh_host}\'s password: ')
ssh.connect(self.ssh_host, username=self.ssh_user, password=password)
self._password_auth = True
os.environ['SSHPASS'] = password
@ -394,8 +374,7 @@ class Backup:
try:
pkey = RSAKey.from_private_key_file(self.ssh_keyfile)
except paramiko.PasswordRequiredException:
password = getpass(
f'Enter passwphrase for key \'{self.ssh_keyfile}\': ')
password = getpass(f'Enter passwphrase for key \'{self.ssh_keyfile}\': ')
try:
pkey = RSAKey.from_private_key_file(self.ssh_keyfile, password)
@ -407,8 +386,7 @@ class Backup:
pkey = Ed25519Key.from_private_key_file(self.ssh_keyfile)
except paramiko.PasswordRequiredException:
try:
pkey = Ed25519Key.from_private_key_file(
self.ssh_keyfile, password)
pkey = Ed25519Key.from_private_key_file(self.ssh_keyfile, password)
except paramiko.SSHException:
pass
@ -417,8 +395,7 @@ class Backup:
pkey = ECDSAKey.from_private_key_file(self.ssh_keyfile)
except paramiko.PasswordRequiredException:
try:
pkey = ECDSAKey.from_private_key_file(
self.ssh_keyfile, password)
pkey = ECDSAKey.from_private_key_file(self.ssh_keyfile, password)
except paramiko.SSHException:
pass
@ -427,8 +404,7 @@ class Backup:
pkey = DSSKey.from_private_key_file(self.ssh_keyfile)
except paramiko.PasswordRequiredException:
try:
pkey = DSSKey.from_private_key_file(
self.ssh_keyfile, password)
pkey = DSSKey.from_private_key_file(self.ssh_keyfile, password)
except paramiko.SSHException:
pass
@ -441,51 +417,38 @@ class Backup:
return ssh
def _returncode_log(self, returncode: int) -> None:
def _returncode_log(self, returncode):
match returncode:
case 2:
logger.error(
'Rsync error (return code 2) - Protocol incompatibility')
logger.error('Rsync error (return code 2) - Protocol incompatibility')
case 3:
logger.error(
'Rsync error (return code 3) - Errors selecting input/output files, dirs')
logger.error('Rsync error (return code 3) - Errors selecting input/output files, dirs')
case 4:
logger.error(
'Rsync error (return code 4) - Requested action not supported')
logger.error('Rsync error (return code 4) - Requested action not supported')
case 5:
logger.error(
'Rsync error (return code 5) - Error starting client-server protocol')
logger.error('Rsync error (return code 5) - Error starting client-server protocol')
case 10:
logger.error(
'Rsync error (return code 10) - Error in socket I/O')
logger.error('Rsync error (return code 10) - Error in socket I/O')
case 11:
logger.error(
'Rsync error (return code 11) - Error in file I/O')
logger.error('Rsync error (return code 11) - Error in file I/O')
case 12:
logger.error(
'Rsync error (return code 12) - Error in rsync protocol data stream')
logger.error('Rsync error (return code 12) - Error in rsync protocol data stream')
case 22:
logger.error(
'Rsync error (return code 22) - Error allocating core memory buffers')
logger.error('Rsync error (return code 22) - Error allocating core memory buffers')
case 23:
logger.warning(
'Rsync error (return code 23) - Partial transfer due to error')
logger.warning('Rsync error (return code 23) - Partial transfer due to error')
case 24:
logger.warning(
'Rsync error (return code 24) - Partial transfer due to vanished source files')
logger.warning('Rsync error (return code 24) - Partial transfer due to vanished source files')
case 30:
logger.error(
'Rsync error (return code 30) - Timeout in data send/receive')
logger.error('Rsync error (return code 30) - Timeout in data send/receive')
case 35:
logger.error(
'Rsync error (return code 35) - Timeout waiting for daemon connection')
logger.error('Rsync error (return code 35) - Timeout waiting for daemon connection')
case _:
logger.error(
'Rsync error (return code %d) - Check rsync(1) for details', returncode)
logger.error('Rsync error (return code %d) - Check rsync(1) for details', returncode)
# Function to read configuration file
@timing
def run(self) -> int:
@timing(logger)
def run(self):
"""Perform the backup"""
logger.info('Starting backup...')
@ -511,11 +474,10 @@ class Backup:
count += 1
if count == 0:
logger.info(
'No existing files or directories specified for backup. Nothing to do')
logger.info('No existing files or directories specified for backup. Nothing to do')
try:
_notify('Backup finished. No files copied')
notify('Backup finished. No files copied')
except NameError:
pass
@ -536,10 +498,10 @@ class Backup:
if self._last_backup == '':
rsync = f'/usr/bin/rsync {self.options} --exclude-from={self._exclude_path} ' +\
f'--files-from={self._inputs_path} / "{self._server}{self._output_dir}"'
f'--files-from={self._inputs_path} / "{self._server}{self._output_dir}"'
else:
rsync = f'/usr/bin/rsync {self.options} --link-dest="{self._last_backup}" --exclude-from=' +\
f'{self._exclude_path} --files-from={self._inputs_path} / "{self._server}{self._output_dir}"'
f'{self._exclude_path} --files-from={self._inputs_path} / "{self._server}{self._output_dir}"'
euid = os.geteuid()
@ -556,7 +518,6 @@ class Backup:
args = shlex.split(rsync)
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p:
output: Union[bytes, List[str]]
output, _ = p.communicate()
try:
@ -590,10 +551,7 @@ class Backup:
os.remove(self._exclude_path)
if self._remote:
assert self._ssh is not None
_, stdout, _ = self._ssh.exec_command(
f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi')
_, stdout, _ = self._ssh.exec_command(f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi')
output = stdout.read().decode('utf-8').strip()
@ -616,12 +574,10 @@ class Backup:
self._ssh.close()
else:
if returncode != 0:
logger.error(
'Some errors occurred while performing the backup')
logger.error('Some errors occurred while performing the backup')
try:
_notify(
'Some errors occurred while performing the backup. Check log for details')
_notify('Some errors occurred while performing the backup. Check log for details')
except NameError:
pass
@ -637,14 +593,14 @@ class Backup:
return 0
def _parse_arguments() -> argparse.Namespace:
def _parse_arguments():
euid = os.geteuid()
if euid == 0:
user = os.getenv('SUDO_USER')
else:
user = os.getenv('USER')
homedir = os.path.expanduser(f'~{user}')
parser = argparse.ArgumentParser(prog='simple_backup',
@ -652,39 +608,26 @@ def _parse_arguments() -> argparse.Namespace:
epilog='See simple_backup(1) manpage for full documentation',
formatter_class=MyFormatter)
parser.add_argument('-v', '--verbose', action='store_true',
help='More verbose output')
parser.add_argument('-v', '--verbose', action='store_true', help='More verbose output')
parser.add_argument('-c', '--config', default=f'{homedir}/.config/simple_backup/simple_backup.conf',
help='Specify location of configuration file')
parser.add_argument('-i', '--inputs', nargs='+',
help='Paths/files to backup')
parser.add_argument(
'-o', '--output', help='Output directory for the backup')
parser.add_argument('-e', '--exclude', nargs='+',
help='Files/directories/patterns to exclude from the backup')
parser.add_argument('-k', '--keep', type=int,
help='Number of old backups to keep')
parser.add_argument(
'-u', '--user', help='Explicitly specify the user running the backup')
parser.add_argument('-s', '--checksum', action='store_true',
help='Use checksum rsync option to compare files')
parser.add_argument(
'--ssh-host', help='Server hostname (for remote backup)')
parser.add_argument(
'--ssh-user', help='Username to connect to server (for remote backup)')
parser.add_argument('-i', '--inputs', nargs='+', help='Paths/files to backup')
parser.add_argument('-o', '--output', help='Output directory for the backup')
parser.add_argument('-e', '--exclude', nargs='+', help='Files/directories/patterns to exclude from the backup')
parser.add_argument('-k', '--keep', type=int, help='Number of old backups to keep')
parser.add_argument('-u', '--user', help='Explicitly specify the user running the backup')
parser.add_argument('-s', '--checksum', action='store_true', help='Use checksum rsync option to compare files')
parser.add_argument('--ssh-host', help='Server hostname (for remote backup)')
parser.add_argument('--ssh-user', help='Username to connect to server (for remote backup)')
parser.add_argument('--keyfile', help='SSH key location')
parser.add_argument('-z', '--compress', action='store_true',
help='Compress data during the transfer')
parser.add_argument('-z', '--compress', action='store_true', help='Compress data during the transfer')
parser.add_argument('--remove-before-backup', action='store_true',
help='Remove old backups before executing the backup, instead of after')
parser.add_argument('--no-syslog', action='store_true',
help='Disable systemd journal logging')
parser.add_argument('--no-syslog', action='store_true', help='Disable systemd journal logging')
parser.add_argument('--rsync-options', nargs='+',
choices=['a', 'l', 'p', 't', 'g', 'o',
'c', 'h', 's', 'D', 'H', 'X'],
choices=['a', 'l', 'p', 't', 'g', 'o', 'c', 'h', 's', 'D', 'H', 'X'],
help='Specify options for rsync')
parser.add_argument('--remote-sudo', action='store_true',
help='Run rsync on remote server with sudo if allowed')
parser.add_argument('--remote-sudo', action='store_true', help='Run rsync on remote server with sudo if allowed')
parser.add_argument('--numeric-ids', action='store_true',
help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name)')
@ -693,7 +636,7 @@ def _parse_arguments() -> argparse.Namespace:
return args
def _expand_inputs(inputs, user: Optional[str] = None):
def _expand_inputs(inputs, user=None):
expanded_inputs = []
for i in inputs:
@ -709,15 +652,14 @@ def _expand_inputs(inputs, user: Optional[str] = None):
logger.warning('Cannot expand \'~\'. No user specified')
if len(i_ex) == 0:
logger.warning(
'No file or directory matching input %s. Skipping...', i)
logger.warning('No file or directory matching input %s. Skipping...', i)
else:
expanded_inputs.extend(i_ex)
return expanded_inputs
def _read_config(config_file, user: Optional[str] = None):
def _read_config(config_file, user=None):
config_args = {'inputs': None,
'output': None,
'exclude': None,
@ -729,11 +671,7 @@ def _read_config(config_file, user: Optional[str] = None):
'numeric_ids': False}
if not os.path.isfile(config_file):
if user is not None:
logger.warning('Config file %s does not exist', config_file)
else:
logger.warning(
'User not specified. Can\'t read configuration file')
logger.warning('Config file %s does not exist', config_file)
return config_args
@ -813,7 +751,7 @@ def _read_config(config_file, user: Optional[str] = None):
return config_args
def _notify(text: str) -> None:
def _notify(text):
euid = os.geteuid()
if euid == 0:
@ -827,15 +765,14 @@ def _notify(text: str) -> None:
os.seteuid(int(uid))
os.environ['DBUS_SESSION_BUS_ADDRESS'] = f'unix:path=/run/user/{uid}/bus'
obj = dbus.SessionBus().get_object('org.freedesktop.Notifications',
'/org/freedesktop/Notifications')
obj = dbus.SessionBus().get_object('org.freedesktop.Notifications', '/org/freedesktop/Notifications')
obj = dbus.Interface(obj, 'org.freedesktop.Notifications')
obj.Notify('', 0, '', 'simple_backup', text, [], {'urgency': 1}, 10000)
os.seteuid(int(euid))
def simple_backup() -> int:
def simple_backup():
"""Main"""
args = _parse_arguments()
@ -848,13 +785,7 @@ def simple_backup() -> int:
if euid == 0:
user = os.getenv('SUDO_USER')
if user is not None:
homedir = os.path.expanduser(f'~{user}')
else:
logger.warning(
'Failed to detect user. You can use -u/--user parameter to manually specify it')
homedir = None
homedir = os.path.expanduser(f'~{user}')
else:
user = os.getenv('USER')
homedir = os.getenv('HOME')
@ -872,7 +803,6 @@ def simple_backup() -> int:
config_args = _read_config(args.config, user)
except (configparser.NoSectionError, configparser.NoOptionError):
logger.critical('Bad configuration file')
return 6
inputs = args.inputs if args.inputs is not None else config_args['inputs']
@ -885,8 +815,7 @@ def simple_backup() -> int:
remote_sudo = args.remote_sudo or config_args['remote_sudo']
if args.rsync_options is None:
rsync_options = ['-a', '-r', '-v', '-h', '-H',
'-X', '-s', '--ignore-missing-args', '--mkpath']
rsync_options = ['-a', '-r', '-v', '-h', '-H', '-X', '-s', '--ignore-missing-args', '--mkpath']
else:
rsync_options = ['-r', '-v']