177 lines
8.2 KiB
Python
177 lines
8.2 KiB
Python
"""Online SQLite snapshots with optional copy to a pre-mounted network directory."""
|
|
import datetime as dt
|
|
from contextlib import closing
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
|
|
|
|
class Backups:
|
|
def __init__(self, app):
|
|
self.a = app
|
|
self.guard = threading.Lock()
|
|
self.running = False
|
|
|
|
def config(self):
|
|
return self.a.setting('backup_config', dict(enabled=False, time='03:00', keep=14, networkPath='', revision=0))
|
|
|
|
def status(self):
|
|
return dict(config=self.config(), running=self.running,
|
|
localPath=str(self.a.DATA/'managed-backups'),
|
|
last=self.a.setting('backup_last', None),
|
|
history=self.a.setting('backup_history', [])[-20:][::-1])
|
|
|
|
@staticmethod
|
|
def network_mount(folder):
|
|
if os.name == 'nt':
|
|
return folder.drive.startswith('\\\\')
|
|
try:
|
|
records = Path('/proc/self/mountinfo').read_text().splitlines()
|
|
except OSError:
|
|
return False
|
|
return Backups.linux_network_mount(PurePosixPath(str(folder)), records)
|
|
|
|
@staticmethod
|
|
def linux_network_mount(folder, records):
|
|
matches = []
|
|
for line in records:
|
|
left, separator, right = line.partition(' - ')
|
|
fields, detail = left.split(), right.split()
|
|
if not separator or len(fields) < 5 or not detail:
|
|
continue
|
|
mount = PurePosixPath(re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), fields[4]))
|
|
if folder.is_relative_to(mount):
|
|
matches.append((len(mount.parts), detail[0]))
|
|
# The nearest mount wins: a tmpfs inside an NFS share is still local.
|
|
filesystem = max(matches, default=(0, ''), key=lambda x:x[0])[1]
|
|
return filesystem in {'cifs', 'smb3', 'nfs', 'nfs4', 'davfs',
|
|
'fuse.sshfs', 'fuse.rclone', 'fuse.davfs', 'fuse.davfs2',
|
|
'fuse.wdfs', 'fuse.s3fs', 'fuse.goofys'}
|
|
|
|
def network_folder(self, text):
|
|
if not text:
|
|
return None
|
|
if not isinstance(text, str) or len(text) > 500 or any(ord(c) < 32 for c in text) or '://' in text:
|
|
raise self.a.Problem('请填写服务器已挂载的绝对目录,不能填写网页或分享链接')
|
|
folder = Path(text)
|
|
if not folder.is_absolute():
|
|
raise self.a.Problem('备份路径须为服务器上的绝对目录')
|
|
folder = folder.resolve()
|
|
if not folder.is_dir():
|
|
raise self.a.Problem('备份目录不存在,请先在服务器挂载网络存储并授予服务账号写入权限')
|
|
# A disconnected mount must not silently become a local-only cloud backup.
|
|
if not self.network_mount(folder):
|
|
raise self.a.Problem('该目录未检测到支持的网络挂载,请先连接 NFS、SMB、WebDAV 或 rclone 网络盘')
|
|
if folder == (self.a.DATA/'managed-backups').resolve():
|
|
raise self.a.Problem('网络副本与本机备份目录不能相同')
|
|
return folder
|
|
|
|
def save(self, data):
|
|
if set(data) != {'enabled','time','keep','networkPath','revision'}:
|
|
raise self.a.Problem('备份设置字段不完整')
|
|
with self.guard:
|
|
if self.running:
|
|
raise self.a.Problem('备份正在执行,请完成后再调整设置', 409)
|
|
with self.a.LOCK:
|
|
old = self.config()
|
|
if type(data['revision']) is not int or data['revision'] != old['revision']:
|
|
raise self.a.Problem('设置已变化,请重新读取后保存', 409)
|
|
if not isinstance(data['enabled'], bool) or not isinstance(data['time'], str) or not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', data['time']):
|
|
raise self.a.Problem('请选择有效的每日备份时间')
|
|
self.a.integer(data['keep'], 1, 90)
|
|
if not isinstance(data['networkPath'], str):
|
|
raise self.a.Problem('备份目录格式不正确')
|
|
self.network_folder(data['networkPath'])
|
|
self.a.set_setting('backup_config', dict(data, revision=old['revision']+1))
|
|
return self.status()
|
|
|
|
def start(self, reason='manual'):
|
|
with self.guard:
|
|
if self.running:
|
|
raise self.a.Problem('已有备份正在执行', 409)
|
|
self.running = True
|
|
threading.Thread(target=self.run, args=(reason,), daemon=True).start()
|
|
return dict(started=True)
|
|
|
|
def prune(self, folder, prefix, keep):
|
|
owned = [p for p in folder.glob(prefix+'*.sqlite') if p.is_file() and not p.is_symlink()]
|
|
for path in sorted(owned, key=lambda p:p.name, reverse=True)[keep:]:
|
|
path.unlink()
|
|
|
|
def run(self, reason='manual'):
|
|
a = self.a
|
|
config = self.config()
|
|
result = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), reason=reason,
|
|
localOk=False, networkOk=False, networkRequested=bool(config['networkPath']))
|
|
temporary = network_temp = None
|
|
try:
|
|
local = a.DATA/'managed-backups'
|
|
local.mkdir(mode=0o700, exist_ok=True)
|
|
instance = a.setting('backup_instance')
|
|
if not instance:
|
|
instance = secrets.token_hex(8); a.set_setting('backup_instance', instance)
|
|
prefix = 'vision-'+instance+'-'
|
|
name = prefix+dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')+'.sqlite'
|
|
target = local/name
|
|
temporary = local/(name+'.partial')
|
|
# Separate read connection avoids holding the live write lock during IO.
|
|
with closing(sqlite3.connect(a.DATA/'vision.db', timeout=10)) as source, closing(sqlite3.connect(temporary)) as dest:
|
|
source.backup(dest, pages=256, sleep=0.01)
|
|
if dest.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
|
|
raise RuntimeError('数据库副本完整性检查失败')
|
|
dest.execute('DELETE FROM sessions'); dest.commit()
|
|
os.chmod(temporary, 0o600)
|
|
with temporary.open('rb') as f:
|
|
digest = hashlib.file_digest(f, 'sha256').hexdigest()
|
|
os.replace(temporary, target)
|
|
result.update(localOk=True, file=name, bytes=target.stat().st_size, sha256=digest)
|
|
self.prune(local, prefix, config['keep'])
|
|
if config['networkPath']:
|
|
network = self.network_folder(config['networkPath'])
|
|
network_temp = network/(name+'.partial')
|
|
shutil.copyfile(target, network_temp)
|
|
os.chmod(network_temp, 0o600)
|
|
with network_temp.open('rb') as f:
|
|
if hashlib.file_digest(f, 'sha256').hexdigest() != digest:
|
|
raise RuntimeError('网络副本校验不一致')
|
|
os.replace(network_temp, network/name)
|
|
result['networkOk'] = True
|
|
self.prune(network, prefix, config['keep'])
|
|
except Exception as error:
|
|
result['error'] = str(error)[:300] or '备份未完成'
|
|
finally:
|
|
for temporary_path in (temporary, network_temp):
|
|
try:
|
|
if temporary_path and temporary_path.exists(): temporary_path.unlink()
|
|
except OSError:
|
|
pass
|
|
with a.LOCK:
|
|
a.set_setting('backup_last', result)
|
|
history = a.setting('backup_history', [])
|
|
a.set_setting('backup_history', (history+[result])[-100:])
|
|
self.running = False
|
|
return result
|
|
|
|
def tick(self, now=None):
|
|
now = now or dt.datetime.now(dt.timezone(dt.timedelta(hours=8)))
|
|
config = self.config(); day = now.strftime('%Y-%m-%d')
|
|
if not config['enabled'] or now.strftime('%H:%M') < config['time'] or self.running:
|
|
return False
|
|
if self.a.setting('backup_attempt_day') == day:
|
|
return False
|
|
self.a.set_setting('backup_attempt_day', day)
|
|
self.start('scheduled')
|
|
return True
|
|
|
|
def watch(self):
|
|
while True:
|
|
try: self.tick()
|
|
except Exception: pass
|
|
time.sleep(60)
|