Save the last successful backup and add --last-backup

Write the completion time and backup directory to
$XDG_STATE_HOME/simple_backup/last_backup after each successful backup
(as the target user when run as root), for status bar reminders.
--last-backup prints it.
This commit is contained in:
Fuxino
2026-10-25 12:36:00 +01:00
parent 7547db1ea8
commit 6d54019a18
7 changed files with 306 additions and 5 deletions
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+137
View File
@@ -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