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
|
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
pkgname=simple_backup
|
pkgname=simple_backup
|
||||||
pkgdesc='Simple backup script that uses rsync to copy files'
|
pkgdesc='Simple backup script that uses rsync to copy files'
|
||||||
pkgver=4.1.6
|
pkgver=4.1.7
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
url="https://git.shouldnt.work/fuxino/${pkgname}"
|
url="https://git.shouldnt.work/fuxino/${pkgname}"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
@@ -12,18 +12,16 @@ license=('GPL-3.0-or-later')
|
|||||||
makedepends=('git'
|
makedepends=('git'
|
||||||
'python-setuptools'
|
'python-setuptools'
|
||||||
'python-build'
|
'python-build'
|
||||||
'python-installer'
|
'python-installer')
|
||||||
'python-wheel')
|
|
||||||
depends=('python>=3.10'
|
depends=('python>=3.10'
|
||||||
'rsync'
|
'rsync')
|
||||||
'python-dotenv')
|
|
||||||
optdepends=('python-systemd: use systemd log'
|
optdepends=('python-systemd: use systemd log'
|
||||||
'python-dbus: for desktop notifications'
|
'python-dbus: for desktop notifications'
|
||||||
'python-paramiko: for remote backup through ssh')
|
'python-paramiko: for remote backup through ssh')
|
||||||
conflicts=('simple_backup-git')
|
conflicts=('simple_backup-git')
|
||||||
source=(git+${url}?signed#tag=${pkgver})
|
source=(git+${url}?signed#tag=${pkgver})
|
||||||
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
||||||
sha256sums=('b3b29d9e2e1b7b949e95674d9a401e8eeb0d5f898e8450473dce94f799ee9df3')
|
sha256sums=('011cd5546745e413ac7603b64531def2688c2bf6e4ea19876a67547a1db208ce')
|
||||||
|
|
||||||
build()
|
build()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,21 +20,19 @@ The script uses rsync to actually run the backup, so you will have to install it
|
|||||||
sudo pacman -Syu rsync
|
sudo pacman -Syu rsync
|
||||||
```
|
```
|
||||||
|
|
||||||
It's also required to have python-dotenv
|
Optional dependencies are systemd-python to enable using systemd journal for logging, dbus-python for desktop notifications, and paramiko for remote backup.
|
||||||
|
|
||||||
Optional dependencies are systemd-python to enable using systemd journal for logging, and dbus-python for desktop notifications.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
To install the program, first clone the repository:
|
To install the program, first clone the repository:
|
||||||
|
|
||||||
```bash
|
```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:
|
Then install the tools required to build the package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install --upgrade build wheel
|
pip install --upgrade build
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, run:
|
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** for the release version
|
||||||
- **simple_backup-git** for the git 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
|
## Remote backup
|
||||||
> **Warning**
|
> **Warning**
|
||||||
> This feature is experimental
|
> 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.
|
For this to work, rsync must be installed on the server too.
|
||||||
|
|
||||||
### Server authentication
|
### Server authentication
|
||||||
|
|||||||
+18
-3
@@ -1,4 +1,4 @@
|
|||||||
.TH SIMPLE_BACKUP 1 2025-03-30 SIMPLE_BACKUP 4.1.6
|
.TH SIMPLE_BACKUP 1 2026-10-08 SIMPLE_BACKUP 4.2.0
|
||||||
.SH NAME
|
.SH NAME
|
||||||
simple_backup \- Backup files and folders using rsync
|
simple_backup \- Backup files and folders using rsync
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -52,7 +52,15 @@ or to use single or double quotes around them.
|
|||||||
.B \-o, \-\-output DIR
|
.B \-o, \-\-output DIR
|
||||||
Specify the directory where the files will be copied. The program will automatically
|
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
|
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
|
.TP
|
||||||
.B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
|
.B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
|
||||||
Specify files, directories or patterns to exclude from the backup. Matching files and directories
|
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
|
.B \-k, \-\-keep N
|
||||||
Specify how many old backups (so excluding the current one) will be kept. The default behavior
|
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).
|
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
|
.TP
|
||||||
.B \-u, \-\-user USERNAME
|
.B \-u, \-\-user USERNAME
|
||||||
Explicitly specify the user running the backup (in case it is needed for home directory expansion).
|
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
|
Remove old backups (if necessary) before creating the new backup. Useful to free some space
|
||||||
before performing the backup.
|
before performing the backup.
|
||||||
Default behavior is to remove old backups after successfully completing 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
|
.TP
|
||||||
.B \-\-no\-syslog
|
.B \-\-no\-syslog
|
||||||
Don't use systemd journal for logging.
|
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
|
.P
|
||||||
\-a, \-l, \-p, \-t, \-g, \-o, \-c, \-h, \-D, \-H, \-X, \-s
|
\-a, \-l, \-p, \-t, \-g, \-o, \-c, \-h, \-D, \-H, \-X, \-s
|
||||||
.P
|
.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
|
.P
|
||||||
.EX
|
.EX
|
||||||
simple_backup \-\-rsync\-options a l p
|
simple_backup \-\-rsync\-options a l p
|
||||||
|
|||||||
+46
-1
@@ -1,3 +1,48 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ['setuptools']
|
requires = ['setuptools>=77']
|
||||||
build-backend = 'setuptools.build_meta'
|
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,39 +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://git.shouldnt.work/fuxino
|
|
||||||
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
|
|
||||||
Programming Language :: Python :: 3.13
|
|
||||||
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."""
|
"""Init."""
|
||||||
|
|
||||||
__version__ = '4.1.6'
|
__version__ = '4.2.0'
|
||||||
|
|||||||
+115
-71
@@ -11,8 +11,10 @@ Classes:
|
|||||||
Backup
|
Backup
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Avoid evaluating annotations at definition time, as they reference optional modules (paramiko)
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
# Import libraries
|
# Import libraries
|
||||||
import traceback
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
@@ -32,10 +34,6 @@ from tempfile import mkstemp
|
|||||||
from getpass import GetPassWarning, getpass
|
from getpass import GetPassWarning, getpass
|
||||||
from glob import glob
|
from glob import glob
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
warnings.filterwarnings('error')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import paramiko
|
import paramiko
|
||||||
from paramiko import RSAKey, Ed25519Key, ECDSAKey, DSSKey
|
from paramiko import RSAKey, Ed25519Key, ECDSAKey, DSSKey
|
||||||
@@ -50,10 +48,9 @@ except ImportError:
|
|||||||
try:
|
try:
|
||||||
import dbus
|
import dbus
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
dbus = None
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
logger = logging.getLogger(os.path.basename(__file__))
|
logger = logging.getLogger(os.path.basename(__file__))
|
||||||
c_handler = StreamHandler()
|
c_handler = StreamHandler()
|
||||||
@@ -160,6 +157,7 @@ class Backup:
|
|||||||
self._ssh = None
|
self._ssh = None
|
||||||
self._password_auth = False
|
self._password_auth = False
|
||||||
self._password = None
|
self._password = None
|
||||||
|
self._removed_count = 0
|
||||||
|
|
||||||
def check_params(self, homedir: str = '') -> int:
|
def check_params(self, homedir: str = '') -> int:
|
||||||
"""Check if parameters for the backup are valid"""
|
"""Check if parameters for the backup are valid"""
|
||||||
@@ -210,7 +208,9 @@ class Backup:
|
|||||||
# Function to create the actual backup directory
|
# Function to create the actual backup directory
|
||||||
def define_backup_dir(self) -> None:
|
def define_backup_dir(self) -> None:
|
||||||
"""Define the actual backup dir"""
|
"""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}'
|
self._output_dir = f'{self.output}/simple_backup/{now}'
|
||||||
|
|
||||||
if self._remote:
|
if self._remote:
|
||||||
@@ -282,6 +282,8 @@ class Backup:
|
|||||||
logger.error(
|
logger.error(
|
||||||
'Error while removing backup %s. Permission denied', dirs[i])
|
'Error while removing backup %s. Permission denied', dirs[i])
|
||||||
|
|
||||||
|
self._removed_count = count
|
||||||
|
|
||||||
if count == 1:
|
if count == 1:
|
||||||
logger.info('Removed %d backup', count)
|
logger.info('Removed %d backup', count)
|
||||||
elif count > 1:
|
elif count > 1:
|
||||||
@@ -315,10 +317,7 @@ class Backup:
|
|||||||
logger.critical(
|
logger.critical(
|
||||||
'Cannot access the backup directory. Permission denied')
|
'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
|
|
||||||
|
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
@@ -327,7 +326,7 @@ class Backup:
|
|||||||
except IndexError:
|
except IndexError:
|
||||||
logger.info('No previous backups available')
|
logger.info('No previous backups available')
|
||||||
|
|
||||||
def _ssh_connect(self, homedir: str = '') -> paramiko.client.SSHClient:
|
def _ssh_connect(self, homedir: str = '') -> Optional[paramiko.client.SSHClient]:
|
||||||
try:
|
try:
|
||||||
ssh = paramiko.SSHClient()
|
ssh = paramiko.SSHClient()
|
||||||
except NameError:
|
except NameError:
|
||||||
@@ -343,6 +342,9 @@ class Backup:
|
|||||||
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
|
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 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)
|
ssh.connect(self.ssh_host, username=self.ssh_user)
|
||||||
|
|
||||||
return ssh
|
return ssh
|
||||||
@@ -371,8 +373,12 @@ class Backup:
|
|||||||
|
|
||||||
if self.ssh_keyfile is None:
|
if self.ssh_keyfile is None:
|
||||||
try:
|
try:
|
||||||
|
# Fail instead of reading the password with echo if no terminal is available
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter('error', GetPassWarning)
|
||||||
password = getpass(
|
password = getpass(
|
||||||
f'{self.ssh_user}@{self.ssh_host}\'s password: ')
|
f'{self.ssh_user}@{self.ssh_host}\'s password: ')
|
||||||
|
|
||||||
ssh.connect(self.ssh_host, username=self.ssh_user,
|
ssh.connect(self.ssh_host, username=self.ssh_user,
|
||||||
password=password)
|
password=password)
|
||||||
|
|
||||||
@@ -490,25 +496,36 @@ class Backup:
|
|||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
"""Perform the backup"""
|
"""Perform the backup"""
|
||||||
|
|
||||||
logger.info('Starting backup...')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_notify('Starting backup...')
|
return self._run()
|
||||||
except NameError:
|
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
|
pass
|
||||||
|
|
||||||
self.define_backup_dir()
|
def _run(self) -> int:
|
||||||
self.find_last_backup()
|
logger.info('Starting backup...')
|
||||||
|
|
||||||
_, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
_notify('Starting backup...')
|
||||||
|
|
||||||
|
self.define_backup_dir()
|
||||||
|
|
||||||
|
fd, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
||||||
count = 0
|
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:
|
for i in self.inputs:
|
||||||
if not os.path.exists(i):
|
if not os.path.exists(i):
|
||||||
logger.warning('Input %s not found. Skipping', i)
|
logger.warning('Input %s not found. Skipping', i)
|
||||||
else:
|
else:
|
||||||
fp.write(i)
|
# rsync reads --files-from paths relative to the source ('/')
|
||||||
|
fp.write(os.path.abspath(i))
|
||||||
fp.write('\n')
|
fp.write('\n')
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
@@ -516,16 +533,13 @@ class Backup:
|
|||||||
logger.info(
|
logger.info(
|
||||||
'No existing files or directories specified for backup. Nothing to do')
|
'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
|
|
||||||
|
|
||||||
return 1
|
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:
|
if self.exclude is not None:
|
||||||
for e in self.exclude:
|
for e in self.exclude:
|
||||||
fp.write(e)
|
fp.write(e)
|
||||||
@@ -534,6 +548,9 @@ class Backup:
|
|||||||
if self.keep != -1 and self._remove_before:
|
if self.keep != -1 and self._remove_before:
|
||||||
self.remove_old_backups()
|
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...')
|
logger.info('Copying files. This may take a long time...')
|
||||||
|
|
||||||
if self._last_backup == '':
|
if self._last_backup == '':
|
||||||
@@ -585,11 +602,23 @@ class Backup:
|
|||||||
else:
|
else:
|
||||||
logger.error(output)
|
logger.error(output)
|
||||||
|
|
||||||
if self.keep != -1 and not self._remove_before:
|
if self._removed_count > 0 and returncode not in [0, 24]:
|
||||||
self.remove_old_backups()
|
# 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)
|
logger.error(message)
|
||||||
os.remove(self._exclude_path)
|
_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:
|
if self._remote:
|
||||||
assert self._ssh is not None
|
assert self._ssh is not None
|
||||||
@@ -602,17 +631,11 @@ class Backup:
|
|||||||
if output == 'ok':
|
if output == 'ok':
|
||||||
logger.info('Backup completed')
|
logger.info('Backup completed')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify('Backup completed')
|
_notify('Backup completed')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
logger.error('Backup failed')
|
logger.error('Backup failed')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify('Backup failed (check log for details)')
|
_notify('Backup failed (check log for details)')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self._ssh:
|
if self._ssh:
|
||||||
self._ssh.close()
|
self._ssh.close()
|
||||||
@@ -621,34 +644,26 @@ class Backup:
|
|||||||
logger.error(
|
logger.error(
|
||||||
'Some errors occurred while performing the backup')
|
'Some errors occurred while performing the backup')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify(
|
_notify(
|
||||||
'Some errors occurred while performing the backup. Check log for details')
|
'Some errors occurred while performing the backup. Check log for details')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
logger.info('Backup completed')
|
logger.info('Backup completed')
|
||||||
|
|
||||||
try:
|
|
||||||
_notify('Backup completed')
|
_notify('Backup completed')
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_user() -> Optional[str]:
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
return os.getenv('SUDO_USER') or os.getenv('DOAS_USER')
|
||||||
|
|
||||||
|
return os.getenv('USER')
|
||||||
|
|
||||||
|
|
||||||
def _parse_arguments() -> argparse.Namespace:
|
def _parse_arguments() -> argparse.Namespace:
|
||||||
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',
|
parser = argparse.ArgumentParser(prog='simple_backup',
|
||||||
description='Simple backup script written in Python that uses rsync to copy files',
|
description='Simple backup script written in Python that uses rsync to copy files',
|
||||||
epilog='See simple_backup(1) manpage for full documentation',
|
epilog='See simple_backup(1) manpage for full documentation',
|
||||||
@@ -656,7 +671,7 @@ def _parse_arguments() -> argparse.Namespace:
|
|||||||
|
|
||||||
parser.add_argument('-v', '--verbose', action='store_true',
|
parser.add_argument('-v', '--verbose', action='store_true',
|
||||||
help='More verbose output')
|
help='More verbose output')
|
||||||
parser.add_argument('-c', '--config', default=f'{homedir}/.config/simple_backup/simple_backup.conf',
|
parser.add_argument('-c', '--config', default='~/.config/simple_backup/simple_backup.conf',
|
||||||
help='Specify location of configuration file')
|
help='Specify location of configuration file')
|
||||||
parser.add_argument('-i', '--inputs', nargs='+',
|
parser.add_argument('-i', '--inputs', nargs='+',
|
||||||
help='Paths/files to backup')
|
help='Paths/files to backup')
|
||||||
@@ -695,6 +710,15 @@ def _parse_arguments() -> argparse.Namespace:
|
|||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def _expand_inputs(inputs, user: Optional[str] = None):
|
||||||
expanded_inputs = []
|
expanded_inputs = []
|
||||||
|
|
||||||
@@ -703,11 +727,11 @@ def _expand_inputs(inputs, user: Optional[str] = None):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
i_ex = glob(os.path.expanduser(i.replace('~', f'~{user}')))
|
i_ex = glob(_expand_home(i, user))
|
||||||
else:
|
else:
|
||||||
i_ex = glob(i)
|
i_ex = glob(i)
|
||||||
|
|
||||||
if '~' in i:
|
if i.startswith('~'):
|
||||||
logger.warning('Cannot expand \'~\'. No user specified')
|
logger.warning('Cannot expand \'~\'. No user specified')
|
||||||
|
|
||||||
if len(i_ex) == 0:
|
if len(i_ex) == 0:
|
||||||
@@ -739,7 +763,8 @@ def _read_config(config_file, user: Optional[str] = None):
|
|||||||
|
|
||||||
return config_args
|
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)
|
config.read(config_file)
|
||||||
|
|
||||||
section = 'backup'
|
section = 'backup'
|
||||||
@@ -751,7 +776,7 @@ def _read_config(config_file, user: Optional[str] = None):
|
|||||||
section = 'default'
|
section = 'default'
|
||||||
inputs = config.get(section, 'inputs')
|
inputs = config.get(section, 'inputs')
|
||||||
|
|
||||||
inputs = inputs.split(',')
|
inputs = [i.strip() for i in inputs.split(',')]
|
||||||
inputs = _expand_inputs(inputs, user)
|
inputs = _expand_inputs(inputs, user)
|
||||||
inputs = list(set(inputs))
|
inputs = list(set(inputs))
|
||||||
|
|
||||||
@@ -760,15 +785,15 @@ def _read_config(config_file, user: Optional[str] = None):
|
|||||||
output = config.get(section, 'backup_dir')
|
output = config.get(section, 'backup_dir')
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
output = os.path.expanduser(output.replace('~', f'~{user}'))
|
output = _expand_home(output, user)
|
||||||
elif user is None and '~' in output:
|
elif user is None and output.startswith('~'):
|
||||||
logger.warning('Cannot expand \'~\', no user specified')
|
logger.warning('Cannot expand \'~\', no user specified')
|
||||||
|
|
||||||
config_args['output'] = output
|
config_args['output'] = output
|
||||||
|
|
||||||
try:
|
try:
|
||||||
exclude = config.get(section, 'exclude')
|
exclude = config.get(section, 'exclude')
|
||||||
exclude = exclude.split(',')
|
exclude = [e.strip() for e in exclude.split(',') if e.strip() != '']
|
||||||
except configparser.NoOptionError:
|
except configparser.NoOptionError:
|
||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
@@ -833,12 +858,20 @@ def _target_uid() -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_notify_failed = False
|
||||||
|
|
||||||
|
|
||||||
def _notify(text: str) -> None:
|
def _notify(text: str) -> None:
|
||||||
|
global _notify_failed
|
||||||
|
|
||||||
|
if dbus is None or _notify_failed:
|
||||||
|
return
|
||||||
|
|
||||||
euid = os.geteuid()
|
euid = os.geteuid()
|
||||||
uid = _target_uid()
|
uid = _target_uid()
|
||||||
|
|
||||||
if uid is None:
|
if uid is None:
|
||||||
print('notify: no target uid (neither SUDO_UID nor DOAS_USER set)', file=sys.stderr)
|
logger.debug('Cannot send desktop notification: user not found')
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -851,8 +884,10 @@ def _notify(text: str) -> None:
|
|||||||
'/org/freedesktop/Notifications')
|
'/org/freedesktop/Notifications')
|
||||||
obj = dbus.Interface(obj, 'org.freedesktop.Notifications')
|
obj = dbus.Interface(obj, 'org.freedesktop.Notifications')
|
||||||
obj.Notify('simple_backup', 0, '', 'simple_backup', text, [], {'urgency': dbus.Byte(1)}, 10000)
|
obj.Notify('simple_backup', 0, '', 'simple_backup', text, [], {'urgency': dbus.Byte(1)}, 10000)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
# 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:
|
finally:
|
||||||
os.seteuid(euid)
|
os.seteuid(euid)
|
||||||
|
|
||||||
@@ -869,7 +904,7 @@ def simple_backup() -> int:
|
|||||||
euid = os.geteuid()
|
euid = os.geteuid()
|
||||||
|
|
||||||
if euid == 0:
|
if euid == 0:
|
||||||
user = os.getenv('SUDO_USER')
|
user = _detect_user()
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
homedir = os.path.expanduser(f'~{user}')
|
homedir = os.path.expanduser(f'~{user}')
|
||||||
@@ -890,10 +925,19 @@ def simple_backup() -> int:
|
|||||||
except NameError:
|
except NameError:
|
||||||
pass
|
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:
|
try:
|
||||||
config_args = _read_config(args.config, user)
|
config_args = _read_config(config_file, user)
|
||||||
except (configparser.NoSectionError, configparser.NoOptionError):
|
except (configparser.Error, ValueError) as e:
|
||||||
logger.critical('Bad configuration file')
|
# configparser.Error: missing sections/options or bad syntax, ValueError: invalid values (e.g. keep=abc)
|
||||||
|
logger.critical('Bad configuration file: %s', e)
|
||||||
|
|
||||||
return 6
|
return 6
|
||||||
|
|
||||||
@@ -906,12 +950,12 @@ def simple_backup() -> int:
|
|||||||
ssh_keyfile = args.keyfile if args.keyfile is not None else config_args['ssh_keyfile']
|
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']
|
remote_sudo = args.remote_sudo or config_args['remote_sudo']
|
||||||
|
|
||||||
if args.rsync_options is None:
|
# Options needed in any case (--mkpath creates the backup directory on the first run)
|
||||||
rsync_options = ['-a', '-r', '-v', '-h', '-H',
|
rsync_options = ['-r', '-v', '--ignore-missing-args', '--mkpath']
|
||||||
'-X', '-s', '--ignore-missing-args', '--mkpath']
|
|
||||||
else:
|
|
||||||
rsync_options = ['-r', '-v']
|
|
||||||
|
|
||||||
|
if args.rsync_options is None:
|
||||||
|
rsync_options.extend(['-a', '-h', '-H', '-X', '-s'])
|
||||||
|
else:
|
||||||
for ro in args.rsync_options:
|
for ro in args.rsync_options:
|
||||||
rsync_options.append(f'-{ro}')
|
rsync_options.append(f'-{ro}')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user