mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
The only backups of the panel were the ones taken by hand before an update. Now `manage.py backup` writes a new directory under backups/ with a consistent copy of the sqlite database (the backup API, while the panel runs, checked with PRAGMA integrity_check), the secret key, the worker's node_id, settings.py, the SSH keys of the panel's user and, outside Docker, the commit. It keeps the newest 7 (--keep N) and deletes older ones only after a good backup; a failed one leaves nothing, and a second run while one runs fails. Files are mode 600, directories 700: the database holds compute passwords and keys. With PostgreSQL the database is left out. It runs daily at 03:15: in Docker from the image's cron into a new volume, webvirtcloud-backups; otherwise from webvirt-backup.timer, which the installer and the manual steps install. A backup has the layout of the update backups, so the restore steps take either.
79 lines
3.5 KiB
Python
79 lines
3.5 KiB
Python
import fcntl
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
from contextlib import closing
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from django.conf import settings
|
|
from django.core.management.base import BaseCommand, CommandError
|
|
from django.db import connection
|
|
|
|
PREFIX = "webvirtcloud-backup-"
|
|
|
|
|
|
class Command(BaseCommand):
|
|
help = ("Copy the database, secret key, worker id, settings and the SSH keys of the panel's user into a new "
|
|
"directory in backups/, then delete all but the newest --keep backups.")
|
|
|
|
def add_arguments(self, parser):
|
|
parser.add_argument("--keep", type=int, default=7, help="how many backups to keep (default 7)")
|
|
|
|
def handle(self, *args, keep, **options):
|
|
if keep < 1:
|
|
raise CommandError("--keep must be at least 1")
|
|
os.umask(0o077) # the database holds compute passwords and keys
|
|
backups = settings.BASE_DIR / "backups"
|
|
backups.mkdir(mode=0o700, exist_ok=True)
|
|
with open(backups / ".lock", "w") as lock:
|
|
try:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
raise CommandError("Another backup is running") from None
|
|
self.stdout.write(str(self.backup(backups, keep)))
|
|
|
|
def backup(self, backups, keep):
|
|
name = PREFIX + datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
partial = backups / f".{name}"
|
|
partial.mkdir()
|
|
try:
|
|
self.copy(partial)
|
|
partial.rename(backups / name)
|
|
except BaseException:
|
|
shutil.rmtree(partial, ignore_errors=True)
|
|
raise
|
|
# only after a good backup: a failing one never costs an older one;
|
|
# the partial ones are what a killed run left behind
|
|
for old in [*sorted(backups.glob(PREFIX + "*"))[:-keep], *backups.glob(f".{PREFIX}*")]:
|
|
shutil.rmtree(old)
|
|
return backups / name
|
|
|
|
def copy(self, to):
|
|
database = connection.settings_dict
|
|
if database["ENGINE"] == "django.db.backends.sqlite3":
|
|
# the backup API copies a consistent state while the panel writes
|
|
read_only = Path(database["NAME"]).resolve().as_uri() + "?mode=ro"
|
|
with closing(sqlite3.connect(read_only, uri=True, timeout=30)) as source, \
|
|
closing(sqlite3.connect(to / "db.sqlite3")) as copy:
|
|
source.backup(copy)
|
|
result = copy.execute("PRAGMA integrity_check").fetchone()[0]
|
|
if result != "ok":
|
|
raise CommandError(f"The copy of the database is damaged: {result}")
|
|
else:
|
|
self.stderr.write("The database is not sqlite and is not in the backup: back it up with its own tools.")
|
|
data = settings.BASE_DIR / "data"
|
|
for source in (data / "secret_key", data / "node_id", settings.BASE_DIR / "webvirtcloud" / "settings.py"):
|
|
if source.is_file():
|
|
shutil.copyfile(source, to / source.name)
|
|
ssh = Path.home() / ".ssh"
|
|
if ssh.is_dir():
|
|
(to / "ssh").mkdir()
|
|
for source in ssh.iterdir():
|
|
if source.is_file() and not source.is_symlink():
|
|
shutil.copyfile(source, to / "ssh" / source.name)
|
|
if (settings.BASE_DIR / ".git").exists():
|
|
commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=settings.BASE_DIR, capture_output=True, text=True)
|
|
if commit.returncode == 0:
|
|
(to / "commit").write_text(commit.stdout)
|