Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cbca8e4d8
|
||
|
|
54880651be
|
||
|
|
793a30fad8
|
||
|
|
871dd0f49a
|
||
|
|
30e4416f8f
|
||
|
|
d0f888cbb4
|
||
|
|
7ef29cf551
|
||
|
|
4f5499eba9
|
||
|
|
fc165fccbf
|
||
|
|
d79ea56c0f
|
||
|
|
5189910131
|
||
|
|
7d4c43f8ff
|
||
|
|
ac7e6ec21b
|
||
|
|
578d992a19
|
||
|
|
e2d8897a45
|
||
|
|
dadc4b0265
|
||
|
|
7e7d11c520
|
||
|
|
22e0556b50
|
||
|
|
2b91178e94
|
||
|
|
2c2649113d
|
||
|
|
0f4a100ac7
|
||
|
|
60d83151dc
|
||
|
|
47aec456be
|
||
|
|
c715188973
|
||
|
|
57613649c2
|
||
|
|
67dbb49a67
|
||
|
|
e62a668f3e
|
||
|
|
bd8934d57f
|
||
|
|
96fe3c7813
|
||
|
|
9fdb959540
|
||
|
|
eb889beee7
|
||
|
|
2a37eb4172
|
||
|
|
45a07205a1
|
@@ -0,0 +1,38 @@
|
||||
# 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.7
|
||||
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')
|
||||
depends=('python>=3.10'
|
||||
'rsync')
|
||||
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=('011cd5546745e413ac7603b64531def2688c2bf6e4ea19876a67547a1db208ce')
|
||||
|
||||
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
|
||||
}
|
||||
@@ -20,21 +20,19 @@ The script uses rsync to actually run the backup, so you will have to install it
|
||||
sudo pacman -Syu rsync
|
||||
```
|
||||
|
||||
It's also required to have python-dotenv
|
||||
|
||||
Optional dependencies are systemd-python to enable using systemd journal for logging, and dbus-python for desktop notifications.
|
||||
Optional dependencies are systemd-python to enable using systemd journal for logging, dbus-python for desktop notifications, and paramiko for remote backup.
|
||||
|
||||
## Install
|
||||
To install the program, first clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Fuxino/simple_backup.git
|
||||
git clone https://git.shouldnt.work/fuxino/simple_backup.git
|
||||
```
|
||||
|
||||
Then install the tools required to build the package:
|
||||
|
||||
```bash
|
||||
pip install --upgrade build wheel
|
||||
pip install --upgrade build
|
||||
```
|
||||
|
||||
Finally, run:
|
||||
@@ -49,11 +47,16 @@ For Arch Linux and Arch-based distros, two packages are available in the AUR (au
|
||||
- **simple_backup** for the release version
|
||||
- **simple_backup-git** for the git version
|
||||
|
||||
## Old backups
|
||||
The number of old backups to keep can be set with the --keep (or -k) argument (or in the configuration file).
|
||||
Old backups are only removed if rsync completes successfully, so that they are not deleted when the new backup may be incomplete.
|
||||
The only rsync error that is ignored is return code 24 (source files vanished during the transfer), which is common when backing up a live system.
|
||||
|
||||
## Remote backup
|
||||
> **Warning**
|
||||
> This feature is experimental
|
||||
|
||||
It's possible to use a remote server as destination for the backup. Just use the --username (or -u) and --host arguments (or set them in the configuration file).
|
||||
It's possible to use a remote server as destination for the backup. Just use the --ssh-user and --ssh-host arguments (or set them in the configuration file).
|
||||
For this to work, rsync must be installed on the server too.
|
||||
|
||||
### Server authentication
|
||||
|
||||
+19
-4
@@ -1,4 +1,4 @@
|
||||
.TH SIMPLE_BACKUP 1 2023-06-15 SIMPLE_BACKUP 3.2.6
|
||||
.TH SIMPLE_BACKUP 1 2026-10-08 SIMPLE_BACKUP 4.2.0
|
||||
.SH NAME
|
||||
simple_backup \- Backup files and folders using rsync
|
||||
.SH SYNOPSIS
|
||||
@@ -52,7 +52,15 @@ or to use single or double quotes around them.
|
||||
.B \-o, \-\-output DIR
|
||||
Specify the directory where the files will be copied. The program will automatically
|
||||
create a subdirectory called \(aqsimple_backup\(aq (if it does not already exist) and
|
||||
inside this directory the actual backup directory (using the current date and time).
|
||||
inside this directory the actual backup directory (using the current date and time, for example
|
||||
\(aq2026\-10\-08_17\-34\-47\(aq).
|
||||
.RS
|
||||
.P
|
||||
Backups are best stored on a Linux filesystem (e.g. ext4 or btrfs). FAT and exFAT filesystems
|
||||
are supported, but they don\(aqt support hard links, so each backup will be a full copy instead
|
||||
of only storing files that changed since the previous one. File ownership and permissions are
|
||||
not preserved either.
|
||||
.RE
|
||||
.TP
|
||||
.B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
|
||||
Specify files, directories or patterns to exclude from the backup. Matching files and directories
|
||||
@@ -61,6 +69,10 @@ will not be copied. Multiple elements can be specified, in the same way as for t
|
||||
.B \-k, \-\-keep N
|
||||
Specify how many old backups (so excluding the current one) will be kept. The default behavior
|
||||
is to keep them all (same as N=\-1).
|
||||
Old backups are only removed if rsync completes successfully, to avoid deleting them when the
|
||||
new backup may be incomplete. rsync return code 24 (source files vanished during the transfer)
|
||||
is treated as success, while any other error (including return code 23, partial transfer due to error)
|
||||
will prevent old backups from being removed. This does not apply when using \-\-remove\-before\-backup.
|
||||
.TP
|
||||
.B \-u, \-\-user USERNAME
|
||||
Explicitly specify the user running the backup (in case it is needed for home directory expansion).
|
||||
@@ -86,6 +98,9 @@ if saving bandwith is needed.
|
||||
Remove old backups (if necessary) before creating the new backup. Useful to free some space
|
||||
before performing the backup.
|
||||
Default behavior is to remove old backups after successfully completing the backup.
|
||||
Note that if the backup fails after old backups have been removed, only the backups kept
|
||||
according to \-\-keep will be left, and the new backup may be incomplete. In particular, with
|
||||
\-\-keep 0 there will be no complete backup available until the next successful one.
|
||||
.TP
|
||||
.B \-\-no\-syslog
|
||||
Don't use systemd journal for logging.
|
||||
@@ -100,7 +115,7 @@ Using \-\-rsync\-options it is possible to manually select which options to use.
|
||||
.P
|
||||
\-a, \-l, \-p, \-t, \-g, \-o, \-c, \-h, \-D, \-H, \-X, \-s
|
||||
.P
|
||||
Options \-r and \-v are used in any case. Not that options must be specified without dash (\-), for example:
|
||||
Options \-r, \-v, \-\-ignore\-missing\-args and \-\-mkpath are used in any case. Note that options must be specified without dash (\-), for example:
|
||||
.P
|
||||
.EX
|
||||
simple_backup \-\-rsync\-options a l p
|
||||
@@ -187,6 +202,6 @@ Bad configuration file.
|
||||
.SH SEE ALSO
|
||||
.BR rsync (1)
|
||||
.SH AUTHORS
|
||||
.MT https://github.com/Fuxino
|
||||
.MT https://git.shouldnt.work/fuxino
|
||||
Daniele Fucini
|
||||
.ME
|
||||
|
||||
+46
-1
@@ -1,3 +1,48 @@
|
||||
[build-system]
|
||||
requires = ['setuptools']
|
||||
requires = ['setuptools>=77']
|
||||
build-backend = 'setuptools.build_meta'
|
||||
|
||||
[project]
|
||||
name = "simple_backup"
|
||||
dynamic = ["version"]
|
||||
description = "Simple backup script that uses rsync to copy files"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "GPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
authors = [
|
||||
{ name = "Daniele Fucini", email = "dfucini@gmail.com" }
|
||||
]
|
||||
maintainers = [
|
||||
{ name = "Daniele Fucini", email = "dfucini@gmail.com" }
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: System :: Archiving :: Backup",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
JOURNAL = ["systemd-python"]
|
||||
NOTIFICATIONS = ["dbus-python"]
|
||||
REMOTE = ["paramiko"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.shouldnt.work/fuxino/simple_backup"
|
||||
|
||||
[project.scripts]
|
||||
simple_backup = "simple_backup.simple_backup:simple_backup"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["simple_backup"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "simple_backup.__version__" }
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
[metadata]
|
||||
name = simple_backup
|
||||
version = attr: simple_backup.__version__
|
||||
description = Simple backup script using rsync
|
||||
long_description = file: README.md
|
||||
author = Daniele Fucini
|
||||
author_email = dfucini@gmail.com
|
||||
license = GPL3
|
||||
url = https://github.com/Fuxino/simple_backup
|
||||
classifiers =
|
||||
Development Status :: 4 - Beta
|
||||
Environment :: Console
|
||||
License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
||||
Natural Language :: English
|
||||
Operating System :: POSIX :: Linux
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: 3.11
|
||||
Programming Language :: Python :: 3.12
|
||||
Topic :: System :: Archiving :: Backup
|
||||
|
||||
[options]
|
||||
packages = simple_backup
|
||||
python_requires = >=3.10
|
||||
install_requires =
|
||||
python-dotenv
|
||||
|
||||
[options.extras_require]
|
||||
JOURNAL =
|
||||
systemd-python
|
||||
NOTIFICATIONS =
|
||||
dbus-python
|
||||
REMOTE =
|
||||
paramiko
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
simple_backup = simple_backup.simple_backup:simple_backup
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Init."""
|
||||
|
||||
__version__ = '4.1.2'
|
||||
__version__ = '4.2.0'
|
||||
|
||||
@@ -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/my_home,/etc
|
||||
inputs=/home/user
|
||||
|
||||
# Output directory.
|
||||
backup_dir=/media/Backup
|
||||
|
||||
+299
-162
@@ -11,9 +11,14 @@ Classes:
|
||||
Backup
|
||||
"""
|
||||
|
||||
# Avoid evaluating annotations at definition time, as they reference optional modules (paramiko)
|
||||
from __future__ import annotations
|
||||
|
||||
# Import libraries
|
||||
import sys
|
||||
import os
|
||||
import pwd
|
||||
from typing import Callable, List, Optional, ParamSpec, TypeVar, Union
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from shutil import rmtree, which
|
||||
@@ -29,10 +34,6 @@ from tempfile import mkstemp
|
||||
from getpass import GetPassWarning, getpass
|
||||
from glob import glob
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
warnings.filterwarnings('error')
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
from paramiko import RSAKey, Ed25519Key, ECDSAKey, DSSKey
|
||||
@@ -47,10 +48,9 @@ except ImportError:
|
||||
try:
|
||||
import dbus
|
||||
except ImportError:
|
||||
pass
|
||||
dbus = None
|
||||
|
||||
|
||||
load_dotenv()
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logger = logging.getLogger(os.path.basename(__file__))
|
||||
c_handler = StreamHandler()
|
||||
@@ -67,29 +67,29 @@ if journal:
|
||||
j_handler.setFormatter(j_format)
|
||||
logger.addHandler(j_handler)
|
||||
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
def timing(_logger):
|
||||
|
||||
def timing(func: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Decorator to measure execution time of a function
|
||||
|
||||
Parameters:
|
||||
_logger: Logger object
|
||||
func: Function to decorate
|
||||
"""
|
||||
def decorator_timing(func):
|
||||
@wraps(func)
|
||||
def wrapper_timing(*args, **kwargs):
|
||||
start = default_timer()
|
||||
@wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
start = default_timer()
|
||||
|
||||
value = func(*args, **kwargs)
|
||||
value = func(*args, **kwargs)
|
||||
|
||||
end = default_timer()
|
||||
end = default_timer()
|
||||
|
||||
_logger.info(f'Elapsed time: {end - start:.3f} seconds')
|
||||
logger.info('Elapsed time: %.3f seconds', end - start)
|
||||
|
||||
return value
|
||||
return value
|
||||
|
||||
return wrapper_timing
|
||||
|
||||
return decorator_timing
|
||||
return wrapper
|
||||
|
||||
|
||||
class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
|
||||
@@ -134,8 +134,9 @@ class Backup:
|
||||
Perform the backup
|
||||
"""
|
||||
|
||||
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):
|
||||
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:
|
||||
self.inputs = inputs
|
||||
self.output = output
|
||||
self.exclude = exclude
|
||||
@@ -152,21 +153,24 @@ class Backup:
|
||||
self._output_dir = ''
|
||||
self._inputs_path = ''
|
||||
self._exclude_path = ''
|
||||
self._remote = None
|
||||
self._remote = False
|
||||
self._ssh = None
|
||||
self._password_auth = False
|
||||
self._password = None
|
||||
self._removed_count = 0
|
||||
|
||||
def check_params(self, homedir=''):
|
||||
def check_params(self, homedir: str = '') -> int:
|
||||
"""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
|
||||
|
||||
@@ -179,7 +183,8 @@ 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()
|
||||
|
||||
@@ -201,19 +206,24 @@ class Backup:
|
||||
return 0
|
||||
|
||||
# Function to create the actual backup directory
|
||||
def define_backup_dir(self):
|
||||
def define_backup_dir(self) -> None:
|
||||
"""Define the actual backup dir"""
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
# Avoid ':', which is not allowed on FAT/exFAT filesystems.
|
||||
# Backups created with the old format ('%Y-%m-%d %H:%M:%S') still sort correctly
|
||||
now = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
self._output_dir = f'{self.output}/simple_backup/{now}'
|
||||
|
||||
if self._remote:
|
||||
self._server = f'{self.ssh_user}@{self.ssh_host}:'
|
||||
|
||||
def remove_old_backups(self):
|
||||
def remove_old_backups(self) -> None:
|
||||
"""Remove old backups if there are more than indicated by 'keep'"""
|
||||
|
||||
if self._remote:
|
||||
_, stdout, _ = self._ssh.exec_command(f'ls {self.output}/simple_backup')
|
||||
assert self._ssh is not None
|
||||
|
||||
_, stdout, _ = self._ssh.exec_command(
|
||||
f'ls {self.output}/simple_backup')
|
||||
|
||||
dirs = stdout.read().decode('utf-8').strip().split('\n')
|
||||
|
||||
@@ -230,14 +240,17 @@ 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
|
||||
@@ -263,16 +276,20 @@ 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])
|
||||
|
||||
self._removed_count = count
|
||||
|
||||
if count == 1:
|
||||
logger.info('Removed %d backup', count)
|
||||
elif count > 1:
|
||||
logger.info('Removed %d backups', count)
|
||||
|
||||
def find_last_backup(self):
|
||||
def find_last_backup(self) -> None:
|
||||
"""Get path of last backup (from last_backup symlink) for rsync --link-dest"""
|
||||
|
||||
if self._remote:
|
||||
@@ -280,7 +297,8 @@ 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] != '':
|
||||
@@ -289,18 +307,17 @@ 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)')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Backup failed (check log for details)')
|
||||
|
||||
sys.exit(3)
|
||||
|
||||
@@ -309,26 +326,31 @@ class Backup:
|
||||
except IndexError:
|
||||
logger.info('No previous backups available')
|
||||
|
||||
def _ssh_connect(self, homedir=''):
|
||||
def _ssh_connect(self, homedir: str = '') -> Optional[paramiko.client.SSHClient]:
|
||||
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(f'Cannot find file {homedir}/.ssh/known_hosts')
|
||||
logger.warning('Cannot find file %s/.ssh/known_hosts', homedir)
|
||||
|
||||
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||
|
||||
try:
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user)
|
||||
# WarningPolicy emits a UserWarning for unknown host keys
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', UserWarning)
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user)
|
||||
|
||||
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())
|
||||
@@ -351,8 +373,14 @@ 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)
|
||||
# Fail instead of reading the password with echo if no terminal is available
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', GetPassWarning)
|
||||
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
|
||||
@@ -374,7 +402,8 @@ 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)
|
||||
@@ -386,7 +415,8 @@ 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
|
||||
|
||||
@@ -395,7 +425,8 @@ 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
|
||||
|
||||
@@ -404,7 +435,8 @@ 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
|
||||
|
||||
@@ -417,75 +449,97 @@ class Backup:
|
||||
|
||||
return ssh
|
||||
|
||||
def _returncode_log(self, returncode):
|
||||
def _returncode_log(self, returncode: int) -> None:
|
||||
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(logger)
|
||||
def run(self):
|
||||
@timing
|
||||
def run(self) -> int:
|
||||
"""Perform the backup"""
|
||||
|
||||
try:
|
||||
return self._run()
|
||||
finally:
|
||||
self._remove_temp_files()
|
||||
|
||||
def _remove_temp_files(self) -> None:
|
||||
for path in [self._inputs_path, self._exclude_path]:
|
||||
if path != '':
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def _run(self) -> int:
|
||||
logger.info('Starting backup...')
|
||||
|
||||
try:
|
||||
_notify('Starting backup...')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Starting backup...')
|
||||
|
||||
self.define_backup_dir()
|
||||
self.find_last_backup()
|
||||
|
||||
_, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
||||
fd, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
||||
count = 0
|
||||
|
||||
with open(self._inputs_path, 'w', encoding='utf-8') as fp:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as fp:
|
||||
for i in self.inputs:
|
||||
if not os.path.exists(i):
|
||||
logger.warning('Input %s not found. Skipping', i)
|
||||
else:
|
||||
fp.write(i)
|
||||
# rsync reads --files-from paths relative to the source ('/')
|
||||
fp.write(os.path.abspath(i))
|
||||
fp.write('\n')
|
||||
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')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Backup finished. No files copied')
|
||||
|
||||
return 1
|
||||
|
||||
_, self._exclude_path = mkstemp(prefix='tmp_exclude', text=True)
|
||||
fd, self._exclude_path = mkstemp(prefix='tmp_exclude', text=True)
|
||||
|
||||
with open(self._exclude_path, 'w', encoding='utf-8') as fp:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as fp:
|
||||
if self.exclude is not None:
|
||||
for e in self.exclude:
|
||||
fp.write(e)
|
||||
@@ -494,14 +548,17 @@ class Backup:
|
||||
if self.keep != -1 and self._remove_before:
|
||||
self.remove_old_backups()
|
||||
|
||||
# Only look for the last backup now, as it may have been removed above
|
||||
self.find_last_backup()
|
||||
|
||||
logger.info('Copying files. This may take a long time...')
|
||||
|
||||
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()
|
||||
|
||||
@@ -518,6 +575,7 @@ 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:
|
||||
@@ -544,90 +602,106 @@ class Backup:
|
||||
else:
|
||||
logger.error(output)
|
||||
|
||||
if self.keep != -1 and not self._remove_before:
|
||||
self.remove_old_backups()
|
||||
if self._removed_count > 0 and returncode not in [0, 24]:
|
||||
# With --remove-before-backup, old backups are already gone if the new one failed
|
||||
if self.keep == 0:
|
||||
message = 'All old backups were removed before the backup, and the new backup may be ' +\
|
||||
'incomplete. No complete backup is available'
|
||||
else:
|
||||
message = 'Old backups were removed before the backup, and the new backup may be incomplete'
|
||||
|
||||
os.remove(self._inputs_path)
|
||||
os.remove(self._exclude_path)
|
||||
logger.error(message)
|
||||
_notify(message)
|
||||
|
||||
if self.keep != -1 and not self._remove_before:
|
||||
# Don't delete old backups if the new one may be incomplete
|
||||
if returncode in [0, 24]:
|
||||
self.remove_old_backups()
|
||||
else:
|
||||
logger.warning('Backup not completed successfully. Old backups will not be removed')
|
||||
|
||||
if self._remote:
|
||||
_, stdout, _ = self._ssh.exec_command(f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi')
|
||||
assert self._ssh is not None
|
||||
|
||||
_, stdout, _ = self._ssh.exec_command(
|
||||
f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi')
|
||||
|
||||
output = stdout.read().decode('utf-8').strip()
|
||||
|
||||
if output == 'ok':
|
||||
logger.info('Backup completed')
|
||||
|
||||
try:
|
||||
_notify('Backup completed')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Backup completed')
|
||||
else:
|
||||
logger.error('Backup failed')
|
||||
|
||||
try:
|
||||
_notify('Backup failed (check log for details)')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Backup failed (check log for details)')
|
||||
|
||||
if self._ssh:
|
||||
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')
|
||||
except NameError:
|
||||
pass
|
||||
_notify(
|
||||
'Some errors occurred while performing the backup. Check log for details')
|
||||
|
||||
return 4
|
||||
|
||||
logger.info('Backup completed')
|
||||
|
||||
try:
|
||||
_notify('Backup completed')
|
||||
except NameError:
|
||||
pass
|
||||
_notify('Backup completed')
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_arguments():
|
||||
euid = os.geteuid()
|
||||
def _detect_user() -> Optional[str]:
|
||||
if os.geteuid() == 0:
|
||||
return os.getenv('SUDO_USER') or os.getenv('DOAS_USER')
|
||||
|
||||
if euid == 0:
|
||||
user = os.getenv('SUDO_USER')
|
||||
else:
|
||||
user = os.getenv('USER')
|
||||
return os.getenv('USER')
|
||||
|
||||
homedir = os.path.expanduser(f'~{user}')
|
||||
|
||||
def _parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(prog='simple_backup',
|
||||
description='Simple backup script written in Python that uses rsync to copy files',
|
||||
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('-c', '--config', default=f'{homedir}/.config/simple_backup/simple_backup.conf',
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help='More verbose output')
|
||||
parser.add_argument('-c', '--config', default='~/.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)')
|
||||
|
||||
@@ -636,7 +710,16 @@ def _parse_arguments():
|
||||
return args
|
||||
|
||||
|
||||
def _expand_inputs(inputs, user=None):
|
||||
def _expand_home(path: str, user: str) -> str:
|
||||
"""Expand a leading '~' to the home directory of user (other '~' are left as they are)"""
|
||||
|
||||
if path == '~' or path.startswith('~/'):
|
||||
path = f'~{user}{path[1:]}'
|
||||
|
||||
return os.path.expanduser(path)
|
||||
|
||||
|
||||
def _expand_inputs(inputs, user: Optional[str] = None):
|
||||
expanded_inputs = []
|
||||
|
||||
for i in inputs:
|
||||
@@ -644,22 +727,23 @@ def _expand_inputs(inputs, user=None):
|
||||
continue
|
||||
|
||||
if user is not None:
|
||||
i_ex = glob(os.path.expanduser(i.replace('~', f'~{user}')))
|
||||
i_ex = glob(_expand_home(i, user))
|
||||
else:
|
||||
i_ex = glob(i)
|
||||
|
||||
if '~' in i:
|
||||
if i.startswith('~'):
|
||||
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=None):
|
||||
def _read_config(config_file, user: Optional[str] = None):
|
||||
config_args = {'inputs': None,
|
||||
'output': None,
|
||||
'exclude': None,
|
||||
@@ -671,11 +755,16 @@ def _read_config(config_file, user=None):
|
||||
'numeric_ids': False}
|
||||
|
||||
if not os.path.isfile(config_file):
|
||||
logger.warning('Config file %s does not exist', 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')
|
||||
|
||||
return config_args
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
# Disable interpolation, so that '%' can be used in paths and patterns
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
config.read(config_file)
|
||||
|
||||
section = 'backup'
|
||||
@@ -687,7 +776,7 @@ def _read_config(config_file, user=None):
|
||||
section = 'default'
|
||||
inputs = config.get(section, 'inputs')
|
||||
|
||||
inputs = inputs.split(',')
|
||||
inputs = [i.strip() for i in inputs.split(',')]
|
||||
inputs = _expand_inputs(inputs, user)
|
||||
inputs = list(set(inputs))
|
||||
|
||||
@@ -696,15 +785,15 @@ def _read_config(config_file, user=None):
|
||||
output = config.get(section, 'backup_dir')
|
||||
|
||||
if user is not None:
|
||||
output = os.path.expanduser(output.replace('~', f'~{user}'))
|
||||
elif user is None and '~' in output:
|
||||
output = _expand_home(output, user)
|
||||
elif user is None and output.startswith('~'):
|
||||
logger.warning('Cannot expand \'~\', no user specified')
|
||||
|
||||
config_args['output'] = output
|
||||
|
||||
try:
|
||||
exclude = config.get(section, 'exclude')
|
||||
exclude = exclude.split(',')
|
||||
exclude = [e.strip() for e in exclude.split(',') if e.strip() != '']
|
||||
except configparser.NoOptionError:
|
||||
exclude = []
|
||||
|
||||
@@ -751,28 +840,59 @@ def _read_config(config_file, user=None):
|
||||
return config_args
|
||||
|
||||
|
||||
def _notify(text):
|
||||
def _target_uid() -> int | None:
|
||||
euid = os.geteuid()
|
||||
|
||||
if euid == 0:
|
||||
uid = os.getenv('SUDO_UID')
|
||||
else:
|
||||
uid = euid
|
||||
if euid != 0:
|
||||
return euid
|
||||
|
||||
if uid is None:
|
||||
if (val := os.getenv('SUDO_UID')) is not None:
|
||||
return int(val)
|
||||
|
||||
if (name := os.getenv('DOAS_USER')) is not None:
|
||||
try:
|
||||
return pwd.getpwnam(name).pw_uid
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
_notify_failed = False
|
||||
|
||||
|
||||
def _notify(text: str) -> None:
|
||||
global _notify_failed
|
||||
|
||||
if dbus is None or _notify_failed:
|
||||
return
|
||||
|
||||
os.seteuid(int(uid))
|
||||
os.environ['DBUS_SESSION_BUS_ADDRESS'] = f'unix:path=/run/user/{uid}/bus'
|
||||
euid = os.geteuid()
|
||||
uid = _target_uid()
|
||||
|
||||
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)
|
||||
if uid is None:
|
||||
logger.debug('Cannot send desktop notification: user not found')
|
||||
|
||||
os.seteuid(int(euid))
|
||||
return
|
||||
|
||||
os.seteuid(uid)
|
||||
|
||||
try:
|
||||
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.Interface(obj, 'org.freedesktop.Notifications')
|
||||
obj.Notify('simple_backup', 0, '', 'simple_backup', text, [], {'urgency': dbus.Byte(1)}, 10000)
|
||||
except Exception as e:
|
||||
# Report the failure only once (e.g. no session bus available), then stop trying
|
||||
_notify_failed = True
|
||||
logger.warning('Cannot send desktop notification: %s', e)
|
||||
finally:
|
||||
os.seteuid(euid)
|
||||
|
||||
|
||||
def simple_backup():
|
||||
def simple_backup() -> int:
|
||||
"""Main"""
|
||||
|
||||
args = _parse_arguments()
|
||||
@@ -784,8 +904,14 @@ def simple_backup():
|
||||
euid = os.geteuid()
|
||||
|
||||
if euid == 0:
|
||||
user = os.getenv('SUDO_USER')
|
||||
homedir = os.path.expanduser(f'~{user}')
|
||||
user = _detect_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
|
||||
else:
|
||||
user = os.getenv('USER')
|
||||
homedir = os.getenv('HOME')
|
||||
@@ -799,10 +925,20 @@ def simple_backup():
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
config_file = args.config
|
||||
|
||||
# Expand '~' to the home of the user running the backup, not root's
|
||||
if config_file.startswith('~/') and user is not None:
|
||||
config_file = f'~{user}{config_file[1:]}'
|
||||
|
||||
config_file = os.path.expanduser(config_file)
|
||||
|
||||
try:
|
||||
config_args = _read_config(args.config, user)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError):
|
||||
logger.critical('Bad configuration file')
|
||||
config_args = _read_config(config_file, user)
|
||||
except (configparser.Error, ValueError) as e:
|
||||
# configparser.Error: missing sections/options or bad syntax, ValueError: invalid values (e.g. keep=abc)
|
||||
logger.critical('Bad configuration file: %s', e)
|
||||
|
||||
return 6
|
||||
|
||||
inputs = args.inputs if args.inputs is not None else config_args['inputs']
|
||||
@@ -814,11 +950,12 @@ def simple_backup():
|
||||
ssh_keyfile = args.keyfile if args.keyfile is not None else config_args['ssh_keyfile']
|
||||
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']
|
||||
else:
|
||||
rsync_options = ['-r', '-v']
|
||||
# Options needed in any case (--mkpath creates the backup directory on the first run)
|
||||
rsync_options = ['-r', '-v', '--ignore-missing-args', '--mkpath']
|
||||
|
||||
if args.rsync_options is None:
|
||||
rsync_options.extend(['-a', '-h', '-H', '-X', '-s'])
|
||||
else:
|
||||
for ro in args.rsync_options:
|
||||
rsync_options.append(f'-{ro}')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user