Files
shopdb-flask/shopdb/core/api/shopdbbackups.py
benjamin.baker 231fd27f68 Fix restore compat check reporting empty schema heads
alembic.ini is a Flask-Migrate config with no script_location, so ScriptDirectory.from_config raised and _current_schema_revisions swallowed it and returned no heads. Every set then failed the subset check and was wrongly blocked as taken on a newer schema than the running code. Set script_location to the migrations directory - the same versions tree the manifest scans - so live heads resolve and compatible sets restore.
2026-09-03 20:11:52 -04:00

359 lines
14 KiB
Python

"""Backups API routes.
Admin-only surface over the site's backup sets. The heavy lifting (mysqldump,
restic snapshot of the instance tree, retention prune, integrity check) runs in
the one-shot backup container defined in docker-compose.backup.yml; this
blueprint is the read-mostly control plane the UI talks to.
Two recovery products stay firewalled, per the design notes:
- code rollback lives in git (tags/releases), NOT here
- data restore lives here, gated on schema compatibility via each set's manifest
Every backup set carries a manifest.json stamped with the core + plugin Alembic
revisions and the running image tag at capture time. A restore is a CHECKED
operation: current code must be at least the set's schema revision, so we never
load rows that reference a column the live schema has not applied yet.
"""
import json
import os
import subprocess
from datetime import datetime, timezone
from flask import Blueprint, current_app, request
from shopdb.core.models import AuditLog
from shopdb.core.models.setting import Setting
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
shopdbbackups_bp = Blueprint('shopdbbackups', __name__)
# Supported backup destination kinds. 'local' needs no credentials; 'smb' and
# 'aws' are ground-wired here so operators can configure them now, but their
# client libraries are held back until the security review clears them, so the
# runner treats a non-local destination as configured-but-not-yet-armed.
DESTINATION_KINDS = ('local', 'smb', 'aws')
# The setting keys that make up each destination's config, grouped by kind so
# the GET can surface only the relevant fields and the PUT can validate what it
# is allowed to write. Secret keys (password/secret/token in the name) are
# masked by the shared settings serializer, same convention as SMTP/SAML.
DESTINATION_FIELDS = {
'local': [],
'smb': [
'backup_smb_host',
'backup_smb_share',
'backup_smb_path',
'backup_smb_username',
'backup_smb_password',
],
'aws': [
'backup_aws_region',
'backup_aws_bucket',
'backup_aws_prefix',
'backup_aws_accesskeyid',
'backup_aws_secretaccesskey',
],
}
# Keys whose value must never leave the API in the clear. Mirrors the shared
# settings convention (_is_secret) so the two surfaces agree on what is secret.
_SECRET_TOKENS = ('password', 'secret', 'token')
SECRET_MASK = '********'
def _is_secret_key(key):
return any(token in key for token in _SECRET_TOKENS)
# Where the backup container drops each set and its manifest. Mounted read-only
# for the api container so the UI can enumerate sets without being able to
# mutate them; writes happen only from the backup one-shot.
BACKUPS_ROOT_ENV = 'SHOPDB_BACKUPS_ROOT'
DEFAULT_BACKUPS_ROOT = '/backups'
# Retention the backup container enforces; surfaced read-only so the UI can show
# "day 1 of 5 retained" without re-deriving the policy.
RETENTION = {
'keepdaily': 5,
'keepweekly': 4,
'keepmonthly': 6,
}
def _backups_root():
return os.environ.get(BACKUPS_ROOT_ENV, DEFAULT_BACKUPS_ROOT)
def _read_manifest(setdir):
"""Load a set's manifest.json, or None when absent/unreadable.
A set without a readable manifest is not restorable (we cannot check its
schema), so callers treat None as "unverified" rather than trusting it.
"""
path = os.path.join(setdir, 'manifest.json')
try:
with open(path, 'r', encoding='utf-8') as handle:
return json.load(handle)
except (OSError, ValueError):
return None
def _current_schema_revisions():
"""Return the live Alembic head revisions (core + plugins) for this build.
Compared against a set's stamped revisions to decide restore compatibility.
Imported lazily so a missing alembic at request time degrades to an empty
map (everything reads as "unknown") instead of 500ing the list endpoint.
"""
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
except ImportError:
return {}
root = current_app.root_path
migrations = os.path.join(os.path.dirname(root), 'migrations')
ini = os.path.join(migrations, 'alembic.ini')
if not os.path.isfile(ini):
return {}
try:
config = Config(ini)
# This is a Flask-Migrate project: alembic.ini carries no
# script_location (it is set at runtime), so ScriptDirectory.from_config
# would raise and leave every set reading as incompatible. Point it at
# the migrations directory - the same versions tree manifest.py scans.
config.set_main_option('script_location', migrations)
script = ScriptDirectory.from_config(config)
return {'core': list(script.get_heads())}
except Exception:
return {}
def _is_compatible(manifest, current):
"""A set restores onto THIS build only when the build's schema is at least
the set's. We compare head-revision sets: if the set was taken on a schema
the current code has not reached, the code is too OLD and would 500 on the
restored rows, so restore is blocked until the code is upgraded.
"""
if not manifest:
return False
setrevs = set(manifest.get('schemarev', {}).get('core', []))
currevs = set(current.get('core', []))
if not setrevs or not currevs:
return False
# Compatible when the current heads cover every head the set was taken at.
return setrevs.issubset(currevs)
def _list_sets():
"""Enumerate backup sets newest-first, each summarized for the list view."""
root = _backups_root()
if not os.path.isdir(root):
return []
current = _current_schema_revisions()
sets = []
for name in sorted(os.listdir(root), reverse=True):
setdir = os.path.join(root, name)
if not os.path.isdir(setdir):
continue
manifest = _read_manifest(setdir)
dbdump = os.path.join(setdir, 'db.sql.gz')
sizebytes = 0
for base, _dirs, files in os.walk(setdir):
for filename in files:
try:
sizebytes += os.path.getsize(os.path.join(base, filename))
except OSError:
pass
sets.append({
'snapshotid': name,
'takenat': (manifest or {}).get('takenat'),
'sizebytes': sizebytes,
'schemarev': (manifest or {}).get('schemarev', {}),
'imagetag': (manifest or {}).get('imagetag'),
'ischeckpassed': bool((manifest or {}).get('ischeckpassed')),
'hasmanifest': manifest is not None,
'hasdbdump': os.path.isfile(dbdump),
'iscompatible': _is_compatible(manifest, current),
})
return sets
@shopdbbackups_bp.route('', methods=['GET'])
@require_role('admin')
def list_backups():
"""List every retained backup set, newest first, with a compatibility flag."""
sets = _list_sets()
laststgood = next((s for s in sets if s['ischeckpassed']), None)
meta = {
'retention': RETENTION,
'lastgoodat': (laststgood or {}).get('takenat'),
'count': len(sets),
}
return success_response(data=sets, meta=meta)
@shopdbbackups_bp.route('/<snapshotid>', methods=['GET'])
@require_role('admin')
def get_backup(snapshotid):
"""Return one set's full manifest plus a resolved compatibility verdict."""
root = _backups_root()
setdir = os.path.join(root, os.path.basename(snapshotid))
if not os.path.isdir(setdir):
return error_response(ErrorCodes.NOT_FOUND, 'Backup set not found',
http_code=404)
manifest = _read_manifest(setdir)
current = _current_schema_revisions()
return success_response(data={
'snapshotid': os.path.basename(snapshotid),
'manifest': manifest,
'currentschemarev': current,
'iscompatible': _is_compatible(manifest, current),
'hasmanifest': manifest is not None,
})
@shopdbbackups_bp.route('', methods=['POST'])
@require_role('admin')
def trigger_backup():
"""Kick off an on-demand backup run.
The api container cannot run docker; it writes a sentinel the host-side
scheduler (or an operator running `docker compose run --rm backup`) picks
up. Returning isrunning=false with a clear message keeps the UI honest
rather than pretending the run started inside this process.
"""
root = _backups_root()
try:
os.makedirs(root, exist_ok=True)
sentinel = os.path.join(root, '.backup-requested')
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
with open(sentinel, 'w', encoding='utf-8') as handle:
handle.write(stamp)
except OSError as error:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not queue a backup: ' + str(error),
http_code=500)
AuditLog.log('requested', 'Backup', entityname=stamp)
return success_response(
data={'isrunning': False, 'isqueued': True, 'requestedat': stamp},
message='Backup queued. The backup service runs it on its next pass.')
@shopdbbackups_bp.route('/<snapshotid>/restore', methods=['POST'])
@require_role('admin')
def restore_backup(snapshotid):
"""Queue a restore of a set, gated on schema compatibility.
We refuse outright when the set is incompatible (current code older than the
set's schema) so an operator cannot foot-gun a schema mismatch from the UI.
The actual restore is performed by scripts/backup/restore.sh in the backup
container; here we validate and record intent.
"""
root = _backups_root()
setdir = os.path.join(root, os.path.basename(snapshotid))
if not os.path.isdir(setdir):
return error_response(ErrorCodes.NOT_FOUND, 'Backup set not found',
http_code=404)
manifest = _read_manifest(setdir)
current = _current_schema_revisions()
if not _is_compatible(manifest, current):
return error_response(
ErrorCodes.CONFLICT,
'This backup was taken on a newer schema than the running code. '
'Upgrade the code to at least the set\'s revision, run '
'`flask db upgrade` and `flask plugin upgrade-all`, then retry.',
details={'currentschemarev': current,
'setschemarev': (manifest or {}).get('schemarev', {})},
http_code=409)
try:
sentinel = os.path.join(root, '.restore-requested')
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
with open(sentinel, 'w', encoding='utf-8') as handle:
handle.write(os.path.basename(snapshotid))
except OSError as error:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not queue the restore: ' + str(error),
http_code=500)
AuditLog.log('restore-requested', 'Backup',
entityname=os.path.basename(snapshotid))
return success_response(
data={'isqueued': True, 'snapshotid': os.path.basename(snapshotid)},
message='Restore queued and validated against the live schema.')
@shopdbbackups_bp.route('/settings', methods=['GET'])
@require_role('admin')
def get_backup_settings():
"""Return destination + retention config for the settings pane.
The destination kind and its (non-secret) fields come from the settings
store so an operator can configure where backups ship without editing env
or redeploying. Secret fields (SMB password, AWS secret key) are never
returned in the clear: we send a mask when a value is set and an empty
string when it is not, matching the shared settings API convention.
'smb' and 'aws' are configurable now but their client libraries are held
back pending the security review, so we flag those destinations as armed:
false to tell the UI the config is stored but not yet acting.
"""
kind = Setting.get('backup_destination_kind', 'local')
if kind not in DESTINATION_KINDS:
kind = 'local'
fields = {}
for key in DESTINATION_FIELDS.get(kind, []):
raw = Setting.get(key, '')
if _is_secret_key(key):
fields[key] = SECRET_MASK if raw else ''
else:
fields[key] = raw or ''
return success_response(data={
'destinationkind': kind,
'destinationkinds': list(DESTINATION_KINDS),
'fields': fields,
'isarmed': kind == 'local',
'retention': RETENTION,
'backupsroot': _backups_root(),
})
@shopdbbackups_bp.route('/settings', methods=['PUT'])
@require_role('admin')
def update_backup_settings():
"""Persist the backup destination kind and its credential/config fields.
Only keys that belong to the chosen destination are accepted, so a stray
payload cannot write arbitrary settings rows through this surface. A secret
field submitted as the mask placeholder means "leave unchanged" - the
client only ever received the mask, so we never overwrite a real secret
with it.
"""
data = request.get_json(silent=True) or {}
kind = data.get('destinationkind', Setting.get('backup_destination_kind', 'local'))
if kind not in DESTINATION_KINDS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'destinationkind must be one of: ' + ', '.join(DESTINATION_KINDS))
Setting.set('backup_destination_kind', kind, valuetype='string',
category='backup')
incoming = data.get('fields', {}) or {}
allowed = set(DESTINATION_FIELDS.get(kind, []))
for key, value in incoming.items():
if key not in allowed:
continue
if _is_secret_key(key) and value == SECRET_MASK:
continue
Setting.set(key, value if value is not None else '',
valuetype='string', category='backup')
AuditLog.log('updated', 'BackupSettings', entityname=kind, changes={
'destinationkind': kind,
})
return get_backup_settings()