From ca3232c515587bb28ec0db4c0381cd8585753b7a Mon Sep 17 00:00:00 2001 From: jessikitty Date: Thu, 24 Sep 2026 14:31:56 +1000 Subject: [PATCH] Add flask backup CLI: create/list/inspect/verify/restore --- app/cli.py | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 app/cli.py diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..e2310ab --- /dev/null +++ b/app/cli.py @@ -0,0 +1,185 @@ +""" +Flask CLI commands for LIM backup and restore. + + flask backup create [--label nightly] [--keep 5] [--no-sqlite-file] + flask backup list + flask backup verify ARCHIVE + flask backup inspect ARCHIVE + flask backup restore ARCHIVE [--mode fresh|merge] [--force] + [--overwrite-files] [--skip-files] + +`restore` is the path to use on a fresh Docker instance: no login required, +and it works before any admin user exists. + +Created by: Jess Rogerson (yelling commands at Claude.AI) +""" + +import json +import os + +import click +from flask.cli import AppGroup + +from app.services import backup_service as bsvc + +backup_cli = AppGroup('backup', help='Backup, verify and restore LIM data.') + + +def _resolve(archive): + """Accept a bare filename in the backup folder or any path.""" + if os.path.isfile(archive): + return os.path.abspath(archive) + candidate = os.path.join(bsvc.backup_folder(), os.path.basename(archive)) + if os.path.isfile(candidate): + return candidate + raise click.ClickException(f'Archive not found: {archive}') + + +@backup_cli.command('create') +@click.option('--label', default=None, help='Short suffix for the filename.') +@click.option('--keep', default=None, type=int, + help='Keep only this many archives afterwards.') +@click.option('--no-sqlite-file', is_flag=True, + help='Omit the raw SQLite snapshot (JSON dump only).') +def create_command(label, keep, no_sqlite_file): + """Create a backup archive.""" + bar_state = {'last': -1} + + def on_progress(stage, done, total, note): + if total and done != bar_state['last']: + bar_state['last'] = done + pct = int(done / total * 100) + click.echo(f'\r [{pct:3d}%] {stage}: {note[:60]:<60}', nl=False) + + click.echo('Creating backup...') + result = bsvc.create_backup(label=label, created_by='cli', + progress=on_progress, + include_sqlite_file=not no_sqlite_file) + click.echo('') + + manifest = result['manifest'] + click.echo(f" Archive : {result['filename']} ({result['size_human']})") + click.echo(f" Path : {result['path']}") + click.echo(f" Rows : " + ', '.join( + f'{v} {k}' for k, v in manifest['counts'].items())) + click.echo(f" Uploads : {manifest['uploads']['file_count']} files, " + f"{manifest['uploads']['total_human']}") + + integrity = manifest['integrity'] + if integrity['missing_count']: + click.secho(f" WARNING : {integrity['missing_count']} file(s) are " + f"referenced in the database but missing on disk.", + fg='yellow') + for name in integrity['missing_files'][:10]: + click.echo(f' - {name}') + if integrity['orphaned_count']: + click.echo(f" Note : {integrity['orphaned_count']} file(s) on disk " + f"are not referenced by any record (backed up anyway).") + + if keep: + pruned = bsvc.prune_backups(keep) + if pruned: + click.echo(f" Pruned : {', '.join(pruned)}") + + click.secho('Backup complete.', fg='green') + + +@backup_cli.command('list') +def list_command(): + """List archives in the backup folder.""" + backups = bsvc.list_backups() + if not backups: + click.echo(f'No backups in {bsvc.backup_folder()}') + return + click.echo(f'Backups in {bsvc.backup_folder()}:') + for entry in backups: + manifest = entry['manifest'] or {} + counts = manifest.get('counts', {}) + detail = ', '.join(f'{v} {k}' for k, v in counts.items()) or 'unreadable' + flag = '' if entry['valid'] else ' [NOT A LIM BACKUP]' + click.echo(f" {entry['modified']:%Y-%m-%d %H:%M} " + f"{entry['size_human']:>10} {entry['filename']}{flag}") + click.echo(f" {detail}") + + +@backup_cli.command('inspect') +@click.argument('archive') +def inspect_command(archive): + """Print an archive's manifest.""" + path = _resolve(archive) + click.echo(json.dumps(bsvc.read_manifest(path), indent=2)) + + +@backup_cli.command('verify') +@click.argument('archive') +def verify_command(archive): + """Recompute every checksum in an archive.""" + path = _resolve(archive) + click.echo(f'Verifying {os.path.basename(path)}...') + report = bsvc.verify_backup(path) + click.echo(f" Entries checked : {report['checked']}") + for name in report['failed']: + click.secho(f' CHECKSUM FAIL : {name}', fg='red') + for name in report['missing']: + click.secho(f' MISSING ENTRY : {name}', fg='red') + for error in report['errors']: + click.secho(f' ERROR : {error}', fg='red') + if report['ok']: + click.secho('Archive is intact.', fg='green') + else: + raise click.ClickException('Archive failed verification.') + + +@backup_cli.command('restore') +@click.argument('archive') +@click.option('--mode', type=click.Choice(['fresh', 'merge']), default='fresh', + help='fresh preserves IDs into an empty DB; merge keeps existing data.') +@click.option('--force', is_flag=True, + help='In fresh mode, wipe existing rows first.') +@click.option('--overwrite-files', is_flag=True, + help='Overwrite upload files that already exist.') +@click.option('--skip-files', is_flag=True, + help='Restore database rows only.') +@click.option('--yes', is_flag=True, help='Skip the confirmation prompt.') +def restore_command(archive, mode, force, overwrite_files, skip_files, yes): + """Restore an archive into this instance.""" + path = _resolve(archive) + manifest = bsvc.read_manifest(path) + + click.echo(f"Archive : {os.path.basename(path)}") + click.echo(f"Created : {manifest.get('created_at')} on " + f"{manifest.get('source', {}).get('hostname')}") + click.echo(f"Rows : " + ', '.join( + f'{v} {k}' for k, v in manifest.get('counts', {}).items())) + click.echo(f"Uploads : {manifest.get('uploads', {}).get('file_count')} files, " + f"{manifest.get('uploads', {}).get('total_human')}") + click.echo(f"Mode : {mode}" + + (' (WIPES EXISTING ROWS)' if mode == 'fresh' and force else '')) + + if not yes and not click.confirm('Proceed with restore?', default=False): + click.echo('Cancelled.') + return + + def on_progress(stage, done, total, note): + if total: + click.echo(f'\r {stage}: {done}/{total} {note[:50]:<50}', nl=False) + + summary = bsvc.restore_backup(path, mode=mode, force=force, + overwrite_files=overwrite_files, + skip_files=skip_files, progress=on_progress) + click.echo('') + click.echo(' Inserted : ' + ', '.join( + f'{v} {k}' for k, v in summary['inserted'].items())) + if any(summary['skipped'].values()): + click.echo(' Skipped : ' + ', '.join( + f'{v} {k}' for k, v in summary['skipped'].items() if v)) + click.echo(f" Files : {summary['files_written']} written " + f"({summary['files_bytes_human']}), " + f"{summary['files_skipped']} already present") + for warning in summary['warnings']: + click.secho(f' Note : {warning}', fg='yellow') + click.secho('Restore complete.', fg='green') + + +def register_cli(app): + app.cli.add_command(backup_cli)