Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d0fd46f44
|
||
|
|
b65955f9f6
|
||
|
|
6d54019a18
|
||
|
|
7547db1ea8
|
@@ -4,7 +4,7 @@
|
||||
|
||||
pkgname=simple_backup
|
||||
pkgdesc='Simple backup script that uses rsync to copy files'
|
||||
pkgver=4.4.1
|
||||
pkgver=4.6.0
|
||||
pkgrel=1
|
||||
url="https://git.shouldnt.work/fuxino/$pkgname"
|
||||
arch=('any')
|
||||
@@ -24,7 +24,7 @@ checkdepends=('python-pytest'
|
||||
conflicts=('simple_backup-git')
|
||||
source=("git+$url?signed#tag=$pkgver")
|
||||
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
||||
sha256sums=('cc5b378ac489f7cf3e3a38855dfd530a79f0651a1891ea83355b952ed3a20aee')
|
||||
sha256sums=('049c900b2d778434baa579409ccbb2b66ddb0968cf1dd23b3de0eda65ef3d2c6')
|
||||
|
||||
build()
|
||||
{
|
||||
|
||||
@@ -62,6 +62,17 @@ The number of old backups to keep can be set with the --keep (or -k) argument (o
|
||||
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.
|
||||
|
||||
## Last backup
|
||||
After each successful backup, simple_backup saves its time and directory to `~/.local/state/simple_backup/last_backup` (or `$XDG_STATE_HOME/simple_backup/last_backup`).
|
||||
When run with sudo or doas, the file goes to the home directory of the user who ran the command.
|
||||
Print it with:
|
||||
|
||||
```bash
|
||||
simple_backup --last-backup
|
||||
```
|
||||
|
||||
The file has two lines: the completion time in ISO 8601 format (e.g. `2026-10-25T14:03:12+02:00`) and the backup directory, so scripts (e.g. a status bar indicator) can easily read it to remind you when a backup is overdue.
|
||||
|
||||
## Remote backup
|
||||
> **Warning**
|
||||
> This feature is experimental
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
.TH SIMPLE_BACKUP 1 2026-10-21 SIMPLE_BACKUP 4.5.0
|
||||
.TH SIMPLE_BACKUP 1 2026-10-25 SIMPLE_BACKUP 4.6.0
|
||||
.SH NAME
|
||||
simple_backup \- Backup files and folders using rsync
|
||||
.SH SYNOPSIS
|
||||
@@ -145,6 +145,11 @@ needs to be allowed to run without password in the same way.
|
||||
.TP
|
||||
.B \-\-numeric\-ids
|
||||
Use rsync \-\-numeric\-ids option. This causes rsync to use numeric uid/gid instead of trying to map uid/gid names from the local machine to the server.
|
||||
.TP
|
||||
.B \-\-last\-backup
|
||||
Print the date and directory of the last successful backup, then exit. The exit status is 0, or 1
|
||||
if no backup has been recorded yet or the state file can\(aqt be read (see
|
||||
.BR FILES ).
|
||||
.SH CONFIGURATION
|
||||
An example configuration file is provided at \(aq/usr/share/doc/simple_backup/simple_backup.conf\(aq.
|
||||
Copy it to the default location ($HOME/.config/simple_backup) and edit it as needed.
|
||||
@@ -216,6 +221,16 @@ SSH connection failed.
|
||||
.TP
|
||||
.B 6
|
||||
Bad configuration file or options (for example, only one of SSH host and SSH user specified).
|
||||
.SH FILES
|
||||
.TP
|
||||
.I $XDG_STATE_HOME/simple_backup/last_backup
|
||||
Written after each successful backup (local or remote), replacing the previous one. The first line
|
||||
is the completion time in ISO 8601 format with the UTC offset (for example
|
||||
\(aq2026\-10\-25T14:03:12+02:00\(aq), the second line the backup directory (prefixed with
|
||||
\(aquser@host:\(aq for remote backups). The default location is
|
||||
\fI~/.local/state/simple_backup/last_backup\fR. When running as root with sudo, doas or \-\-user,
|
||||
the file is written to that user\(aqs \fI~/.local/state\fR and owned by them. Useful for scripts
|
||||
that remind you when a backup is overdue.
|
||||
.SH SEE ALSO
|
||||
.BR rsync (1)
|
||||
.SH AUTHORS
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Init."""
|
||||
|
||||
__version__ = '4.5.0'
|
||||
__version__ = '4.6.0'
|
||||
|
||||
@@ -315,6 +315,12 @@ class Backup:
|
||||
if self._remote:
|
||||
self._server = f'{self.ssh_user}@{self.ssh_host}:'
|
||||
|
||||
@property
|
||||
def destination(self) -> str:
|
||||
"""The current backup directory, prefixed with 'user@host:' for remote backups"""
|
||||
|
||||
return f'{self._server}{self._output_dir}'
|
||||
|
||||
def remove_old_backups(self) -> None:
|
||||
"""Remove old backups if there are more than indicated by 'keep'"""
|
||||
|
||||
@@ -902,6 +908,8 @@ def _parse_arguments() -> argparse.Namespace:
|
||||
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)')
|
||||
parser.add_argument('--last-backup', action='store_true',
|
||||
help='Show when the last successful backup was made and exit')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -1077,6 +1085,103 @@ def _notify(text: str) -> None:
|
||||
os.seteuid(euid)
|
||||
|
||||
|
||||
def _state_owner(user: str | None) -> pwd.struct_passwd | None:
|
||||
"""The user whose state file is used when running as root on their behalf (sudo, doas, -u)"""
|
||||
|
||||
if os.geteuid() != 0 or user is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
owner = pwd.getpwnam(user)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
return owner if owner.pw_uid != 0 else None
|
||||
|
||||
|
||||
def _state_file(owner: pwd.struct_passwd | None) -> str:
|
||||
if owner is not None:
|
||||
# XDG_STATE_HOME belongs to root here
|
||||
base = os.path.join(owner.pw_dir, '.local', 'state')
|
||||
else:
|
||||
base = os.getenv('XDG_STATE_HOME', '')
|
||||
|
||||
# The XDG spec says to ignore relative paths
|
||||
if not os.path.isabs(base):
|
||||
base = os.path.expanduser('~/.local/state')
|
||||
|
||||
return os.path.join(base, 'simple_backup', 'last_backup')
|
||||
|
||||
|
||||
def _write_state(user: str | None, destination: str) -> None:
|
||||
"""Record the time and directory of the last successful backup (two lines)"""
|
||||
|
||||
owner = _state_owner(user)
|
||||
path = _state_file(owner)
|
||||
tmp = ''
|
||||
euid, egid = os.geteuid(), os.getegid()
|
||||
|
||||
# Create the file (and any missing directories) as the user, not root
|
||||
if owner is not None:
|
||||
os.setegid(owner.pw_gid)
|
||||
os.seteuid(owner.pw_uid)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
# Replace the file in one step, so readers (e.g. status bar scripts) never see it half written
|
||||
fd, tmp = mkstemp(prefix='.last_backup', dir=os.path.dirname(path), text=True)
|
||||
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as fp:
|
||||
fp.write(f'{datetime.now().astimezone().isoformat(timespec="seconds")}\n{destination}\n')
|
||||
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
logger.warning('Cannot save the time of the last backup to %s: %s', path, e)
|
||||
|
||||
if tmp != '':
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if owner is not None:
|
||||
os.seteuid(euid)
|
||||
os.setegid(egid)
|
||||
|
||||
|
||||
def _show_last_backup(user: str | None) -> int:
|
||||
path = _state_file(_state_owner(user))
|
||||
|
||||
try:
|
||||
with open(path, encoding='utf-8') as fp:
|
||||
time, destination = fp.read().splitlines()[:2]
|
||||
|
||||
completed = datetime.fromisoformat(time).astimezone()
|
||||
except FileNotFoundError:
|
||||
print('No backup recorded yet')
|
||||
|
||||
return 1
|
||||
except (OSError, ValueError) as e:
|
||||
print(f'Cannot read {path}: {e}', file=sys.stderr)
|
||||
|
||||
return 1
|
||||
|
||||
days = (datetime.now().astimezone().date() - completed.date()).days
|
||||
|
||||
if days == 0:
|
||||
age = 'today'
|
||||
elif days == 1:
|
||||
age = 'yesterday'
|
||||
else:
|
||||
age = f'{days} days ago'
|
||||
|
||||
print(f'Last backup: {completed:%Y-%m-%d %H:%M} ({age})')
|
||||
print(f'Directory: {destination}')
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def simple_backup() -> int:
|
||||
"""Main"""
|
||||
|
||||
@@ -1084,6 +1189,9 @@ def simple_backup() -> int:
|
||||
|
||||
user: str | None = args.user or _detect_user()
|
||||
|
||||
if args.last_backup:
|
||||
return _show_last_backup(user)
|
||||
|
||||
if not args.user and os.geteuid() != 0:
|
||||
homedir = os.getenv('HOME', '')
|
||||
elif user is not None:
|
||||
@@ -1167,8 +1275,13 @@ def simple_backup() -> int:
|
||||
try:
|
||||
return_code = backup.check_params(homedir)
|
||||
|
||||
if return_code != 0:
|
||||
return return_code
|
||||
|
||||
return_code = backup.run()
|
||||
|
||||
if return_code == 0:
|
||||
return backup.run()
|
||||
_write_state(user, backup.destination)
|
||||
|
||||
return return_code
|
||||
finally:
|
||||
|
||||
+4
-2
@@ -3,6 +3,7 @@
|
||||
import sys
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -10,13 +11,14 @@ from simple_backup import simple_backup as sb
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep tests out of the systemd journal and the desktop notifications"""
|
||||
def no_side_effects(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Keep tests out of the systemd journal, the desktop notifications and the user's state directory"""
|
||||
|
||||
if hasattr(sb, 'j_handler'):
|
||||
sb.logger.removeHandler(sb.j_handler)
|
||||
|
||||
monkeypatch.setattr(sb, '_notify', lambda text: None)
|
||||
monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'state'))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
+20
-1
@@ -26,6 +26,8 @@ class FakeBackup:
|
||||
|
||||
instances: list['FakeBackup'] = []
|
||||
check_code = 0
|
||||
run_code = 0
|
||||
destination = '/backups/simple_backup/2026-10-25_10-00-00'
|
||||
|
||||
def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str,
|
||||
ssh_host: str | None = None, ssh_user: str | None = None,
|
||||
@@ -56,7 +58,7 @@ class FakeBackup:
|
||||
def run(self) -> int:
|
||||
self.ran = True
|
||||
|
||||
return 0
|
||||
return FakeBackup.run_code
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
@@ -66,6 +68,7 @@ class FakeBackup:
|
||||
def fake_backup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
FakeBackup.instances = []
|
||||
FakeBackup.check_code = 0
|
||||
FakeBackup.run_code = 0
|
||||
monkeypatch.setattr(sb, 'Backup', FakeBackup)
|
||||
|
||||
|
||||
@@ -83,6 +86,8 @@ def as_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run as root, with no sudo/doas variables set"""
|
||||
|
||||
monkeypatch.setattr(os, 'geteuid', lambda: 0)
|
||||
# Writing the state file as root would call seteuid() (covered in test_state.py)
|
||||
monkeypatch.setattr(sb, '_write_state', lambda user, destination: None)
|
||||
|
||||
for var in ['SUDO_USER', 'SUDO_UID', 'DOAS_USER']:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
@@ -270,6 +275,20 @@ ssh_keyfile=/keys/id
|
||||
assert not b.ran
|
||||
assert b.closed
|
||||
|
||||
@pytest.mark.parametrize('check_code, run_code, saved', [(0, 0, True), (2, 0, False), (0, 4, False)])
|
||||
def test_state_saved_only_on_success(self, run_cli: RunCli, config: Callable[[str], str], tmp_path: Path,
|
||||
check_code: int, run_code: int, saved: bool) -> None:
|
||||
FakeBackup.check_code = check_code
|
||||
FakeBackup.run_code = run_code
|
||||
|
||||
assert run_cli('-c', config('backup_dir=/backups')) == (check_code or run_code)
|
||||
|
||||
state = tmp_path / 'state' / 'simple_backup' / 'last_backup'
|
||||
assert state.exists() is saved
|
||||
|
||||
if saved:
|
||||
assert state.read_text().splitlines()[1] == FakeBackup.destination
|
||||
|
||||
|
||||
class TestUser:
|
||||
@pytest.mark.usefixtures('as_user')
|
||||
|
||||
+5
-1
@@ -201,7 +201,11 @@ class TestRsync:
|
||||
for _ in range(2):
|
||||
assert run_cli('-c', config_file, '-i', str(src), '-o', str(out), '-k', '0') == 0
|
||||
|
||||
assert backups(out) == ['2026-10-14_11-00-00']
|
||||
# The fake clock also advances when the state file is written
|
||||
assert backups(out) == ['2026-10-14_12-00-00']
|
||||
state = (tmp_path / 'state' / 'simple_backup' / 'last_backup').read_text().splitlines()
|
||||
assert state[0].startswith('2026-10-14T13:00:00')
|
||||
assert state[1] == f'{out}/simple_backup/2026-10-14_12-00-00'
|
||||
|
||||
|
||||
class ChunkStream(io.BufferedIOBase):
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for the last backup state file and --last-backup"""
|
||||
|
||||
import os
|
||||
import pwd
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from simple_backup import simple_backup as sb
|
||||
|
||||
DESTINATION = '/backups/simple_backup/2026-10-25_10-00-00'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def as_user(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run as a non-root user (the tests may run as root in a chroot)"""
|
||||
|
||||
monkeypatch.setattr(os, 'geteuid', lambda: 1000)
|
||||
monkeypatch.setenv('USER', 'user')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(tmp_path: Path) -> Path:
|
||||
return tmp_path / 'state' / 'simple_backup' / 'last_backup'
|
||||
|
||||
|
||||
def write_state(state: Path, text: str) -> None:
|
||||
state.parent.mkdir(parents=True)
|
||||
state.write_text(text)
|
||||
|
||||
|
||||
def test_write(state: Path) -> None:
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
time, destination = state.read_text().splitlines()
|
||||
assert abs(datetime.now().astimezone() - datetime.fromisoformat(time)) < timedelta(minutes=1)
|
||||
assert destination == DESTINATION
|
||||
# No temporary file left behind
|
||||
assert os.listdir(state.parent) == ['last_backup']
|
||||
|
||||
|
||||
def test_overwrite(state: Path) -> None:
|
||||
write_state(state, '2026-10-01T10:00:00+02:00\n/old\n')
|
||||
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
assert state.read_text().splitlines()[1] == DESTINATION
|
||||
|
||||
|
||||
def test_relative_xdg_state_home_ignored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv('XDG_STATE_HOME', 'relative')
|
||||
monkeypatch.setenv('HOME', str(tmp_path / 'home'))
|
||||
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists()
|
||||
|
||||
|
||||
def test_write_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
# A file where the state directory should be
|
||||
(tmp_path / 'not a dir').write_text('')
|
||||
monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'not a dir'))
|
||||
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
assert 'Cannot save the time of the last backup' in caplog.text
|
||||
|
||||
|
||||
def test_root_writes_as_user(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
calls: list[tuple[str, int]] = []
|
||||
egid = os.getegid()
|
||||
owner = pwd.struct_passwd(('user', 'x', 1000, 1001, '', str(tmp_path / 'home'), '/bin/sh'))
|
||||
|
||||
monkeypatch.setattr(os, 'geteuid', lambda: 0)
|
||||
monkeypatch.setattr(pwd, 'getpwnam', lambda name: owner)
|
||||
monkeypatch.setattr(os, 'seteuid', lambda uid: calls.append(('euid', uid)))
|
||||
monkeypatch.setattr(os, 'setegid', lambda gid: calls.append(('egid', gid)))
|
||||
|
||||
sb._write_state('user', DESTINATION)
|
||||
|
||||
# Root's XDG_STATE_HOME is not used
|
||||
assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists()
|
||||
assert calls == [('egid', 1001), ('euid', 1000), ('euid', 0), ('egid', egid)]
|
||||
|
||||
|
||||
class TestStateOwner:
|
||||
def test_non_root(self) -> None:
|
||||
assert sb._state_owner('user') is None
|
||||
|
||||
@pytest.mark.parametrize('name, expected', [('user', 'user'), ('root', None), ('unknown', None), (None, None)])
|
||||
def test_root(self, monkeypatch: pytest.MonkeyPatch, name: str | None, expected: str | None) -> None:
|
||||
users = {'user': pwd.struct_passwd(('user', 'x', 1000, 1000, '', '/home/user', '/bin/sh')),
|
||||
'root': pwd.struct_passwd(('root', 'x', 0, 0, '', '/root', '/bin/sh'))}
|
||||
|
||||
def getpwnam(name: str) -> pwd.struct_passwd:
|
||||
if name not in users:
|
||||
raise KeyError(name)
|
||||
|
||||
return users[name]
|
||||
|
||||
monkeypatch.setattr(os, 'geteuid', lambda: 0)
|
||||
monkeypatch.setattr(pwd, 'getpwnam', getpwnam)
|
||||
|
||||
owner = sb._state_owner(name)
|
||||
assert (owner.pw_name if owner is not None else None) == expected
|
||||
|
||||
|
||||
class TestShow:
|
||||
@pytest.mark.parametrize('days, age', [(0, 'today'), (1, 'yesterday'), (3, '3 days ago')])
|
||||
def test_age(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str],
|
||||
days: int, age: str) -> None:
|
||||
completed = (datetime.now() - timedelta(days=days)).replace(hour=12, minute=30).astimezone()
|
||||
write_state(state, f'{completed.isoformat(timespec="seconds")}\n{DESTINATION}\n')
|
||||
|
||||
assert run_cli('--last-backup') == 0
|
||||
assert capsys.readouterr().out == \
|
||||
f'Last backup: {completed:%Y-%m-%d} 12:30 ({age})\nDirectory: {DESTINATION}\n'
|
||||
|
||||
def test_after_write(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None:
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
assert run_cli('--last-backup') == 0
|
||||
assert '(today)' in capsys.readouterr().out
|
||||
|
||||
def test_no_backup(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert run_cli('--last-backup') == 1
|
||||
assert capsys.readouterr().out == 'No backup recorded yet\n'
|
||||
|
||||
@pytest.mark.parametrize('text', ['', '2026-10-25T10:00:00+02:00\n', 'yesterday\n/backups\n'])
|
||||
def test_bad_file(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str],
|
||||
text: str) -> None:
|
||||
write_state(state, text)
|
||||
|
||||
assert run_cli('--last-backup') == 1
|
||||
assert 'Cannot read' in capsys.readouterr().err
|
||||
Reference in New Issue
Block a user