diff --git a/backupbot.py b/backupbot.py index 4614ab8..b5191a9 100755 --- a/backupbot.py +++ b/backupbot.py @@ -98,11 +98,11 @@ def export_secrets(): @click.option('retries', '--retries', '-r', envvar='RETRIES', default=1) def create(retries): app_settings = parse_backup_labels() - pre_commands, post_commands, backup_paths, apps = get_backup_details(app_settings) - copy_secrets(apps) + pre_commands, post_commands, backup_paths, apps_versions = get_backup_details(app_settings) + copy_secrets(apps_versions) backup_paths.append(Path(SECRET_PATH)) run_commands(pre_commands) - backup_volumes(backup_paths, apps, int(retries)) + backup_volumes(backup_paths, apps_versions, int(retries)) run_commands(post_commands) @@ -117,15 +117,19 @@ def restore(snapshot, target, noninteractive, volumes, container, no_commands): app_settings = parse_backup_labels('restore', container) if SERVICE != 'ALL': app_settings = {SERVICE: app_settings[SERVICE]} - pre_commands, post_commands, backup_paths, apps = get_backup_details(app_settings, volumes) + pre_commands, post_commands, backup_paths, apps_versions = get_backup_details(app_settings, volumes) snapshots = get_snapshots(snapshot_id=snapshot) if not snapshot: - logger.error("No Snapshots with ID {snapshots} for {apps} found.") + logger.error(f"No Snapshots with ID {snapshots} for {apps_versions.keys()} found.") exit(1) if not noninteractive: - snapshot_date = datetime.fromisoformat(snapshots[0]['time']) - delta = datetime.now(tz=timezone.utc) - snapshot_date - print(f"You are going to restore Snapshot {snapshot} of {apps} at {target}") + print(f"Snapshot to restore: \t{snapshot}") + restore_app_versions = app_versions_from_tags(snapshots[0].get('tags')) + print("Apps:") + for app, version in restore_app_versions.items(): + print(f"\t{app} \t {version}") + if apps_versions.get(app) != version: + print(f"WARNING!!! The running app is deployed with version {apps_versions.get(app)}") print("The following volume paths will be restored:") for p in backup_paths: print(f'\t{p}') @@ -133,6 +137,8 @@ def restore(snapshot, target, noninteractive, volumes, container, no_commands): print("The following commands will be executed:") for container, cmd in list(pre_commands.items()) + list(post_commands.items()): print(f"\t{container.labels['com.docker.swarm.service.name']}:\t{cmd}") + snapshot_date = datetime.fromisoformat(snapshots[0]['time']) + delta = datetime.now(tz=timezone.utc) - snapshot_date print(f"This snapshot is {delta} old") print("\nTHIS COMMAND WILL IRREVERSIBLY OVERWRITES FILES") prompt = input("Type YES (uppercase) to continue: ") @@ -162,18 +168,25 @@ def restic_restore(snapshot_id='latest', include=[], target_dir=None): def get_snapshots(snapshot_id=None): if snapshot_id and snapshot_id != 'latest': snapshots = restic.snapshots(snapshot_id=snapshot_id) - if SERVICE not in snapshots[0]['tags']: + if not SERVICE in app_versions_from_tags(snapshots[0].get('tags')): logger.error(f'Snapshot with ID {snapshot_id} does not contain {SERVICE}') exit(1) else: snapshots = restic.snapshots() - snapshots = list(filter(lambda x: x.get('tags') and SERVICE in x.get('tags'), snapshots)) + snapshots = list(filter(lambda x: SERVICE in app_versions_from_tags(x.get('tags')), snapshots)) if snapshot_id == 'latest': return snapshots[-1:] else: return snapshots +def app_versions_from_tags(tags): + if tags: + app_versions = map(lambda x: x.split(':'), tags) + return {i[0]: i[1] if len(i) > 1 else None for i in app_versions} + else: + return {} + def parse_backup_labels(hook_type='backup', selected_container=[]): client = docker.from_env() container_by_service = { @@ -185,9 +198,12 @@ def parse_backup_labels(hook_type='backup', selected_container=[]): labels = specs['Labels'] stack_name = labels['com.docker.stack.namespace'] container_name = s.name.removeprefix(f"{stack_name}_") + version = labels.get(f'coop-cloud.{stack_name}.version') settings = app_settings[stack_name] = app_settings.get(stack_name) or {} if (backup := labels.get('backupbot.backup')) and bool(backup): settings['enabled'] = True + if version: + settings['version'] = version if selected_container and container_name not in selected_container: logger.debug(f"Skipping {s.name} because it's not a selected container") continue @@ -216,20 +232,20 @@ def parse_backup_labels(hook_type='backup', selected_container=[]): def get_backup_details(app_settings, volumes=[]): backup_paths = set() - backup_apps = [] + backup_apps_versions = {} pre_hooks= {} post_hooks = {} for app, settings in app_settings.items(): if settings.get('enabled'): if SERVICE != 'ALL' and SERVICE != app: continue - backup_apps.append(app) + backup_apps_versions[app] = settings.get('version') add_backup_paths(backup_paths, settings, app, volumes) if hooks:= settings.get('pre_hooks'): pre_hooks.update(hooks) if hooks:= settings.get('post_hooks'): post_hooks.update(hooks) - return pre_hooks, post_hooks, list(backup_paths), backup_apps + return pre_hooks, post_hooks, list(backup_paths), backup_apps_versions def add_backup_paths(backup_paths, settings, app, selected_volumes): @@ -339,7 +355,7 @@ def run_commands(commands): logger.info(result.output.decode()) -def backup_volumes(backup_paths, apps, retries, dry_run=False): +def backup_volumes(backup_paths, apps_versions, retries, dry_run=False): while True: try: logger.info("Backup these paths:") @@ -350,7 +366,9 @@ def backup_volumes(backup_paths, apps, retries, dry_run=False): if parent: # https://restic.readthedocs.io/en/stable/040_backup.html#file-change-detection cmd.extend(['--parent', parent[0]['short_id']]) - tags = set(apps + [SERVICE]) + tags = [f"{app}:{version}" for app,version in apps_versions.items()] + if SERVICE == 'ALL': + tags.append(SERVICE) logger.info("Start volume backup") result = restic.internal.backup.run(cmd, backup_paths, dry_run=dry_run, tags=tags) logger.summary("backup finished", extra=result) @@ -374,7 +392,12 @@ def path_exists(path): def snapshots(): snapshots = get_snapshots() for snap in snapshots: - print(snap['time'], snap['id']) + output = [snap['time'], snap['id']] + if tags:= snap.get('tags'): + app_versions = app_versions_from_tags(tags) + if version:= app_versions.get(SERVICE): + output.append(version) + print(*output) if not snapshots: err_msg = "No Snapshots found" if SERVICE != 'ALL': @@ -395,7 +418,12 @@ def ls(snapshot, path): def list_files(snapshot, path): cmd = restic.cat.base_command() + ['ls'] - cmd = cmd + ['--tag', SERVICE] + if snapshot == 'latest': + latest_snapshot = get_snapshots('latest') + if not latest_snapshot: + logger.error(f"There is no latest snapshot for {SERVICE}") + exit(1) + snapshot = latest_snapshot[0]['short_id'] cmd.append(snapshot) if path: cmd.append(path) @@ -486,7 +514,13 @@ def get_formatted_size(file_path): def dump(snapshot, path): cmd = restic.cat.base_command() + ['dump'] - cmd = cmd + ['--tag', SERVICE] + if snapshot == 'latest': + latest_snapshot = get_snapshots('latest') + if not latest_snapshot: + logger.error(f"There is no latest snapshot for {SERVICE}") + exit(1) + snapshot = latest_snapshot[0]['short_id'] + cmd.append(snapshot) cmd = cmd + [snapshot, path] print(f"Dumping {path} from snapshot '{snapshot}'") output = subprocess.run(cmd, capture_output=True)