Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*:

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -81,4 +100,4 @@ Commands:
- borg
- *Python*:
- packaging
- pyyaml
- pyyaml
42 changes: 36 additions & 6 deletions snapborg/borg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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):
Expand Down
51 changes: 45 additions & 6 deletions snapborg/commands/snapborg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions snapborg/retention.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
10 changes: 8 additions & 2 deletions snapborg/snapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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:
Expand Down Expand Up @@ -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()}"],
Expand Down