1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/appsettings/tests/test_backup.py
catborise 482f5bc16d fix(backup): TLS client certificates persist in Docker and are backed up
libvirt reads a TLS compute's client certificate from the panel user's
~/.pki/libvirt. In Docker that was /var/www/.pki inside the container,
so `docker compose up -d --build` dropped it and every TLS compute
stopped connecting; no backup held it on either install type, although
"Moving to a new machine" says the backup carries what the computes
accept, and the README never said where the certificates go.

Docker keeps /var/www/.pki in a webvirtcloud-pki volume, owned by
www-data and private like ~/.ssh; the backup copies ~/.pki into pki/;
the moving steps put it back; and "Adding a Compute Node" says where the
files go.
2026-11-06 09:12:34 +03:00

110 lines
5.2 KiB
Python

"""manage.py backup: a directory of what a new panel needs to take over,
readable only by the panel's user, the newest --keep of them kept."""
import fcntl
import os
import shutil
import sqlite3
import tempfile
from contextlib import closing
from io import StringIO
from pathlib import Path
from unittest import skipUnless
from unittest.mock import patch
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import connection
from django.test import SimpleTestCase, override_settings
from appsettings.management.commands.backup import Command
SQLITE_ONLY = skipUnless(connection.vendor == "sqlite", "the backup copies only a sqlite database")
class BackupTests(SimpleTestCase):
def setUp(self):
self.base = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.base)
self.addCleanup(os.umask, os.umask(0o022)) # the command sets its own
for path, text in (("data/secret_key", "key"), ("data/node_id", "a1b2c3"),
("webvirtcloud/settings.py", "DEBUG = False"), ("home/.ssh/id_ed25519", "private")):
(self.base / path).parent.mkdir(parents=True, exist_ok=True)
(self.base / path).write_text(text)
(self.base / "home/.ssh/agent").symlink_to(self.base / "data/secret_key")
overridden = override_settings(BASE_DIR=self.base)
overridden.enable()
self.addCleanup(overridden.disable)
home = patch.object(Path, "home", return_value=self.base / "home")
home.start()
self.addCleanup(home.stop)
self.backups = self.base / "backups"
def backup(self, *args):
out = StringIO()
call_command("backup", *args, stdout=out)
return Path(out.getvalue().strip())
def kept(self):
return sorted(p.name for p in self.backups.iterdir() if p.name != ".lock")
@SQLITE_ONLY
def test_a_backup_holds_the_database_keys_and_settings_for_the_panel_user_only(self):
backup = self.backup()
with closing(sqlite3.connect(backup / "db.sqlite3")) as db:
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
self.assertIn("computes_compute", tables)
self.assertEqual(sorted(p.name for p in backup.iterdir()), ["db.sqlite3", "node_id", "secret_key", "settings.py", "ssh"])
self.assertEqual((backup / "secret_key").read_text(), "key")
self.assertEqual([p.name for p in (backup / "ssh").iterdir()], ["id_ed25519"]) # not what a link points to
self.assertEqual(oct(self.backups.stat().st_mode & 0o777), "0o700")
self.assertEqual(oct(backup.stat().st_mode & 0o777), "0o700")
self.assertEqual(oct((backup / "db.sqlite3").stat().st_mode & 0o777), "0o600")
self.assertEqual(oct((backup / "ssh/id_ed25519").stat().st_mode & 0o777), "0o600")
def test_a_backup_holds_the_tls_certificates_of_the_panel_user(self):
key = self.base / "home/.pki/libvirt/clientkey.pem"
key.parent.mkdir(parents=True)
key.write_text("tls key")
key.chmod(0o600)
copy = self.backup() / "pki/libvirt/clientkey.pem"
self.assertEqual(copy.read_text(), "tls key")
self.assertEqual(oct(copy.stat().st_mode & 0o777), "0o600")
def test_only_the_newest_backups_and_no_partial_one_are_kept(self):
for name in ("webvirtcloud-backup-20260101-000000", "webvirtcloud-backup-20260102-000000",
".webvirtcloud-backup-20260103-000000"):
(self.backups / name).mkdir(parents=True)
backup = self.backup("--keep", "2")
self.assertEqual(self.kept(), ["webvirtcloud-backup-20260102-000000", backup.name])
def test_a_failed_backup_leaves_nothing_and_deletes_no_older_one(self):
(self.backups / "webvirtcloud-backup-20260101-000000").mkdir(parents=True)
with patch.object(Command, "copy", side_effect=OSError("No space left on device")), self.assertRaises(OSError):
self.backup("--keep", "1")
self.assertEqual(self.kept(), ["webvirtcloud-backup-20260101-000000"])
@SQLITE_ONLY
def test_a_damaged_copy_fails(self):
with patch("appsettings.management.commands.backup.sqlite3") as sqlite, \
self.assertRaisesRegex(CommandError, "damaged: row 3 missing from index"):
sqlite.connect.return_value.execute.return_value.fetchone.return_value = ("row 3 missing from index",)
self.backup()
self.assertEqual(self.kept(), [])
def test_a_backup_fails_while_another_runs(self):
self.backups.mkdir()
with open(self.backups / ".lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
with self.assertRaisesRegex(CommandError, "Another backup is running"):
self.backup()
self.assertEqual(self.kept(), [])
def test_a_database_other_than_sqlite_is_left_out(self):
errors = StringIO()
with patch.dict(connection.settings_dict, ENGINE="django.db.backends.postgresql"):
call_command("backup", stdout=StringIO(), stderr=errors)
backup = self.backups / self.kept()[0]
self.assertNotIn("db.sqlite3", [p.name for p in backup.iterdir()])
self.assertIn("back it up with its own tools", errors.getvalue())