1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/appsettings/management/commands/backup.py
catborise 777057cb19 feat(appsettings): the panel backs itself up every day
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.
2026-11-03 09:40:47 +03:00

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)