diff --git a/README.md b/README.md index bb3c1cd..bf36c10 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ configs: repo: backupuser@backuphost:root ``` -Normally you might not want to synchronize all the snapper snapshots to the remote backup destination, thus per-repo retention settings can be configured to determine which snapshots will actually be backed up. Note that by default, old snapshots will be pruned from the borg archive according to the retention settings, unless the `--no-prune` flag is given. +Normally you might not want to synchronize all the snapper snapshots to the remote backup destination, thus snapborg lets you configure per-repo retention settings to determine which snapshots will actually be backed up. + +If the snapshot did not have an associated cleanup policy, the backup will stay in the borg archive until you manually delete it. However, if the snapshot did have a cleanup policy, snapborg will keep the backup in the borg archive only until it expires according to the configured retention policy. + +Note that by default, old snapshots will be pruned from the borg archive when running `snapborg backup`, unless the `--no-prune` flag is given. *Example*: @@ -31,6 +35,8 @@ configs: ... ``` +In this example, the number of snapshots on your base system is irrelevant and is entirely handled by snapper itself. Your borg archive will have a maximum of 3 yearly snapshots, 6 monthly snapshots, and the latest snapshot, as well as any snapshot that you manually asked snapper to create (snapshots which don't have an associated cleanup policy). + ### Fault tolerant mode In some scenarios, the backup target repository is not permanently reachable, e. g. when an @@ -64,6 +70,19 @@ Commands: they are already backed up prune Prune old borg backup archives + --ignore-nameprefix-warning-this-is-permanent + Normally, the prune algorithm would only operate on + backups whose name starts with + `snapborg_retentionpolicy_` prefix. This flag disables + the restriction, and the pruning is run according to + the snapborg retention policy on all backups + regardless of their name. THIS MEANS THAT ALL BACKUPS + IN YOUR BORG ARCHIVE ARE SUSCEPTIBLE TO BEING PRUNED. + Use with caution. + --noconfirm when using the + `--ignore-nameprefix-warning-this-is-permanent` flag, + snapborg will prompt you for confirmation. This flag + disables the confirmation prompt. backup Backup snapper snapshots --recreate Delete possibly existing borg archives and recreate them from @@ -81,4 +100,4 @@ Commands: - borg - *Python*: - packaging - - pyyaml \ No newline at end of file + - pyyaml diff --git a/snapborg/borg.py b/snapborg/borg.py index 1aaceca..1117345 100644 --- a/snapborg/borg.py +++ b/snapborg/borg.py @@ -22,13 +22,21 @@ class BorgRepo: - def __init__(self, repopath: str, compression: str, retention, encryption="none", - passphrase=None): + def __init__( + self, + repopath: str, + compression: str, + retention, + encryption="none", + passphrase=None, + snapper_config_name: str = None, + ): self.repopath = repopath self.compression = compression self.retention = retention self.encryption = encryption self.passphrase = passphrase + self.snapper_config_name = snapper_config_name self.is_interactive = os.isatty(sys.stdout.fileno()) def init(self, dryrun=False): @@ -78,9 +86,25 @@ def delete(self, backup_name, dryrun=False): print_output=self.is_interactive, dryrun=dryrun) - def prune(self, override_retention_settings=None, dryrun=False): + def prune( + self, + override_retention_settings=None, + ignore_nameprefix=False, + confirm=True, + dryrun=False, + ): override_retention_settings = override_retention_settings or {} - borg_prune_invocation = ["prune"] + borg_prune_invocation = ["prune", "-P", "snapborg_retentionpolicy_"] + if ignore_nameprefix: + if confirm: + response = input( + f"For config {self.snapper_config_name or self.repopath}: Are you SURE you want to apply pruning to all backups? " + "Permanent loss of data can ensue. Type YES to continue: " + ) + if response != "YES": + raise Exception("Aborting!") + borg_prune_invocation = ["prune"] + retention_settings = selective_merge( override_retention_settings, self.retention, restrict_keys=True) for name, value in retention_settings.items(): @@ -117,8 +141,14 @@ def create_from_config(cls, config): password = get_password(config["storage"]["encryption_passphrase"]) else: raise Exception("Invalid or unsupported encryption mode given!") - return cls(borgrepo, compression, retention=retention, encryption=encryption, - passphrase=password) + return cls( + borgrepo, + compression, + retention=retention, + encryption=encryption, + passphrase=password, + snapper_config_name=config["name"], + ) def get_password(password): diff --git a/snapborg/commands/snapborg.py b/snapborg/commands/snapborg.py index 3871c64..6954ae7 100755 --- a/snapborg/commands/snapborg.py +++ b/snapborg/commands/snapborg.py @@ -47,8 +47,32 @@ def main(): help="The name of a snapper config to operate on") subp = cli.add_subparsers(dest="mode", required=True) - subp.add_parser("prune", help="Prune the borg archives using the retention settings from the " - "snapborg config file") + prunecli = subp.add_parser( + "prune", + help="Prune the borg archives using the retention settings from the " + "snapborg config file", + ) + prunecli.add_argument( + "--ignore-nameprefix-warning-this-is-permanent", + dest="ignore_nameprefix", + action="store_true", + help=( + "Normally, the prune algorithm would only operate on backups whose name starts with " + "`snapborg_retentionpolicy_` prefix. This flag disables the restriction, and the pruning " + "is run according to the snapborg retention policy on all backups regardless of their name. " + "THIS MEANS THAT ALL BACKUPS IN YOUR BORG ARCHIVE ARE SUSCEPTIBLE TO BEING PRUNED. " + "Use with caution." + ), + ) + prunecli.add_argument( + "--noconfirm", + action="store_true", + help=( + "when using the `--ignore-nameprefix-warning-this-is-permanent` flag, snapborg will prompt you " + "for confirmation. This flag disables the confirmation prompt." + ), + ) + subp.add_parser("list", help="List all snapper snapshots including their creation date and " "whether they have already been backed up by snapborg") backupcli = subp.add_parser( @@ -73,7 +97,13 @@ def main(): init(cfg, snapper_configs=configs, dryrun=args.dryrun) elif args.mode == "prune": - prune(cfg, snapper_configs=configs, dryrun=args.dryrun) + prune( + cfg, + snapper_configs=configs, + ignore_nameprefix=args.ignore_nameprefix, + confirm=not args.noconfirm, + dryrun=args.dryrun, + ) elif args.mode == "backup": backup(cfg, snapper_configs=configs, recreate=args.recreate, @@ -214,7 +244,14 @@ def backup_candidate(snapper_config, borg_repo, candidate, recreate, print(f"Backing up snapshot number {candidate.get_number()} " f"from {candidate.get_date().isoformat()}...") path_to_backup = candidate.get_path() - backup_name = f"{snapper_config.name}-{candidate.get_number()}-{candidate.get_date().isoformat()}" + backup_name = f"{snapper_config.name}_{candidate.get_number()}_{candidate.get_date().isoformat()}" + + # If there's a cleanup policy associated with the snapshot, then the snapshot was automatically made by snapper. + # If not, the snapshot was probably manual. If the snapshot was manually taken, we probably want to let the user + # manually delete it from the borg archive. + cleanup_policy = candidate.get_cleanup_policy() + if cleanup_policy is not None: + backup_name = f"snapborg_retentionpolicy_{backup_name}" try: if recreate: borg_repo.delete(backup_name, dryrun=dryrun) @@ -228,9 +265,11 @@ def backup_candidate(snapper_config, borg_repo, candidate, recreate, return False -def prune(cfg, snapper_configs, dryrun): +def prune(cfg, snapper_configs, dryrun, ignore_nameprefix=False, confirm=True): for config in snapper_configs: - BorgRepo.create_from_config(config).prune(dryrun=dryrun) + BorgRepo.create_from_config(config).prune( + ignore_nameprefix=ignore_nameprefix, confirm=confirm, dryrun=dryrun + ) def init(cfg, snapper_configs, dryrun): diff --git a/snapborg/retention.py b/snapborg/retention.py index fa07781..a4e6af6 100644 --- a/snapborg/retention.py +++ b/snapborg/retention.py @@ -54,4 +54,11 @@ def get_retained_snapshots(snapshots, date_key, keep_last=1, keep_minutely=0, ke retained.add(last_snapshot[1]) nr_keep -= 1 interval = (prev_date_fn(interval[0]), interval[0]) + + # backup all snapshots without any cleanup policy in snapper + retained.update( + snapshot[1] + for snapshot in with_date + if snapshot[1].get_cleanup_policy() is None + ) return list(retained) diff --git a/snapborg/snapper.py b/snapborg/snapper.py index a6398e1..e3f66f8 100644 --- a/snapborg/snapper.py +++ b/snapborg/snapper.py @@ -66,7 +66,7 @@ def get_snapshots(self): @classmethod def get(cls, config_name: str): return cls(config_name, run_snapper(["get-config"], config_name)) - + @contextmanager def prevent_cleanup(self, snapshots=None, dryrun=False): """ @@ -78,7 +78,7 @@ def prevent_cleanup(self, snapshots=None, dryrun=False): for s in snapshots: s.prevent_cleanup(dryrun=dryrun) - + try: yield self finally: @@ -108,6 +108,12 @@ def is_backed_up(self): def get_number(self): return self.info["number"] + def get_cleanup_policy(self): + if self._cleanup == "": + return None + else: + return self._cleanup + def purge_userdata(self, dryrun=False): run_snapper( ["modify", "--userdata", "snapborg_backup=", f"{self.get_number()}"],